From 7e649d341e4b4488e6fef54527acd24d50d9d413 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 04:30:34 -0500 Subject: [PATCH 001/129] Chaining(fix[command]): Make command values immutable why: TmuxCommand retained caller-owned lists and compared those lists by identity, so a command could change after construction and equal values did not compare equal. NUL also cannot cross the tmux command boundary. what: - Copy and validate command arguments at construction - Compare command names, arguments, and server generations by value - Cover mutation, invalid tokens, and value equality with red-first tests --- src/LibTmux/Chaining/TmuxCommand.cs | 97 +++++++++++++++++-- .../Chaining/TmuxCommandTests.cs | 36 +++++++ 2 files changed, 127 insertions(+), 6 deletions(-) create mode 100644 tests/LibTmux.UnitTests/Chaining/TmuxCommandTests.cs diff --git a/src/LibTmux/Chaining/TmuxCommand.cs b/src/LibTmux/Chaining/TmuxCommand.cs index 9918ba1..844d8c2 100644 --- a/src/LibTmux/Chaining/TmuxCommand.cs +++ b/src/LibTmux/Chaining/TmuxCommand.cs @@ -1,8 +1,8 @@ +using System.Collections.ObjectModel; + namespace LibTmux; /// One tmux command and the arguments it carries. -/// The tmux command name, such as new-window. -/// Its arguments, separated as tmux will receive them. /// /// The typed methods on , , /// , and each run one command and @@ -10,18 +10,65 @@ namespace LibTmux; /// can be handed to tmux together through rather than /// one process at a time. /// -public sealed record TmuxCommand(string Name, IReadOnlyList Arguments) +public sealed record TmuxCommand { + private string _name = null!; + private ReadOnlyCollection _arguments = null!; + + /// Initializes a tmux command. + /// The tmux command name. + /// Its arguments. + public TmuxCommand(string Name, IReadOnlyList Arguments) + { + this.Name = Name; + this.Arguments = Arguments; + } + + /// Gets the tmux command name. + public string Name + { + get => _name; + init + { + ArgumentException.ThrowIfNullOrEmpty(value); + ValidateToken(value, nameof(Name)); + _name = value; + } + } + + /// Gets the arguments, separated as tmux will receive them. + public IReadOnlyList Arguments + { + get => _arguments; + init + { + ArgumentNullException.ThrowIfNull(value); + string[] copy = [.. value]; + if (copy.Any(static argument => argument is null)) + { + throw new ArgumentException("A tmux command argument cannot be null.", nameof(value)); + } + + foreach (string argument in copy) + { + ValidateToken(argument, nameof(Arguments)); + } + + _arguments = Array.AsReadOnly(copy); + } + } + /// Creates a command from its name and arguments. /// The tmux command name. /// Its arguments. /// The command. - /// is empty. + /// + /// The name is empty, an argument is null, or a token contains NUL. + /// public static TmuxCommand Create(string name, params string[] arguments) { - ArgumentException.ThrowIfNullOrEmpty(name); ArgumentNullException.ThrowIfNull(arguments); - return new TmuxCommand(name, [.. arguments]); + return new TmuxCommand(name, arguments); } /// Gets the server generation this command's target belongs to. @@ -40,4 +87,42 @@ public static TmuxCommand Create(string name, params string[] arguments) /// Returns this command the way tmux receives it. /// The command name followed by its arguments. public IReadOnlyList ToArguments() => [Name, .. Arguments]; + + /// + public bool Equals(TmuxCommand? other) => + other is not null + && string.Equals(Name, other.Name, StringComparison.Ordinal) + && RequiredGeneration == other.RequiredGeneration + && Arguments.SequenceEqual(other.Arguments, StringComparer.Ordinal); + + /// + public override int GetHashCode() + { + var hash = new HashCode(); + hash.Add(Name, StringComparer.Ordinal); + hash.Add(RequiredGeneration); + foreach (string argument in Arguments) + { + hash.Add(argument, StringComparer.Ordinal); + } + + return hash.ToHashCode(); + } + + /// Deconstructs the command into its name and arguments. + /// The command name. + /// The command arguments. + public void Deconstruct(out string Name, out IReadOnlyList Arguments) + { + Name = this.Name; + Arguments = this.Arguments; + } + + private static void ValidateToken(string value, string parameterName) + { + if (value.Contains('\0', StringComparison.Ordinal)) + { + throw new ArgumentException("Tmux command tokens cannot contain NUL.", parameterName); + } + } } diff --git a/tests/LibTmux.UnitTests/Chaining/TmuxCommandTests.cs b/tests/LibTmux.UnitTests/Chaining/TmuxCommandTests.cs new file mode 100644 index 0000000..64966e2 --- /dev/null +++ b/tests/LibTmux.UnitTests/Chaining/TmuxCommandTests.cs @@ -0,0 +1,36 @@ +namespace LibTmux.UnitTests.Chaining; + +public sealed class TmuxCommandTests +{ + [Fact] + public void Command_tokens_reject_nul_and_null_arguments() + { + Assert.Throws(() => TmuxCommand.Create("bad\0name")); + Assert.Throws( + () => TmuxCommand.Create("display-message", "bad\0argument")); + Assert.Throws( + () => new TmuxCommand("display-message", [null!])); + } + + [Fact] + public void Command_arguments_are_owned_by_the_value() + { + var arguments = new List { "original" }; + + var command = new TmuxCommand("display-message", arguments); + arguments[0] = "mutated"; + arguments.Add("injected"); + + Assert.Equal(["original"], command.Arguments); + } + + [Fact] + public void Equal_commands_compare_their_argument_values() + { + var left = new TmuxCommand("display-message", ["-p", "value"]); + var right = new TmuxCommand("display-message", ["-p", "value"]); + + Assert.Equal(left, right); + Assert.Equal(left.GetHashCode(), right.GetHashCode()); + } +} From 484afc47671c29f08a5a6834fb2d32c0faced761 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 04:54:45 -0500 Subject: [PATCH 002/129] ControlMode(fix[correlation]): Fence typed command replies why: Tmux emits one control block per parsed command, not per input line, and interleaves unflagged hook blocks. Raw lines, aliases, and semicolon sequences could therefore move one caller's output to another caller. what: - Accept immutable TmuxCommand values and validate their server generation - Render literal argv and collect flagged blocks through a private parser fence - Preserve alignment after cancellation and keep typed command diagnostics - Update consumers, examples, public contracts, and real-tmux coverage --- README.md | 4 +- .../LibTmux.Benchmarks/ModeBenchmarks.cs | 2 +- docs/api/README.md | 13 +- docs/modes/control-mode.md | 4 +- docs/modes/matrix.md | 2 +- docs/public-api.json | 114 ++++++++- docs/public-api.md | 12 +- eng/parity/tests/test_production_plan.py | 1 + eng/parity/verify_production_plan.py | 1 + .../LibTmux.Examples/Snippets/ControlMode.cs | 4 +- .../Streaming/HierarchyEndpointWatch.cs | 4 +- src/LibTmux.Mcp/Streaming/PaneActivityHub.cs | 4 +- .../ControlMode/ControlModeCommandRenderer.cs | 47 ++++ src/LibTmux/ControlMode/ControlModeGuard.cs | 61 +++++ src/LibTmux/ControlMode/ControlModeSession.cs | 228 ++++++++++++++---- .../ControlMode/IControlModeSession.cs | 13 +- .../Exceptions/ControlModeCommandException.cs | 35 +++ src/LibTmux/PublicAPI.Unshipped.txt | 7 +- src/LibTmux/README.md | 4 +- src/LibTmux/Server.ControlMode.cs | 4 +- .../ControlMode/ControlModeSessionTests.cs | 138 ++++++++++- .../ControlModeCorrelationTests.cs | 222 +++++++++++++++++ .../ControlModeSessionFailureTests.cs | 97 +++++--- .../Mcp/HierarchyWatcherLifecycleTests.cs | 4 +- .../Mcp/PaneActivityHubLifecycleTests.cs | 4 +- 25 files changed, 916 insertions(+), 113 deletions(-) create mode 100644 src/LibTmux/ControlMode/ControlModeCommandRenderer.cs create mode 100644 src/LibTmux/ControlMode/ControlModeGuard.cs create mode 100644 src/LibTmux/Exceptions/ControlModeCommandException.cs create mode 100644 tests/LibTmux.UnitTests/ControlMode/ControlModeCorrelationTests.cs diff --git a/README.md b/README.md index baecfa5..27b35b7 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,9 @@ Console.WriteLine(built.Name); ```csharp run // Control mode: one client, held open, streaming what tmux does. await using IControlModeSession control = await server.EnterControlModeAsync(cancellationToken: ct); -IReadOnlyList reply = await control.SendAsync("new-window -d -n build", ct); +IReadOnlyList reply = await control.SendAsync( + TmuxCommand.Create("new-window", "-d", "-n", "build"), + ct); ``` ```csharp run diff --git a/benchmarks/LibTmux.Benchmarks/ModeBenchmarks.cs b/benchmarks/LibTmux.Benchmarks/ModeBenchmarks.cs index b90c103..0918b6c 100644 --- a/benchmarks/LibTmux.Benchmarks/ModeBenchmarks.cs +++ b/benchmarks/LibTmux.Benchmarks/ModeBenchmarks.cs @@ -75,7 +75,7 @@ public async Task ControlMode() { for (int index = 0; index < Commands; index++) { - await _control.SendAsync("display-message -p bench"); + await _control.SendAsync(TmuxCommand.Create("display-message", "-p", "bench")); } } } diff --git a/docs/api/README.md b/docs/api/README.md index 82bfc1a..ea44441 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -22,6 +22,7 @@ modes differ. | `LibTmux.ClientAttachment` | What one client is looking at. | | `LibTmux.CommandPromptRequest` | Describes one command-prompt invocation. | | `LibTmux.ConfirmBeforeRequest` | Describes one confirm-before invocation. | +| `LibTmux.ControlModeCommandException` | Reports a command rejected by a live tmux control client. | | `LibTmux.CopyModeRequest` | Describes one copy-mode invocation. | | `LibTmux.DisplayMenuRequest` | Describes one display-menu invocation. | | `LibTmux.DisplayMessageRequest` | Describes one display-message invocation. | @@ -189,6 +190,7 @@ modes differ. | `LibTmux.ClientAttachment.#ctor(LibTmux.Session,LibTmux.Window,LibTmux.Pane)` | What one client is looking at. | | `LibTmux.CommandPromptRequest.#ctor(System.String,System.String,System.String,System.String,System.Boolean,System.Boolean,System.Boolean,System.Boolean,System.Nullable{LibTmux.PromptType},System.Boolean,System.Boolean,System.Boolean,System.Boolean)` | Initializes a command prompt. | | `LibTmux.ConfirmBeforeRequest.#ctor(System.Collections.Generic.IReadOnlyList{System.String},System.String,System.String,System.Boolean,System.String)` | Initializes a confirmation. | +| `LibTmux.ControlModeCommandException.#ctor(System.String,LibTmux.TmuxCommand,System.Collections.Generic.IReadOnlyList{System.String},System.Collections.Generic.IReadOnlyList{System.String},System.Exception)` | Initializes a control-mode command exception. | | `LibTmux.CopyModeRequest.#ctor(System.Boolean,System.Boolean,System.Boolean,System.Boolean,System.Boolean,System.String)` | Initializes a copy-mode request. | | `LibTmux.DisplayMenuRequest.#ctor(System.Collections.Generic.IReadOnlyList{LibTmux.TmuxMenuItem},System.String,System.String,System.String,System.String,System.String,System.String,System.String,System.String,System.String,System.String,System.Boolean,System.Boolean)` | Initializes a menu. | | `LibTmux.DisplayMessageRequest.#ctor(System.String,System.Boolean,System.String,System.Boolean,System.Boolean,System.Boolean,System.String,System.Nullable{System.TimeSpan},System.Boolean,System.Boolean)` | Initializes a display-message request. | @@ -197,7 +199,7 @@ modes differ. | `LibTmux.GetOptionRequest.#ctor(System.String,System.Nullable{LibTmux.OptionScope},System.Boolean,System.Boolean,System.Boolean,System.Boolean)` | Initializes a request for one option. | | `LibTmux.GetOptionsRequest.#ctor(System.Nullable{LibTmux.OptionScope},System.Boolean,System.Boolean,System.Boolean,System.Boolean)` | Initializes a request for every option in a scope. | | `LibTmux.HookRequest.#ctor(System.String,System.Nullable{LibTmux.OptionScope},System.Boolean)` | Initializes a request naming one hook. | -| `LibTmux.IControlModeSession.SendAsync(System.String,System.Threading.CancellationToken)` | Runs one command on this client and reads what it answered. | +| `LibTmux.IControlModeSession.SendAsync(LibTmux.TmuxCommand,System.Threading.CancellationToken)` | Runs one command on this client and reads what it answered. | | `LibTmux.IfShellRequest.#ctor(System.String,System.Collections.Generic.IReadOnlyList{System.String},System.Collections.Generic.IReadOnlyList{System.String},System.Boolean,System.String)` | Initializes a conditional command. | | `LibTmux.IncompleteSnapshotException.#ctor(System.String,LibTmux.SnapshotDepth)` | Initializes the exception for one uncaptured relation. | | `LibTmux.LibTmuxException.#ctor(System.String,LibTmux.TmuxDispatchState,System.Exception)` | Initializes a LibTmux exception that knows whether tmux ran the command. | @@ -495,7 +497,7 @@ modes differ. | `LibTmux.TmuxChaining.ToRunCommand(LibTmux.HookRequest,LibTmux.TmuxHooks)` | Returns running a hook as one tmux command. | | `LibTmux.TmuxChaining.ToUnsetCommand(LibTmux.HookRequest,LibTmux.TmuxHooks)` | Returns removing a hook as one tmux command. | | `LibTmux.TmuxCleanupException.#ctor(System.String,System.OperationCanceledException,System.Int32,System.Exception)` | Initializes a cleanup exception. | -| `LibTmux.TmuxCommand.#ctor(System.String,System.Collections.Generic.IReadOnlyList{System.String})` | One tmux command and the arguments it carries. | +| `LibTmux.TmuxCommand.#ctor(System.String,System.Collections.Generic.IReadOnlyList{System.String})` | Initializes a tmux command. | | `LibTmux.TmuxCommand.Create(System.String,System.String[])` | Creates a command from its name and arguments. | | `LibTmux.TmuxCommand.ToArguments` | Returns this command the way tmux receives it. | | `LibTmux.TmuxCommandException.#ctor(System.String,LibTmux.TmuxCommandResult,System.Exception)` | Initializes a command exception. | @@ -661,6 +663,9 @@ modes differ. | `LibTmux.ConfirmBeforeRequest.DefaultYes` | Gets whether pressing enter confirms rather than cancels. | | `LibTmux.ConfirmBeforeRequest.Prompt` | Gets the question shown, or null for tmux's own wording. | | `LibTmux.ConfirmBeforeRequest.TargetClient` | Gets the client to ask, or null for the caller's own. | +| `LibTmux.ControlModeCommandException.Command` | Gets the command tmux rejected. | +| `LibTmux.ControlModeCommandException.ErrorLines` | Gets the error lines tmux reported. | +| `LibTmux.ControlModeCommandException.OutputLines` | Gets output produced before tmux rejected the command. | | `LibTmux.CopyModeRequest.Cancel` | Gets whether copy mode is left instead of entered. | | `LibTmux.CopyModeRequest.ExitOnBottom` | Gets whether reaching the bottom leaves copy mode. | | `LibTmux.CopyModeRequest.MouseDrag` | Gets whether the mode is entered for a mouse drag. | @@ -1045,8 +1050,8 @@ modes differ. | `LibTmux.TmuxCleanupException.CleanupFailure` | Gets the cleanup failure. | | `LibTmux.TmuxCleanupException.ClientProcessId` | Gets the disposable client process identifier. | | `LibTmux.TmuxCleanupException.OriginalCancellation` | Gets the original cancellation. | -| `LibTmux.TmuxCommand.Arguments` | Its arguments, separated as tmux will receive them. | -| `LibTmux.TmuxCommand.Name` | The tmux command name, such as new-window. | +| `LibTmux.TmuxCommand.Arguments` | Gets the arguments, separated as tmux will receive them. | +| `LibTmux.TmuxCommand.Name` | Gets the tmux command name. | | `LibTmux.TmuxCommandException.Result` | Gets the inspectable command result. | | `LibTmux.TmuxCommandNotFoundException.TmuxBinaryPath` | Gets the configured tmux executable path. | | `LibTmux.TmuxCommandResult.Arguments` | Gets the logical tmux arguments. | diff --git a/docs/modes/control-mode.md b/docs/modes/control-mode.md index 9e11244..a1e7990 100644 --- a/docs/modes/control-mode.md +++ b/docs/modes/control-mode.md @@ -8,7 +8,7 @@ producing output, windows appearing, sessions changing. ```csharp await using IControlModeSession control = await server.EnterControlModeAsync(cancellationToken: ct); -await control.SendAsync("new-window -d -n build", ct); +await control.SendAsync(TmuxCommand.Create("new-window", "-d", "-n", "build"), ct); await foreach (TmuxEvent observed in control.Events.WithCancellation(ct)) { @@ -59,7 +59,7 @@ The marker arrives in sequence, where the discarded events would have been: ```csharp await using IControlModeSession control = await server.EnterControlModeAsync(cancellationToken: ct); -await control.SendAsync("new-window -d -n build", ct); +await control.SendAsync(TmuxCommand.Create("new-window", "-d", "-n", "build"), ct); await foreach (TmuxEvent observed in control.Events.WithCancellation(ct)) { diff --git a/docs/modes/matrix.md b/docs/modes/matrix.md index aecd500..0eafe81 100644 --- a/docs/modes/matrix.md +++ b/docs/modes/matrix.md @@ -23,7 +23,7 @@ Window window = await session.CreateWindowAsync(new NewWindowRequest(name: "buil ```csharp await using IControlModeSession control = await server.EnterControlModeAsync(cancellationToken: ct); -await control.SendAsync("new-window -d -n build", ct); +await control.SendAsync(TmuxCommand.Create("new-window", "-d", "-n", "build"), ct); ``` ```csharp diff --git a/docs/public-api.json b/docs/public-api.json index f8378d0..fcdb4a9 100644 --- a/docs/public-api.json +++ b/docs/public-api.json @@ -3064,6 +3064,26 @@ "state": [], "summary": "A live tmux control client reporting what tmux does until disposed." }, + { + "id": "T:LibTmux.ControlModeCommandException", + "namespace": "LibTmux", + "name": "ControlModeCommandException", + "kind": "class", + "package": "LibTmux", + "modifiers": [ + "public", + "sealed" + ], + "baseType": "LibTmuxException", + "interfaces": [], + "ownership": "reference", + "state": [ + "Command", + "OutputLines", + "ErrorLines" + ], + "summary": "Reports a command rejected by a live tmux control client." + }, { "id": "T:LibTmux.TmuxEvent", "namespace": "LibTmux", @@ -24939,6 +24959,16 @@ "signature": "interface LibTmux.IControlModeSession", "portable": true }, + { + "id": "T:LibTmux.ControlModeCommandException", + "declaringType": "T:LibTmux.ControlModeCommandException", + "name": "ControlModeCommandException", + "kind": "type", + "visibility": "public", + "package": "LibTmux", + "signature": "sealed class LibTmux.ControlModeCommandException", + "portable": true + }, { "id": "T:LibTmux.TmuxEvent", "declaringType": "T:LibTmux.TmuxEvent", @@ -25048,7 +25078,7 @@ "summary": "Gets whether the client is still running." }, { - "id": "M:LibTmux.IControlModeSession.SendAsync(string,System.Threading.CancellationToken)", + "id": "M:LibTmux.IControlModeSession.SendAsync(LibTmux.TmuxCommand,System.Threading.CancellationToken)", "declaringType": "T:LibTmux.IControlModeSession", "name": "SendAsync", "kind": "method", @@ -25059,17 +25089,95 @@ "parameters": [ { "name": "command", - "type": "string" + "type": "TmuxCommand" }, { "name": "cancellationToken", "type": "System.Threading.CancellationToken" } ], - "signature": "Task> SendAsync(string command, CancellationToken cancellationToken = default)", + "signature": "Task> SendAsync(TmuxCommand command, CancellationToken cancellationToken = default)", "portable": true, "summary": "Runs one command on this client and reads what it answered." }, + { + "id": "M:LibTmux.ControlModeCommandException.#ctor(string,TmuxCommand,System.Collections.Generic.IReadOnlyList{string},System.Collections.Generic.IReadOnlyList{string},Exception?)", + "declaringType": "T:LibTmux.ControlModeCommandException", + "name": ".ctor", + "kind": "constructor", + "visibility": "public", + "package": "LibTmux", + "static": false, + "returnType": "ControlModeCommandException", + "parameters": [ + { + "name": "message", + "type": "string" + }, + { + "name": "command", + "type": "TmuxCommand" + }, + { + "name": "outputLines", + "type": "IReadOnlyList" + }, + { + "name": "errorLines", + "type": "IReadOnlyList" + }, + { + "name": "innerException", + "type": "Exception?", + "default": "null" + } + ], + "signature": "ControlModeCommandException(string message, TmuxCommand command, IReadOnlyList outputLines, IReadOnlyList errorLines, Exception? innerException = null)", + "portable": true, + "summary": "Initializes a control-mode command exception." + }, + { + "id": "P:LibTmux.ControlModeCommandException.Command", + "declaringType": "T:LibTmux.ControlModeCommandException", + "name": "Command", + "kind": "property", + "visibility": "public", + "package": "LibTmux", + "static": false, + "returnType": "TmuxCommand", + "parameters": [], + "signature": "TmuxCommand LibTmux.ControlModeCommandException.Command { get; }", + "portable": true, + "summary": "Gets the command tmux rejected." + }, + { + "id": "P:LibTmux.ControlModeCommandException.OutputLines", + "declaringType": "T:LibTmux.ControlModeCommandException", + "name": "OutputLines", + "kind": "property", + "visibility": "public", + "package": "LibTmux", + "static": false, + "returnType": "IReadOnlyList", + "parameters": [], + "signature": "IReadOnlyList LibTmux.ControlModeCommandException.OutputLines { get; }", + "portable": true, + "summary": "Gets output produced before tmux rejected the command." + }, + { + "id": "P:LibTmux.ControlModeCommandException.ErrorLines", + "declaringType": "T:LibTmux.ControlModeCommandException", + "name": "ErrorLines", + "kind": "property", + "visibility": "public", + "package": "LibTmux", + "static": false, + "returnType": "IReadOnlyList", + "parameters": [], + "signature": "IReadOnlyList LibTmux.ControlModeCommandException.ErrorLines { get; }", + "portable": true, + "summary": "Gets the error lines tmux reported." + }, { "id": "M:LibTmux.TmuxOutputEvent.#ctor(string,string)", "declaringType": "T:LibTmux.TmuxOutputEvent", diff --git a/docs/public-api.md b/docs/public-api.md index 284084c..57a4a8e 100644 --- a/docs/public-api.md +++ b/docs/public-api.md @@ -437,6 +437,7 @@ internal static class Program | `T:LibTmux.WindowResizeMode` | enum | `public` | None | `Enum` | value | Defines WindowResizeMode values. | `LibTmux` | | `T:LibTmux.WindowRotationDirection` | enum | `public` | None | `Enum` | value | Defines WindowRotationDirection values. | `LibTmux` | | `T:LibTmux.IControlModeSession` | interface | `public` | `System.IAsyncDisposable` | `None` | reference | A live tmux control client reporting what tmux does until disposed. | `LibTmux` | +| `T:LibTmux.ControlModeCommandException` | class | `public, sealed` | None | `LibTmuxException` | reference | Reports a command rejected by a live tmux control client. State: Command, OutputLines, ErrorLines. | `LibTmux` | | `T:LibTmux.TmuxEvent` | record | `public, abstract` | None | `object` | value | One thing a tmux control client reported without being asked. | `LibTmux` | | `T:LibTmux.TmuxEventsDroppedEvent` | record | `public, sealed` | None | `LibTmux.TmuxEvent` | value | A loss marker emitted when the bounded control-event buffer overflows. | `LibTmux` | | `T:LibTmux.TmuxOutputEvent` | record | `public, sealed` | None | `LibTmux.TmuxEvent` | value | Bytes a pane wrote, with tmux's escaping decoded. | `LibTmux` | @@ -589,6 +590,15 @@ internal static class Program | `P:LibTmux.ConfirmBeforeRequest.Prompt` | `string? LibTmux.ConfirmBeforeRequest.Prompt { get; }` | Public | No | Portable | Gets Prompt. | | `P:LibTmux.ConfirmBeforeRequest.TargetClient` | `string? LibTmux.ConfirmBeforeRequest.TargetClient { get; }` | Public | No | Portable | Gets TargetClient. | +### `T:LibTmux.ControlModeCommandException` + +| Member ID | Declaration | Visibility | Static | Platform | Notes | +| --- | --- | --- | --- | --- | --- | +| `M:LibTmux.ControlModeCommandException.#ctor(string,TmuxCommand,System.Collections.Generic.IReadOnlyList{string},System.Collections.Generic.IReadOnlyList{string},Exception?)` | `ControlModeCommandException(string message, TmuxCommand command, IReadOnlyList outputLines, IReadOnlyList errorLines, Exception? innerException = null)` | Public | No | Portable | Initializes a control-mode command exception. | +| `P:LibTmux.ControlModeCommandException.Command` | `TmuxCommand LibTmux.ControlModeCommandException.Command { get; }` | Public | No | Portable | Gets the command tmux rejected. | +| `P:LibTmux.ControlModeCommandException.ErrorLines` | `IReadOnlyList LibTmux.ControlModeCommandException.ErrorLines { get; }` | Public | No | Portable | Gets the error lines tmux reported. | +| `P:LibTmux.ControlModeCommandException.OutputLines` | `IReadOnlyList LibTmux.ControlModeCommandException.OutputLines { get; }` | Public | No | Portable | Gets output produced before tmux rejected the command. | + ### `T:LibTmux.CopyModeRequest` | Member ID | Declaration | Visibility | Static | Platform | Notes | @@ -707,7 +717,7 @@ internal static class Program | Member ID | Declaration | Visibility | Static | Platform | Notes | | --- | --- | --- | --- | --- | --- | -| `M:LibTmux.IControlModeSession.SendAsync(string,System.Threading.CancellationToken)` | `Task> SendAsync(string command, CancellationToken cancellationToken = default)` | Public | No | Portable | Runs one command on this client and reads what it answered. | +| `M:LibTmux.IControlModeSession.SendAsync(LibTmux.TmuxCommand,System.Threading.CancellationToken)` | `Task> SendAsync(TmuxCommand command, CancellationToken cancellationToken = default)` | Public | No | Portable | Runs one command on this client and reads what it answered. | | `P:LibTmux.IControlModeSession.Events` | `IAsyncEnumerable LibTmux.IControlModeSession.Events { get; }` | Public | No | Portable | Reads what tmux reports for as long as the client runs. | | `P:LibTmux.IControlModeSession.IsRunning` | `bool LibTmux.IControlModeSession.IsRunning { get; }` | Public | No | Portable | Gets whether the client is still running. | diff --git a/eng/parity/tests/test_production_plan.py b/eng/parity/tests/test_production_plan.py index 225425a..a612ae5 100644 --- a/eng/parity/tests/test_production_plan.py +++ b/eng/parity/tests/test_production_plan.py @@ -323,6 +323,7 @@ COMPONENT_API_TYPES: dict[int, tuple[str, ...]] = { 1: ( "T:LibTmux.Client", + "T:LibTmux.ControlModeCommandException", "T:LibTmux.IControlModeSession", "T:LibTmux.Internal.TmuxCommandDispatcher", "T:LibTmux.Internal.TmuxCommandFailure", diff --git a/eng/parity/verify_production_plan.py b/eng/parity/verify_production_plan.py index 6bcee71..2215475 100644 --- a/eng/parity/verify_production_plan.py +++ b/eng/parity/verify_production_plan.py @@ -336,6 +336,7 @@ COMPONENT_API_TYPES: dict[int, tuple[str, ...]] = { 1: ( "T:LibTmux.Client", + "T:LibTmux.ControlModeCommandException", "T:LibTmux.IControlModeSession", "T:LibTmux.Internal.TmuxCommandDispatcher", "T:LibTmux.Internal.TmuxCommandFailure", diff --git a/examples/LibTmux.Examples/Snippets/ControlMode.cs b/examples/LibTmux.Examples/Snippets/ControlMode.cs index 591cfbf..e7bdd57 100644 --- a/examples/LibTmux.Examples/Snippets/ControlMode.cs +++ b/examples/LibTmux.Examples/Snippets/ControlMode.cs @@ -13,7 +13,7 @@ public static async Task WatchForWindowAdd(Server server, CancellationToken ct) #region WatchForWindowAdd await using IControlModeSession control = await server.EnterControlModeAsync(cancellationToken: ct); - await control.SendAsync("new-window -d -n build", ct); + await control.SendAsync(TmuxCommand.Create("new-window", "-d", "-n", "build"), ct); await foreach (TmuxEvent observed in control.Events.WithCancellation(ct)) { @@ -33,7 +33,7 @@ public static async Task NoticeDroppedEvents(Server server, CancellationToken ct #region NoticeDroppedEvents await using IControlModeSession control = await server.EnterControlModeAsync(cancellationToken: ct); - await control.SendAsync("new-window -d -n build", ct); + await control.SendAsync(TmuxCommand.Create("new-window", "-d", "-n", "build"), ct); await foreach (TmuxEvent observed in control.Events.WithCancellation(ct)) { diff --git a/src/LibTmux.Mcp/Streaming/HierarchyEndpointWatch.cs b/src/LibTmux.Mcp/Streaming/HierarchyEndpointWatch.cs index 267dcfe..e976f95 100644 --- a/src/LibTmux.Mcp/Streaming/HierarchyEndpointWatch.cs +++ b/src/LibTmux.Mcp/Streaming/HierarchyEndpointWatch.cs @@ -376,7 +376,9 @@ private async Task StartTransitionAsync( .ConfigureAwait(false); starting = session; await session - .SendAsync("refresh-client -f ignore-size,no-output", cancellationToken) + .SendAsync( + TmuxCommand.Create("refresh-client", "-f", "ignore-size,no-output"), + cancellationToken) .ConfigureAwait(false); WatchRun run = new(session); diff --git a/src/LibTmux.Mcp/Streaming/PaneActivityHub.cs b/src/LibTmux.Mcp/Streaming/PaneActivityHub.cs index 344d6d2..84f57e8 100644 --- a/src/LibTmux.Mcp/Streaming/PaneActivityHub.cs +++ b/src/LibTmux.Mcp/Streaming/PaneActivityHub.cs @@ -324,7 +324,9 @@ internal async Task AcquireAsync( // A listening client must ignore size or it can shrink the session's windows. // The flag is available throughout the supported tmux range. - await starting.SendAsync("refresh-client -f ignore-size", cancellationToken) + await starting.SendAsync( + TmuxCommand.Create("refresh-client", "-f", "ignore-size"), + cancellationToken) .ConfigureAwait(false); WatchRun run = new(starting); diff --git a/src/LibTmux/ControlMode/ControlModeCommandRenderer.cs b/src/LibTmux/ControlMode/ControlModeCommandRenderer.cs new file mode 100644 index 0000000..0cdf1dd --- /dev/null +++ b/src/LibTmux/ControlMode/ControlModeCommandRenderer.cs @@ -0,0 +1,47 @@ +using System.Text; + +namespace LibTmux; + +/// Renders typed argv as one physical tmux control-input line. +internal static class ControlModeCommandRenderer +{ + internal static string Render(TmuxCommand command) + { + ArgumentNullException.ThrowIfNull(command); + var rendered = new StringBuilder(); + foreach (string token in command.ToArguments()) + { + if (rendered.Length > 0) + { + rendered.Append(' '); + } + + AppendToken(rendered, token); + } + + return rendered.ToString(); + } + + private static void AppendToken(StringBuilder rendered, string token) + { + if (token.Contains('\r', StringComparison.Ordinal) + || token.Contains('\n', StringComparison.Ordinal)) + { + foreach (byte value in Encoding.UTF8.GetBytes(token)) + { + rendered.Append('\\'); + rendered.Append(Convert.ToString(value, 8).PadLeft(3, '0')); + } + + return; + } + + rendered.Append('\''); + foreach (char character in token) + { + rendered.Append(character == '\'' ? "'\"'\"'" : character); + } + + rendered.Append('\''); + } +} diff --git a/src/LibTmux/ControlMode/ControlModeGuard.cs b/src/LibTmux/ControlMode/ControlModeGuard.cs new file mode 100644 index 0000000..883aa9f --- /dev/null +++ b/src/LibTmux/ControlMode/ControlModeGuard.cs @@ -0,0 +1,61 @@ +using System.Globalization; + +namespace LibTmux; + +internal enum ControlModeGuardKind +{ + Begin, + End, + Error, +} + +/// One parsed tmux control-mode block guard. +internal readonly record struct ControlModeGuard( + ControlModeGuardKind Kind, + long Timestamp, + long Number, + int Flags) +{ + internal bool Matches(ControlModeGuard begin) => + Timestamp == begin.Timestamp + && Number == begin.Number + && Flags == begin.Flags; + + internal static bool TryParse(string line, out ControlModeGuard guard) + { + string[] fields = line.Split(' '); + ControlModeGuardKind? kind = fields.Length == 4 + ? fields[0] switch + { + "%begin" => ControlModeGuardKind.Begin, + "%end" => ControlModeGuardKind.End, + "%error" => ControlModeGuardKind.Error, + _ => null, + } + : null; + if (kind is null + || !long.TryParse( + fields[1], + NumberStyles.None, + CultureInfo.InvariantCulture, + out long timestamp) + || !long.TryParse( + fields[2], + NumberStyles.None, + CultureInfo.InvariantCulture, + out long number) + || !int.TryParse( + fields[3], + NumberStyles.None, + CultureInfo.InvariantCulture, + out int flags) + || flags is not (0 or 1)) + { + guard = default; + return false; + } + + guard = new ControlModeGuard(kind.Value, timestamp, number, flags); + return true; + } +} diff --git a/src/LibTmux/ControlMode/ControlModeSession.cs b/src/LibTmux/ControlMode/ControlModeSession.cs index 1856ff1..b545898 100644 --- a/src/LibTmux/ControlMode/ControlModeSession.cs +++ b/src/LibTmux/ControlMode/ControlModeSession.cs @@ -1,6 +1,7 @@ using System.Diagnostics; using System.Runtime.ExceptionServices; using System.Runtime.Versioning; +using System.Security.Cryptography; using LibTmux.Internal; namespace LibTmux; @@ -35,7 +36,9 @@ public Task WriteLineAsync( internal sealed class ControlModeSession : IControlModeSession { private readonly IControlModeProcess _process; + private readonly ServerGeneration? _generation; private readonly TimeSpan _exitBudget; + private readonly Func _sentinelFactory; /// How many unread events are held before the oldest are dropped. /// /// A pane can outpace any reader, and a caller may never read @@ -68,11 +71,15 @@ internal sealed class ControlModeSession : IControlModeSession internal ControlModeSession( IControlModeProcess process, SemaphoreSlim? writeLock = null, - TimeSpan? exitBudget = null) + TimeSpan? exitBudget = null, + ServerGeneration? generation = null, + Func? sentinelFactory = null) { _process = process ?? throw new ArgumentNullException(nameof(process)); + _generation = generation; _writeLock = writeLock ?? new SemaphoreSlim(1, 1); _exitBudget = exitBudget ?? DefaultExitBudget; + _sentinelFactory = sentinelFactory ?? CreateSentinel; if (_exitBudget <= TimeSpan.Zero) { throw new ArgumentOutOfRangeException(nameof(exitBudget)); @@ -90,6 +97,7 @@ internal static ControlModeSession Start( string tmuxBinaryPath, IReadOnlyList prefixArguments, string? target, + ServerGeneration generation, Action configureEnvironment) { // Draining stderr can hang when tmux hands its pipe to the longer-lived server. @@ -121,25 +129,43 @@ internal static ControlModeSession Start( configureEnvironment(startInfo); Process process = Process.Start(startInfo) ?? throw new InvalidOperationException("The tmux control client did not start."); - return new ControlModeSession(new SystemControlModeProcess(process)); + return new ControlModeSession( + new SystemControlModeProcess(process), + generation: generation); } /// Waits until tmux has answered its own attach. internal Task WaitForReadyAsync(CancellationToken cancellationToken) => _ready.Task.WaitAsync(cancellationToken); - public async Task> SendAsync( - string command, + public Task> SendAsync( + TmuxCommand command, CancellationToken cancellationToken = default) { - ArgumentException.ThrowIfNullOrEmpty(command); + ArgumentNullException.ThrowIfNull(command); + ValidateGeneration(command); ThrowIfStopping(); if (_process.HasExited) { throw new InvalidOperationException("The tmux control client has exited."); } - PendingCommand pending = new(); + string sentinel = _sentinelFactory(); + var pending = new PendingCommand(command, sentinel); + Task> transaction = DispatchAndWaitAsync( + ControlModeCommandRenderer.Render(command), + pending, + cancellationToken); + return cancellationToken.CanBeCanceled + ? WaitForCallerAsync(transaction, cancellationToken) + : transaction; + } + + private async Task> DispatchAndWaitAsync( + string commandLine, + PendingCommand pending, + CancellationToken cancellationToken) + { Exception? dispatchFailure = null; // Queueing and writing happen together under one lock. tmux answers in @@ -164,9 +190,10 @@ public async Task> SendAsync( try { - await _process.WriteLineAsync(command.AsMemory(), cancellationToken) + string framedCommand = $"{commandLine}\n{pending.Sentinel}"; + await _process.WriteLineAsync(framedCommand.AsMemory(), CancellationToken.None) .ConfigureAwait(false); - await _process.FlushAsync(cancellationToken).ConfigureAwait(false); + await _process.FlushAsync(CancellationToken.None).ConfigureAwait(false); } catch (Exception error) { @@ -199,9 +226,54 @@ await _process.WriteLineAsync(command.AsMemory(), cancellationToken) ExceptionDispatchInfo.Capture(dispatchFailure).Throw(); } - return await pending.Completion.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + return await pending.Completion.Task.ConfigureAwait(false); + } + + private static async Task> WaitForCallerAsync( + Task> transaction, + CancellationToken cancellationToken) + { + try + { + return await transaction.WaitAsync(cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + _ = transaction.ContinueWith( + static completed => _ = completed.Exception, + CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted + | TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + throw; + } + } + + private void ValidateGeneration(TmuxCommand command) + { + if (command.RequiredGeneration is not ServerGeneration expected) + { + return; + } + + if (_generation is not ServerGeneration actual) + { + throw new InvalidOperationException( + "The control client has no server generation to validate the command against."); + } + + if (expected != actual) + { + throw new StaleServerGenerationException( + "The command targets a different tmux server generation.", + expected, + actual); + } } + private static string CreateSentinel() => + $"libtmux-control-{Convert.ToHexString(RandomNumberGenerator.GetBytes(32))}"; + public async ValueTask DisposeAsync() { Task disposal; @@ -432,8 +504,16 @@ private void ThrowIfStopping() => ObjectDisposedException.ThrowIf(Volatile.Read(ref _stopRequested) != 0, this); /// One waiting command. - private sealed class PendingCommand + private sealed class PendingCommand(TmuxCommand command, string sentinel) { + internal TmuxCommand Command { get; } = command; + + internal List ErrorLines { get; } = []; + + internal List OutputLines { get; } = []; + + internal string Sentinel { get; } = sentinel; + internal TaskCompletionSource> Completion { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); } @@ -448,7 +528,14 @@ private async Task PumpAsync() { if (line.StartsWith("%begin ", StringComparison.Ordinal)) { - await ReadBlockAsync(line).ConfigureAwait(false); + if (!ControlModeGuard.TryParse(line, out ControlModeGuard begin) + || begin.Kind != ControlModeGuardKind.Begin) + { + throw new InvalidDataException( + "The tmux control client sent a malformed block guard."); + } + + await ReadBlockAsync(begin).ConfigureAwait(false); continue; } @@ -486,27 +573,30 @@ private async Task PumpAsync() } } - private async Task ReadBlockAsync(string beginLine) + private async Task ReadBlockAsync(ControlModeGuard begin) { // Only a matching %end or %error terminates a block; output may start with %. - string suffix = beginLine["%begin ".Length..]; List lines = []; bool failed = false; bool terminated = false; while (await _process.ReadLineAsync().ConfigureAwait(false) is string line) { - if (IsBlockTerminator(line, "%end ", suffix)) + if (ControlModeGuard.TryParse(line, out ControlModeGuard guard) + && guard.Matches(begin)) { - terminated = true; - break; - } + if (guard.Kind == ControlModeGuardKind.End) + { + terminated = true; + break; + } - if (IsBlockTerminator(line, "%error ", suffix)) - { - failed = true; - terminated = true; - break; + if (guard.Kind == ControlModeGuardKind.Error) + { + failed = true; + terminated = true; + break; + } } lines.Add(line); @@ -519,56 +609,98 @@ private async Task ReadBlockAsync(string beginLine) } // Attach's reply is the readiness block; enqueuing it shifts every later reply. - TmuxCommandException? failure = failed - ? new TmuxCommandException( - lines.Count == 0 ? "The tmux command failed." : string.Join('\n', lines), - BuildFailure(lines)) + InvalidOperationException? attachFailure = failed + ? new InvalidOperationException( + lines.Count == 0 ? "The tmux attach failed." : string.Join('\n', lines)) : null; - bool completedReadiness = failure is null + bool completedReadiness = attachFailure is null ? _ready.TrySetResult() - : _ready.TrySetException(failure); + : _ready.TrySetException(attachFailure); if (completedReadiness) { return; } - TaskCompletionSource>? completion; + // Hooks use flag 0 and may interleave with the caller's work. Only + // control-input commands use flag 1 and belong to a pending request. + if (begin.Flags != 1) + { + return; + } + + PendingCommand? pending; lock (_pending) { - completion = _pending.Count == 0 ? null : _pending.Dequeue().Completion; + pending = _pending.Count == 0 ? null : _pending.Peek(); } - if (completion is null) + if (pending is null) { + throw new InvalidDataException( + "The tmux control client sent a command block with no pending request."); + } + + string sentinelError = $"parse error: unknown command: {pending.Sentinel}"; + if (failed && lines.Count == 1 && string.Equals( + lines[0], + sentinelError, + StringComparison.Ordinal)) + { + CompletePending(pending); return; } + if (failed && lines.Any(line => line.Contains( + pending.Sentinel, + StringComparison.Ordinal))) + { + throw new InvalidDataException( + "The tmux control client returned an unrecognized request fence."); + } + if (failed) { - completion.TrySetException(failure!); + if (lines.Count == 0) + { + pending.ErrorLines.Add("The tmux command failed."); + } + else + { + pending.ErrorLines.AddRange(lines); + } + return; } - completion.TrySetResult(lines); + pending.OutputLines.AddRange(lines); } - private static TmuxCommandResult BuildFailure(IReadOnlyList lines) + private void CompletePending(PendingCommand pending) { - // tmux reports a control-mode failure as the block's own lines rather - // than on a separate stream, so they are the error text here. - byte[] text = System.Text.Encoding.UTF8.GetBytes(string.Join('\n', lines)); - return new TmuxCommandResult( - arguments: [], - exitCode: 1, - standardOutput: ReadOnlyMemory.Empty, - standardError: text, - standardOutputLines: [], - standardErrorLines: lines); - } + lock (_pending) + { + if (_pending.Count == 0 || !ReferenceEquals(_pending.Peek(), pending)) + { + throw new InvalidDataException( + "The tmux control client lost its request boundary."); + } + + _pending.Dequeue(); + } + + if (pending.ErrorLines.Count == 0) + { + pending.Completion.TrySetResult([.. pending.OutputLines]); + return; + } - private static bool IsBlockTerminator(string line, string prefix, string suffix) => - line.StartsWith(prefix, StringComparison.Ordinal) - && string.Equals(line[prefix.Length..], suffix, StringComparison.Ordinal); + string reported = string.Join('\n', pending.ErrorLines); + pending.Completion.TrySetException(new ControlModeCommandException( + reported.Length == 0 ? "The tmux command failed." : reported, + pending.Command, + pending.OutputLines, + pending.ErrorLines)); + } private static (string Name, IReadOnlyList Arguments) SplitNotification(string line) { diff --git a/src/LibTmux/ControlMode/IControlModeSession.cs b/src/LibTmux/ControlMode/IControlModeSession.cs index 9248c82..5aa2479 100644 --- a/src/LibTmux/ControlMode/IControlModeSession.cs +++ b/src/LibTmux/ControlMode/IControlModeSession.cs @@ -28,9 +28,7 @@ public interface IControlModeSession : IAsyncDisposable public bool IsRunning { get; } /// Runs one command on this client and reads what it answered. - /// - /// The command line, exactly as it would be typed at tmux's command prompt. - /// + /// The typed command to run. /// Stops waiting for the answer. /// The lines tmux printed, empty when it printed nothing. /// @@ -39,9 +37,14 @@ public interface IControlModeSession : IAsyncDisposable /// else's. Cancelling stops the wait, not the command; tmux has already /// been told. /// - /// tmux reported the command failed. + /// + /// Tmux reported the command failed. + /// + /// + /// The command targets a different tmux server generation. + /// /// The client is no longer running. public Task> SendAsync( - string command, + TmuxCommand command, CancellationToken cancellationToken = default); } diff --git a/src/LibTmux/Exceptions/ControlModeCommandException.cs b/src/LibTmux/Exceptions/ControlModeCommandException.cs new file mode 100644 index 0000000..5e60e69 --- /dev/null +++ b/src/LibTmux/Exceptions/ControlModeCommandException.cs @@ -0,0 +1,35 @@ +using System.Collections.ObjectModel; + +namespace LibTmux; + +/// Reports a command rejected by a live tmux control client. +public sealed class ControlModeCommandException : LibTmuxException +{ + private readonly ReadOnlyCollection _outputLines; + private readonly ReadOnlyCollection _errorLines; + + /// Initializes a control-mode command exception. + public ControlModeCommandException( + string message, + TmuxCommand command, + IReadOnlyList outputLines, + IReadOnlyList errorLines, + Exception? innerException = null) + : base(message, TmuxDispatchState.Dispatched, innerException) + { + Command = command ?? throw new ArgumentNullException(nameof(command)); + ArgumentNullException.ThrowIfNull(outputLines); + ArgumentNullException.ThrowIfNull(errorLines); + _outputLines = Array.AsReadOnly(outputLines.ToArray()); + _errorLines = Array.AsReadOnly(errorLines.ToArray()); + } + + /// Gets the command tmux rejected. + public TmuxCommand Command { get; } + + /// Gets output produced before tmux rejected the command. + public IReadOnlyList OutputLines => _outputLines; + + /// Gets the error lines tmux reported. + public IReadOnlyList ErrorLines => _errorLines; +} diff --git a/src/LibTmux/PublicAPI.Unshipped.txt b/src/LibTmux/PublicAPI.Unshipped.txt index a6c128a..2f1f454 100644 --- a/src/LibTmux/PublicAPI.Unshipped.txt +++ b/src/LibTmux/PublicAPI.Unshipped.txt @@ -115,6 +115,11 @@ LibTmux.ConfirmBeforeRequest.DefaultYes.get -> bool LibTmux.ConfirmBeforeRequest.Equals(LibTmux.ConfirmBeforeRequest? other) -> bool LibTmux.ConfirmBeforeRequest.Prompt.get -> string? LibTmux.ConfirmBeforeRequest.TargetClient.get -> string? +LibTmux.ControlModeCommandException +LibTmux.ControlModeCommandException.Command.get -> LibTmux.TmuxCommand! +LibTmux.ControlModeCommandException.ControlModeCommandException(string! message, LibTmux.TmuxCommand! command, System.Collections.Generic.IReadOnlyList! outputLines, System.Collections.Generic.IReadOnlyList! errorLines, System.Exception? innerException = null) -> void +LibTmux.ControlModeCommandException.ErrorLines.get -> System.Collections.Generic.IReadOnlyList! +LibTmux.ControlModeCommandException.OutputLines.get -> System.Collections.Generic.IReadOnlyList! LibTmux.CopyModeRequest LibTmux.CopyModeRequest.$() -> LibTmux.CopyModeRequest! LibTmux.CopyModeRequest.Cancel.get -> bool @@ -216,7 +221,7 @@ LibTmux.HookRequest.Scope.get -> LibTmux.OptionScope? LibTmux.IControlModeSession LibTmux.IControlModeSession.Events.get -> System.Collections.Generic.IAsyncEnumerable! LibTmux.IControlModeSession.IsRunning.get -> bool -LibTmux.IControlModeSession.SendAsync(string! command, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +LibTmux.IControlModeSession.SendAsync(LibTmux.TmuxCommand! command, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! LibTmux.IfShellRequest LibTmux.IfShellRequest.$() -> LibTmux.IfShellRequest! LibTmux.IfShellRequest.Background.get -> bool diff --git a/src/LibTmux/README.md b/src/LibTmux/README.md index bc94c75..d1d1dc5 100644 --- a/src/LibTmux/README.md +++ b/src/LibTmux/README.md @@ -89,7 +89,9 @@ Window built = await session.CreateWindowAsync(new NewWindowRequest(name: "build ```csharp run // One client, held open, streaming what tmux does on its own. await using IControlModeSession control = await server.EnterControlModeAsync(cancellationToken: ct); -IReadOnlyList reply = await control.SendAsync("list-windows", ct); +IReadOnlyList reply = await control.SendAsync( + TmuxCommand.Create("list-windows"), + ct); ``` ```csharp run diff --git a/src/LibTmux/Server.ControlMode.cs b/src/LibTmux/Server.ControlMode.cs index 9419ada..485882f 100644 --- a/src/LibTmux/Server.ControlMode.cs +++ b/src/LibTmux/Server.ControlMode.cs @@ -32,7 +32,8 @@ public async Task EnterControlModeAsync( // Attaching needs a session to attach to, and a server with none exits // the moment it is started. Discovering first turns "no server" into // the ordinary connection error rather than a client that dies at once. - await ConnectAsync(cancellationToken).ConfigureAwait(false); + Server live = await RediscoverCurrentGenerationAsync(cancellationToken) + .ConfigureAwait(false); if (connection.IsPsmux) { throw new NotSupportedException( @@ -43,6 +44,7 @@ public async Task EnterControlModeAsync( connection.Options.TmuxBinaryPath, connection.PrefixArguments, target, + live.Generation!.Value, startInfo => TmuxConnection.ApplyChildEnvironment( startInfo, connection.Options.ChildEnvironment)); diff --git a/tests/LibTmux.IntegrationTests/ControlMode/ControlModeSessionTests.cs b/tests/LibTmux.IntegrationTests/ControlMode/ControlModeSessionTests.cs index 8d44391..47ba57c 100644 --- a/tests/LibTmux.IntegrationTests/ControlMode/ControlModeSessionTests.cs +++ b/tests/LibTmux.IntegrationTests/ControlMode/ControlModeSessionTests.cs @@ -26,12 +26,14 @@ public async Task A_control_client_answers_commands_and_reports_what_it_saw() // that block to the first caller would answer every command with the // previous one's output, so the first command has to get its own. IReadOnlyList panes = await control.SendAsync( - "list-panes -F '#{pane_id}'", + TmuxCommand.Create("list-panes", "-F", "#{pane_id}"), token); Assert.Equal(["%0"], panes); - IReadOnlyList sessions = await control.SendAsync("list-sessions", token); + IReadOnlyList sessions = await control.SendAsync( + TmuxCommand.Create("list-sessions"), + token); Assert.Single(sessions); } @@ -49,14 +51,16 @@ public async Task A_pane_id_inside_a_block_is_data_rather_than_a_notification() // with one too. Ending the block at the first such line would truncate // this answer and leave the rest to be read as notifications. IReadOnlyList reported = await control.SendAsync( - "display-message -p '#{pane_id}'", + TmuxCommand.Create("display-message", "-p", "#{pane_id}"), token); Assert.Equal(["%0"], reported); // The stream is still in step: a command after the ambiguous one is // answered with its own output rather than the leftovers. - Assert.Equal(["ok"], await control.SendAsync("display-message -p 'ok'", token)); + Assert.Equal( + ["ok"], + await control.SendAsync(TmuxCommand.Create("display-message", "-p", "ok"), token)); } [UnixFact] @@ -69,17 +73,126 @@ public async Task A_failing_command_faults_only_its_own_caller() await using IControlModeSession control = await server.EnterControlModeAsync( cancellationToken: token); - await Assert.ThrowsAsync( - () => control.SendAsync("no-such-tmux-command", token)); + await Assert.ThrowsAsync( + () => control.SendAsync(TmuxCommand.Create("no-such-tmux-command"), token)); // The client survives a rejected command, so the session is still // usable rather than needing to be torn down and reopened. Assert.True(control.IsRunning); Assert.Equal(["still-here"], await control.SendAsync( - "display-message -p 'still-here'", + TmuxCommand.Create("display-message", "-p", "still-here"), token)); } + [UnixFact] + public async Task A_command_alias_cannot_move_a_reply_to_the_next_caller() + { + await using RawTmuxTestContext raw = await RawTmuxTestContext.StartAsync( + TestContext.Current.CancellationToken); + CancellationToken token = TestContext.Current.CancellationToken; + RawTmuxResult configured = await raw.ExecuteAsync( + [ + "set-option", + "-s", + "command-alias[200]", + "libtmux-expand=display-message -p one; display-message -p two", + ], + token); + Assert.Equal(0, configured.ExitCode); + Server server = await ConnectAsync(raw, token); + await using IControlModeSession control = await server.EnterControlModeAsync( + cancellationToken: token); + + Task> expanded = control.SendAsync( + TmuxCommand.Create("libtmux-expand"), + token); + Task> following = control.SendAsync( + TmuxCommand.Create("display-message", "-p", "following"), + token); + + Assert.Equal(["one", "two"], await expanded); + Assert.Equal(["following"], await following); + } + + [UnixFact] + public async Task Typed_arguments_are_literal_tmux_arguments() + { + await using RawTmuxTestContext raw = await RawTmuxTestContext.StartAsync( + TestContext.Current.CancellationToken); + CancellationToken token = TestContext.Current.CancellationToken; + Server server = await ConnectAsync(raw, token); + await using IControlModeSession control = await server.EnterControlModeAsync( + cancellationToken: token); + const string Value = "space ' ; $HOME \\ π"; + + IReadOnlyList output = await control.SendAsync( + TmuxCommand.Create("display-message", "-p", Value), + token); + + Assert.Equal([Value], output); + } + + [UnixFact] + public async Task Hook_blocks_do_not_replace_command_output() + { + await using RawTmuxTestContext raw = await RawTmuxTestContext.StartAsync( + TestContext.Current.CancellationToken); + CancellationToken token = TestContext.Current.CancellationToken; + RawTmuxResult configured = await raw.ExecuteAsync( + [ + "set-hook", + "-g", + "after-list-panes", + "display-message -p hook-output", + ], + token); + Assert.Equal(0, configured.ExitCode); + Server server = await ConnectAsync(raw, token); + await using IControlModeSession control = await server.EnterControlModeAsync( + cancellationToken: token); + + IReadOnlyList panes = await control.SendAsync( + TmuxCommand.Create("list-panes", "-F", "#{pane_id}"), + token); + + Assert.Equal(["%0"], panes); + Assert.Equal( + ["aligned"], + await control.SendAsync( + TmuxCommand.Create("display-message", "-p", "aligned"), + token)); + } + + [UnixFact] + public async Task Cancellation_keeps_later_callers_behind_the_request_fence() + { + await using RawTmuxTestContext raw = await RawTmuxTestContext.StartAsync( + TestContext.Current.CancellationToken); + CancellationToken token = TestContext.Current.CancellationToken; + Server server = await ConnectAsync(raw, token); + await using IControlModeSession control = await server.EnterControlModeAsync( + cancellationToken: token); + string channel = $"control-{Guid.NewGuid():N}"; + using var callerCancellation = CancellationTokenSource.CreateLinkedTokenSource(token); + + Task> blocked = control.SendAsync( + TmuxCommand.Create("wait-for", channel), + callerCancellation.Token); + await Task.Delay(TimeSpan.FromMilliseconds(50), token); + callerCancellation.Cancel(); + await Assert.ThrowsAnyAsync(async () => await blocked); + + Task> following = control.SendAsync( + TmuxCommand.Create("display-message", "-p", "after-wait"), + token); + await Task.Delay(TimeSpan.FromMilliseconds(50), token); + Assert.False(following.IsCompleted); + + RawTmuxResult signal = await raw.ExecuteAsync(["wait-for", "-S", channel], token); + Assert.Equal(0, signal.ExitCode); + Assert.Equal(["after-wait"], await following); + } + [UnixFact] public async Task Pane_output_arrives_decoded() { @@ -90,7 +203,14 @@ public async Task Pane_output_arrives_decoded() await using IControlModeSession control = await server.EnterControlModeAsync( cancellationToken: token); - await control.SendAsync("send-keys -t %0 'echo libtmux-control-marker' Enter", token); + await control.SendAsync( + TmuxCommand.Create( + "send-keys", + "-t", + "%0", + "echo libtmux-control-marker", + "Enter"), + token); // tmux escapes the payload the way it escapes an option value, so a // reader that passed it through would report the literal escape @@ -124,7 +244,7 @@ public async Task The_event_stream_ends_with_an_exit() Server server = await ConnectAsync(raw, token); IControlModeSession control = await server.EnterControlModeAsync(cancellationToken: token); - await control.SendAsync("kill-server", token); + await control.SendAsync(TmuxCommand.Create("kill-server"), token); List observed = []; using CancellationTokenSource timeout = CancellationTokenSource.CreateLinkedTokenSource(token); diff --git a/tests/LibTmux.UnitTests/ControlMode/ControlModeCorrelationTests.cs b/tests/LibTmux.UnitTests/ControlMode/ControlModeCorrelationTests.cs new file mode 100644 index 0000000..c1aefe2 --- /dev/null +++ b/tests/LibTmux.UnitTests/ControlMode/ControlModeCorrelationTests.cs @@ -0,0 +1,222 @@ +using System.Runtime.Versioning; +using System.Threading.Channels; + +namespace LibTmux.UnitTests.ControlMode; + +[UnsupportedOSPlatform("windows")] +public sealed class ControlModeCorrelationTests +{ + [Fact] + public async Task A_request_owns_every_flagged_block_through_its_fence() + { + CancellationToken token = TestContext.Current.CancellationToken; + string[] sentinels = ["libtmux-control-first", "libtmux-control-following"]; + int sentinelIndex = 0; + var process = new ScriptedProcess(expectedWrites: 2); + await using var session = new ControlModeSession( + process, + sentinelFactory: () => sentinels[sentinelIndex++]); + await session.WaitForReadyAsync(token); + + Task> first = session.SendAsync( + TmuxCommand.Create("libtmux-expand"), + token); + Task> following = session.SendAsync( + TmuxCommand.Create("display-message", "-p", "following"), + token); + await process.WritesObserved.Task.WaitAsync(token); + + Assert.Equal( + [ + "'libtmux-expand'\nlibtmux-control-first", + "'display-message' '-p' 'following'\nlibtmux-control-following", + ], + process.Writes); + + process.EmitBlock(number: 10, flags: 1, failed: false, "one"); + process.EmitBlock(number: 11, flags: 0, failed: false, "hook-output"); + process.EmitBlock(number: 12, flags: 1, failed: false, "two"); + process.EmitFence(number: 13, sentinels[0]); + process.EmitBlock(number: 14, flags: 1, failed: false, "following"); + process.EmitFence(number: 15, sentinels[1]); + + Assert.Equal(["one", "two"], await first); + Assert.Equal(["following"], await following); + } + + [Fact] + public async Task A_failed_command_keeps_its_typed_diagnostics() + { + CancellationToken token = TestContext.Current.CancellationToken; + const string Sentinel = "libtmux-control-failure"; + var process = new ScriptedProcess(expectedWrites: 1); + await using var session = new ControlModeSession( + process, + sentinelFactory: () => Sentinel); + await session.WaitForReadyAsync(token); + TmuxCommand command = TmuxCommand.Create("no-such-command"); + + Task> send = session.SendAsync(command, token); + await process.WritesObserved.Task.WaitAsync(token); + process.EmitBlock(number: 10, flags: 1, failed: false, "before-error"); + process.EmitBlock(number: 11, flags: 1, failed: true, "unknown command"); + process.EmitFence(number: 12, Sentinel); + + ControlModeCommandException error = + await Assert.ThrowsAsync(async () => await send); + Assert.Same(command, error.Command); + Assert.Equal(["before-error"], error.OutputLines); + Assert.Equal(["unknown command"], error.ErrorLines); + } + + [Fact] + public async Task A_stale_command_is_rejected_before_dispatch() + { + CancellationToken token = TestContext.Current.CancellationToken; + var attached = new ServerGeneration(processId: 10, startTime: 20); + var process = new ScriptedProcess(expectedWrites: 0); + await using var session = new ControlModeSession(process, generation: attached); + await session.WaitForReadyAsync(token); + TmuxCommand command = TmuxCommand.Create("display-message") with + { + RequiredGeneration = new ServerGeneration(processId: 11, startTime: 21), + }; + + StaleServerGenerationException error = + await Assert.ThrowsAsync( + () => session.SendAsync(command, token)); + + Assert.Equal(command.RequiredGeneration, error.Expected); + Assert.Equal(attached, error.Actual); + Assert.Empty(process.Writes); + } + + [Fact] + public void Typed_arguments_render_without_a_second_physical_line() + { + TmuxCommand command = TmuxCommand.Create( + "display-message", + "-p", + "a'b; $HOME \\ π\r\nend"); + + string rendered = ControlModeCommandRenderer.Render(command); + + Assert.DoesNotContain('\r', rendered); + Assert.DoesNotContain('\n', rendered); + Assert.Contains("\\015\\012", rendered, StringComparison.Ordinal); + Assert.EndsWith("\\145\\156\\144", rendered, StringComparison.Ordinal); + } + + private sealed class ScriptedProcess : IControlModeProcess + { + private readonly Channel _output = Channel.CreateUnbounded(); + private readonly TaskCompletionSource _exited = new( + TaskCreationOptions.RunContinuationsAsynchronously); + private readonly int _expectedWrites; + private readonly List _writes = []; + private int _hasExited; + + internal ScriptedProcess(int expectedWrites) + { + _expectedWrites = expectedWrites; + _output.Writer.TryWrite("%begin 1 1 0"); + _output.Writer.TryWrite("%end 1 1 0"); + if (expectedWrites == 0) + { + WritesObserved.TrySetResult(); + } + } + + internal IReadOnlyList Writes + { + get + { + lock (_writes) + { + return [.. _writes]; + } + } + } + + internal TaskCompletionSource WritesObserved { get; } = new( + TaskCreationOptions.RunContinuationsAsynchronously); + + public bool HasExited => Volatile.Read(ref _hasExited) != 0; + + public Task WriteLineAsync( + ReadOnlyMemory command, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + lock (_writes) + { + _writes.Add(command.ToString()); + if (_writes.Count == _expectedWrites) + { + WritesObserved.TrySetResult(); + } + } + + return Task.CompletedTask; + } + + public Task FlushAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.CompletedTask; + } + + public async Task ReadLineAsync() + { + while (await _output.Reader.WaitToReadAsync()) + { + if (_output.Reader.TryRead(out string? line)) + { + return line; + } + } + + return null; + } + + public void CloseInput() => Stop("%exit"); + + public void Kill() => Stop("%exit killed"); + + public Task WaitForExitAsync(CancellationToken cancellationToken = default) => + _exited.Task.WaitAsync(cancellationToken); + + public void Dispose() => Stop("%exit disposed"); + + internal void EmitBlock(int number, int flags, bool failed, params string[] lines) + { + string suffix = $"2 {number} {flags}"; + _output.Writer.TryWrite($"%begin {suffix}"); + foreach (string line in lines) + { + _output.Writer.TryWrite(line); + } + + _output.Writer.TryWrite($"%{(failed ? "error" : "end")} {suffix}"); + } + + internal void EmitFence(int number, string sentinel) => + EmitBlock( + number, + flags: 1, + failed: true, + $"parse error: unknown command: {sentinel}"); + + private void Stop(string exit) + { + if (Interlocked.Exchange(ref _hasExited, 1) != 0) + { + return; + } + + _output.Writer.TryWrite(exit); + _output.Writer.TryComplete(); + _exited.TrySetResult(); + } + } +} diff --git a/tests/LibTmux.UnitTests/ControlMode/ControlModeSessionFailureTests.cs b/tests/LibTmux.UnitTests/ControlMode/ControlModeSessionFailureTests.cs index 63d34ed..eceb3fe 100644 --- a/tests/LibTmux.UnitTests/ControlMode/ControlModeSessionFailureTests.cs +++ b/tests/LibTmux.UnitTests/ControlMode/ControlModeSessionFailureTests.cs @@ -70,7 +70,7 @@ public async Task Disposal_kills_a_client_whose_write_holds_the_dispatch_lock() await session.WaitForReadyAsync(token); Task> send = session.SendAsync( - "display-message -p stuck", + TmuxCommand.Create("display-message", "-p", "stuck"), token); await process.WriteStarted.Task.WaitAsync(token); @@ -101,7 +101,9 @@ public async Task Terminal_eof_rejects_commands_when_the_process_still_claims_to Assert.False(session.IsRunning); await Assert.ThrowsAsync( - () => session.SendAsync("display-message -p too-late", token)); + () => session.SendAsync( + TmuxCommand.Create("display-message", "-p", "too-late"), + token)); await session.DisposeAsync(); Assert.True(process.DisposeCalled); } @@ -121,7 +123,9 @@ public async Task Terminal_fault_rejects_commands_when_the_process_still_claims_ Assert.False(session.IsRunning); await Assert.ThrowsAsync( - () => session.SendAsync("display-message -p too-late", token)); + () => session.SendAsync( + TmuxCommand.Create("display-message", "-p", "too-late"), + token)); IOException disposalFailure = await Assert.ThrowsAsync( () => session.DisposeAsync().AsTask()); Assert.Same(pumpFailure, disposalFailure); @@ -136,14 +140,15 @@ public async Task Terminal_eof_during_final_check_cannot_escape_the_pending_swee var session = new ControlModeSession(process); await session.WaitForReadyAsync(token); + TmuxCommand command = TmuxCommand.Create("display-message", "-p", "racing"); InvalidOperationException terminalFailure = await Assert.ThrowsAsync(async () => - await session.SendAsync("display-message -p racing", token) + await session.SendAsync(command, token) .WaitAsync(TimeSpan.FromSeconds(2), token)); Assert.Contains("exited before", terminalFailure.Message, StringComparison.Ordinal); Assert.False(session.IsRunning); - Assert.Equal(["display-message -p racing"], process.WriteAttempts); + Assert.Equal([ControlModeCommandRenderer.Render(command)], process.WriteAttempts); await session.DisposeAsync(); Assert.True(process.DisposeCalled); } @@ -173,12 +178,10 @@ public async Task A_terminated_error_attach_block_fails_readiness_with_tmux_outp TruncatedBlockProcess process = TruncatedBlockProcess.ForAttachError(); var session = new ControlModeSession(process); - TmuxCommandException error = await Assert.ThrowsAsync( + InvalidOperationException error = await Assert.ThrowsAsync( () => session.WaitForReadyAsync(token)); Assert.Equal("can't find pane: missing", error.Message); - Assert.Equal(1, error.Result.ExitCode); - Assert.Equal(["can't find pane: missing"], error.Result.StandardErrorLines); await session.DisposeAsync(); Assert.True(process.DisposeCalled); } @@ -191,8 +194,10 @@ public async Task A_truncated_command_block_fails_every_pending_command() var session = new ControlModeSession(process); await session.WaitForReadyAsync(token); - Task> first = session.SendAsync("first", token); - Task> second = session.SendAsync("second", token); + TmuxCommand firstCommand = TmuxCommand.Create("first"); + TmuxCommand secondCommand = TmuxCommand.Create("second"); + Task> first = session.SendAsync(firstCommand, token); + Task> second = session.SendAsync(secondCommand, token); await process.TwoCommandsDispatched.Task.WaitAsync(token); process.EndCommandBlockEarly(); @@ -207,7 +212,12 @@ await Assert.ThrowsAsync( Assert.Same(firstFailure, secondFailure); Assert.Same(firstFailure, disposalFailure); - Assert.Equal(["first", "second"], process.WriteAttempts); + Assert.Equal( + [ + ControlModeCommandRenderer.Render(firstCommand), + ControlModeCommandRenderer.Render(secondCommand), + ], + process.WriteAttempts); Assert.True(process.DisposeCalled); } @@ -221,7 +231,9 @@ public async Task An_event_burst_drops_oldest_without_blocking_a_reply_or_exit() var session = new ControlModeSession(process); await session.WaitForReadyAsync(token); - IReadOnlyList reply = await session.SendAsync("display-message -p reply", token); + IReadOnlyList reply = await session.SendAsync( + TmuxCommand.Create("display-message", "-p", "reply"), + token); await session.DisposeAsync(); var observed = new List(); @@ -262,19 +274,22 @@ public async Task An_ambiguous_dispatch_fails_pending_and_rejects_the_next_comma var dispatchFailure = new IOException($"{failurePoint} failed"); var process = new AmbiguousDispatchProcess(failurePoint, dispatchFailure); var session = new ControlModeSession(process); - const string PendingCommand = "display-message -p pending"; - const string AmbiguousCommand = "display-message -p ambiguous"; - const string NextCommand = "display-message -p next"; + TmuxCommand pendingCommand = TmuxCommand.Create("display-message", "-p", "pending"); + TmuxCommand ambiguousCommand = TmuxCommand.Create("display-message", "-p", "ambiguous"); + TmuxCommand nextCommand = TmuxCommand.Create("display-message", "-p", "next"); + string pendingLine = ControlModeCommandRenderer.Render(pendingCommand); + string ambiguousLine = ControlModeCommandRenderer.Render(ambiguousCommand); + string nextLine = ControlModeCommandRenderer.Render(nextCommand); try { await session.WaitForReadyAsync(token); - Task> pending = session.SendAsync(PendingCommand, token); + Task> pending = session.SendAsync(pendingCommand, token); await process.FirstDispatchCompleted.Task.WaitAsync(token); - Task> ambiguous = session.SendAsync(AmbiguousCommand, token); + Task> ambiguous = session.SendAsync(ambiguousCommand, token); await process.FailureEntered.Task.WaitAsync(token); - Task> next = session.SendAsync(NextCommand, token); + Task> next = session.SendAsync(nextCommand, token); process.ReleaseFailure(); @@ -286,12 +301,12 @@ public async Task An_ambiguous_dispatch_fails_pending_and_rejects_the_next_comma Assert.Same(dispatchFailure, pendingError.InnerException); Assert.Same(dispatchFailure, ambiguousError); - Assert.Equal([PendingCommand, AmbiguousCommand], process.WriteAttempts); - Assert.DoesNotContain(NextCommand, process.WriteAttempts); + Assert.Equal([pendingLine, ambiguousLine], process.WriteAttempts); + Assert.DoesNotContain(nextLine, process.WriteAttempts); Assert.Equal( failurePoint == DispatchFailurePoint.PartialWrite - ? AmbiguousCommand[..8] - : AmbiguousCommand, + ? ambiguousLine[..8] + : ambiguousLine, process.AmbiguousAcceptedText); Assert.True(process.InputClosed); Assert.True(process.DisposeCalled); @@ -319,6 +334,24 @@ private static async Task DrainEventsAsync( } } + private static string FirstPromptLine(ReadOnlyMemory input) + { + ReadOnlySpan characters = input.Span; + int separator = characters.IndexOf('\n'); + return separator < 0 + ? characters.ToString() + : characters[..separator].ToString(); + } + + private static string RequestFence(ReadOnlyMemory input) + { + ReadOnlySpan characters = input.Span; + int separator = characters.LastIndexOf('\n'); + return separator < 0 + ? string.Empty + : characters[(separator + 1)..].ToString(); + } + private sealed class TruncatedBlockProcess : IControlModeProcess { private readonly Channel _output = Channel.CreateUnbounded( @@ -376,7 +409,7 @@ public Task WriteLineAsync( CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); - WriteAttempts.Add(command.ToString()); + WriteAttempts.Add(FirstPromptLine(command)); return Task.CompletedTask; } @@ -439,6 +472,7 @@ private sealed class BurstOutputProcess : IControlModeProcess TaskCreationOptions.RunContinuationsAsynchronously); private readonly int _notificationCount; private int _hasExited; + private string _sentinel = string.Empty; internal BurstOutputProcess(int notificationCount) { @@ -456,6 +490,7 @@ public Task WriteLineAsync( CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); + _sentinel = RequestFence(command); return Task.CompletedTask; } @@ -474,6 +509,7 @@ public Task FlushAsync(CancellationToken cancellationToken) "%burst " + index.ToString(CultureInfo.InvariantCulture)); } + QueueFence(); _output.Writer.TryWrite("%exit done"); CompleteOutput(); return Task.CompletedTask; @@ -502,9 +538,16 @@ public Task WaitForExitAsync(CancellationToken cancellationToken = default) => private void QueueReply() { - _output.Writer.TryWrite("%begin 2 2 0"); + _output.Writer.TryWrite("%begin 2 2 1"); _output.Writer.TryWrite("reply-ok"); - _output.Writer.TryWrite("%end 2 2 0"); + _output.Writer.TryWrite("%end 2 2 1"); + } + + private void QueueFence() + { + _output.Writer.TryWrite("%begin 2 3 1"); + _output.Writer.TryWrite($"parse error: unknown command: {_sentinel}"); + _output.Writer.TryWrite("%error 2 3 1"); } private void CompleteOutput() @@ -598,7 +641,7 @@ public Task WriteLineAsync( CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); - WriteAttempts.Add(command.ToString()); + WriteAttempts.Add(FirstPromptLine(command)); return Task.CompletedTask; } @@ -762,7 +805,7 @@ public async Task WriteLineAsync( CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); - string text = command.ToString(); + string text = FirstPromptLine(command); WriteAttempts.Add(text); int call = Interlocked.Increment(ref _writeCalls); if (call != 2 || _failurePoint != DispatchFailurePoint.PartialWrite) diff --git a/tests/LibTmux.UnitTests/Mcp/HierarchyWatcherLifecycleTests.cs b/tests/LibTmux.UnitTests/Mcp/HierarchyWatcherLifecycleTests.cs index 186055d..b67435c 100644 --- a/tests/LibTmux.UnitTests/Mcp/HierarchyWatcherLifecycleTests.cs +++ b/tests/LibTmux.UnitTests/Mcp/HierarchyWatcherLifecycleTests.cs @@ -788,11 +788,11 @@ internal FakeControlModeSession(bool pauseDisposal = false) public bool IsRunning => Volatile.Read(ref _running) != 0; public Task> SendAsync( - string command, + TmuxCommand command, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); - Commands.Add(command); + Commands.Add(string.Join(' ', command.ToArguments())); return Task.FromResult>([]); } diff --git a/tests/LibTmux.UnitTests/Mcp/PaneActivityHubLifecycleTests.cs b/tests/LibTmux.UnitTests/Mcp/PaneActivityHubLifecycleTests.cs index 29b4d57..74854e4 100644 --- a/tests/LibTmux.UnitTests/Mcp/PaneActivityHubLifecycleTests.cs +++ b/tests/LibTmux.UnitTests/Mcp/PaneActivityHubLifecycleTests.cs @@ -320,11 +320,11 @@ internal FakeControlModeSession(bool pauseDisposal = false) public bool IsRunning => Volatile.Read(ref _running) != 0; public Task> SendAsync( - string command, + TmuxCommand command, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); - Commands.Add(command); + Commands.Add(string.Join(' ', command.ToArguments())); return Task.FromResult>([]); } From c4a4bad2bd872205e7f3b721cc183cff008e805a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 05:04:20 -0500 Subject: [PATCH 003/129] ControlMode(fix[io]): Bound process output why: A control client could deadlock startup on an unread stderr pipe or allocate an unbounded stdout line. what: - drain stderr continuously while retaining a bounded diagnostic tail - parse stdout with a bounded UTF-8 line reader - verify the real pipe-fill regression and bounded helpers --- src/LibTmux/ControlMode/ControlModeLimits.cs | 21 +++ .../ControlMode/ControlModeLineReader.cs | 111 +++++++++++++ src/LibTmux/ControlMode/ControlModeSession.cs | 73 +++----- .../ControlMode/SystemControlModeProcess.cs | 157 ++++++++++++++++++ .../ControlMode/ControlModeSessionTests.cs | 56 +++++++ .../ControlMode/ControlModeProcessTests.cs | 43 +++++ 6 files changed, 413 insertions(+), 48 deletions(-) create mode 100644 src/LibTmux/ControlMode/ControlModeLimits.cs create mode 100644 src/LibTmux/ControlMode/ControlModeLineReader.cs create mode 100644 src/LibTmux/ControlMode/SystemControlModeProcess.cs create mode 100644 tests/LibTmux.UnitTests/ControlMode/ControlModeProcessTests.cs diff --git a/src/LibTmux/ControlMode/ControlModeLimits.cs b/src/LibTmux/ControlMode/ControlModeLimits.cs new file mode 100644 index 0000000..82e5df9 --- /dev/null +++ b/src/LibTmux/ControlMode/ControlModeLimits.cs @@ -0,0 +1,21 @@ +namespace LibTmux; + +internal sealed class ControlModeLimits +{ + private const int DefaultMaxLineBytes = 64 * 1024; + private const int DefaultStandardErrorTailBytes = 64 * 1024; + + internal ControlModeLimits( + int maxLineBytes = DefaultMaxLineBytes, + int standardErrorTailBytes = DefaultStandardErrorTailBytes) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxLineBytes); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(standardErrorTailBytes); + MaxLineBytes = maxLineBytes; + StandardErrorTailBytes = standardErrorTailBytes; + } + + internal int MaxLineBytes { get; } + + internal int StandardErrorTailBytes { get; } +} diff --git a/src/LibTmux/ControlMode/ControlModeLineReader.cs b/src/LibTmux/ControlMode/ControlModeLineReader.cs new file mode 100644 index 0000000..6bac7e2 --- /dev/null +++ b/src/LibTmux/ControlMode/ControlModeLineReader.cs @@ -0,0 +1,111 @@ +using System.Buffers; +using System.Text; + +namespace LibTmux; + +internal sealed class ControlModeLineReader +{ + private static readonly Encoding Utf8 = new UTF8Encoding( + encoderShouldEmitUTF8Identifier: false, + throwOnInvalidBytes: true); + + private readonly byte[] _buffer; + private readonly int _maxLineBytes; + private readonly Stream _stream; + private int _end; + private int _start; + + internal ControlModeLineReader( + Stream stream, + int maxLineBytes, + int bufferSize = 4096) + { + _stream = stream ?? throw new ArgumentNullException(nameof(stream)); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxLineBytes); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(bufferSize); + _maxLineBytes = maxLineBytes; + _buffer = new byte[Math.Min(bufferSize, maxLineBytes)]; + } + + internal async Task ReadLineAsync(CancellationToken cancellationToken = default) + { + ArrayBufferWriter? line = null; + while (true) + { + int available = _end - _start; + int newline = Array.IndexOf(_buffer, (byte)'\n', _start, available); + if (newline >= 0) + { + int finalBytes = newline - _start; + EnsureWithinLimit((line?.WrittenCount ?? 0) + finalBytes); + string result = Decode(line, _buffer, _start, finalBytes); + _start = newline + 1; + return result; + } + + if (available > 0) + { + line ??= new ArrayBufferWriter(Math.Min(_maxLineBytes, _buffer.Length)); + EnsureWithinLimit(line.WrittenCount + available); + Append(line, _buffer, _start, available); + _start = _end; + } + + _start = 0; + _end = await _stream.ReadAsync(_buffer, cancellationToken).ConfigureAwait(false); + if (_end != 0) + { + continue; + } + + return line is null ? null : Decode(line, [], 0, 0); + } + } + + private static void Append( + ArrayBufferWriter destination, + byte[] source, + int start, + int length) => + destination.Write(source.AsSpan(start, length)); + + private static string Decode( + ArrayBufferWriter? prefix, + byte[] final, + int start, + int length) + { + if (prefix is null) + { + ReadOnlySpan bytes = final.AsSpan(start, length); + return Decode(bytes.EndsWith("\r"u8) ? bytes[..^1] : bytes); + } + + prefix.Write(final.AsSpan(start, length)); + ReadOnlySpan completed = prefix.WrittenSpan; + return Decode(completed.EndsWith("\r"u8) ? completed[..^1] : completed); + } + + private static string Decode(ReadOnlySpan bytes) + { + try + { + return Utf8.GetString(bytes); + } + catch (DecoderFallbackException error) + { + throw new InvalidDataException( + "The tmux control client sent invalid UTF-8.", + error); + } + } + + private void EnsureWithinLimit(int bytes) + { + if (bytes > _maxLineBytes) + { + throw new InvalidDataException( + $"A tmux control-mode line exceeded {_maxLineBytes} bytes."); + } + } +} diff --git a/src/LibTmux/ControlMode/ControlModeSession.cs b/src/LibTmux/ControlMode/ControlModeSession.cs index b545898..44a08cb 100644 --- a/src/LibTmux/ControlMode/ControlModeSession.cs +++ b/src/LibTmux/ControlMode/ControlModeSession.cs @@ -6,25 +6,6 @@ namespace LibTmux; -internal interface IControlModeProcess : IDisposable -{ - public bool HasExited { get; } - - public Task WriteLineAsync( - ReadOnlyMemory command, - CancellationToken cancellationToken); - - public Task FlushAsync(CancellationToken cancellationToken); - - public Task ReadLineAsync(); - - public void CloseInput(); - - public void Kill(); - - public Task WaitForExitAsync(CancellationToken cancellationToken = default); -} - /// Reads one tmux control client and correlates what it says. /// /// tmux answers on one stream that carries two different things: blocks that @@ -100,8 +81,6 @@ internal static ControlModeSession Start( ServerGeneration generation, Action configureEnvironment) { - // Draining stderr can hang when tmux hands its pipe to the longer-lived server. - // Startup failures write too little to fill that pipe before the client exits. ProcessStartInfo startInfo = new(tmuxBinaryPath) { RedirectStandardInput = true, @@ -130,7 +109,7 @@ internal static ControlModeSession Start( Process process = Process.Start(startInfo) ?? throw new InvalidOperationException("The tmux control client did not start."); return new ControlModeSession( - new SystemControlModeProcess(process), + new SystemControlModeProcess(process, new ControlModeLimits()), generation: generation); } @@ -342,6 +321,18 @@ private async Task DisposeCoreAsync() pumpFailure = error; } + using (var errorPumpBudget = new CancellationTokenSource(_exitBudget)) + { + try + { + await _process.StopErrorPumpAsync(errorPumpBudget.Token).ConfigureAwait(false); + } + catch (Exception error) + { + cleanupFailures.Add(error); + } + } + try { _process.Dispose(); @@ -567,7 +558,8 @@ private async Task PumpAsync() _events.TryWrite(new TmuxExitEvent(exitReason)); _events.Complete(); Exception terminalFailure = pumpFailure ?? new InvalidOperationException( - "The tmux control client exited before it finished attaching."); + WithStandardError( + "The tmux control client exited before it finished attaching.")); _ready.TrySetException(terminalFailure); StopAndFailPending(terminalFailure); } @@ -610,8 +602,8 @@ private async Task ReadBlockAsync(ControlModeGuard begin) // Attach's reply is the readiness block; enqueuing it shifts every later reply. InvalidOperationException? attachFailure = failed - ? new InvalidOperationException( - lines.Count == 0 ? "The tmux attach failed." : string.Join('\n', lines)) + ? new InvalidOperationException(WithStandardError( + lines.Count == 0 ? "The tmux attach failed." : string.Join('\n', lines))) : null; bool completedReadiness = attachFailure is null ? _ready.TrySetResult() @@ -727,6 +719,14 @@ private static TmuxEvent ToEvent(string name, IReadOnlyList arguments) return new TmuxOutputEvent(arguments[0], OptionParser.DecodeEscapes(payload)); } + private string WithStandardError(string message) + { + string standardError = _process.StandardErrorTail.Trim(); + return standardError.Length == 0 + ? message + : $"{message}\nStandard error:\n{standardError}"; + } + private void FailPending(Exception? failure = null) { failure ??= new InvalidOperationException( @@ -752,27 +752,4 @@ private void StopAndFailPending(Exception failure) } } - private sealed class SystemControlModeProcess(Process process) : IControlModeProcess - { - public bool HasExited => process.HasExited; - - public Task WriteLineAsync( - ReadOnlyMemory command, - CancellationToken cancellationToken) => - process.StandardInput.WriteLineAsync(command, cancellationToken); - - public Task FlushAsync(CancellationToken cancellationToken) => - process.StandardInput.FlushAsync(cancellationToken); - - public Task ReadLineAsync() => process.StandardOutput.ReadLineAsync(); - - public void CloseInput() => process.StandardInput.Close(); - - public void Kill() => process.Kill(entireProcessTree: false); - - public Task WaitForExitAsync(CancellationToken cancellationToken = default) => - process.WaitForExitAsync(cancellationToken); - - public void Dispose() => process.Dispose(); - } } diff --git a/src/LibTmux/ControlMode/SystemControlModeProcess.cs b/src/LibTmux/ControlMode/SystemControlModeProcess.cs new file mode 100644 index 0000000..3e59ece --- /dev/null +++ b/src/LibTmux/ControlMode/SystemControlModeProcess.cs @@ -0,0 +1,157 @@ +using System.Buffers; +using System.Diagnostics; +using System.Text; + +namespace LibTmux; + +internal interface IControlModeProcess : IDisposable +{ + public bool HasExited { get; } + + public string StandardErrorTail => string.Empty; + + public Task WriteLineAsync( + ReadOnlyMemory command, + CancellationToken cancellationToken); + + public Task FlushAsync(CancellationToken cancellationToken); + + public Task ReadLineAsync(); + + public void CloseInput(); + + public void Kill(); + + public Task WaitForExitAsync(CancellationToken cancellationToken = default); + + public Task StopErrorPumpAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} + +internal sealed class SystemControlModeProcess : IControlModeProcess +{ + private readonly CancellationTokenSource _errorPumpCancellation = new(); + private readonly Task _errorPump; + private readonly ControlModeLineReader _output; + private readonly Process _process; + private readonly RollingByteTail _standardError; + + internal SystemControlModeProcess(Process process, ControlModeLimits limits) + { + _process = process ?? throw new ArgumentNullException(nameof(process)); + ArgumentNullException.ThrowIfNull(limits); + _output = new ControlModeLineReader( + process.StandardOutput.BaseStream, + limits.MaxLineBytes); + _standardError = new RollingByteTail(limits.StandardErrorTailBytes); + _errorPump = PumpStandardErrorAsync(_errorPumpCancellation.Token); + } + + public bool HasExited => _process.HasExited; + + public string StandardErrorTail => Encoding.UTF8.GetString(_standardError.Snapshot()); + + public Task WriteLineAsync( + ReadOnlyMemory command, + CancellationToken cancellationToken) => + _process.StandardInput.WriteLineAsync(command, cancellationToken); + + public Task FlushAsync(CancellationToken cancellationToken) => + _process.StandardInput.FlushAsync(cancellationToken); + + public Task ReadLineAsync() => _output.ReadLineAsync(); + + public void CloseInput() => _process.StandardInput.Close(); + + public void Kill() => _process.Kill(entireProcessTree: false); + + public Task WaitForExitAsync(CancellationToken cancellationToken = default) => + _process.WaitForExitAsync(cancellationToken); + + public async Task StopErrorPumpAsync(CancellationToken cancellationToken) + { + await _errorPumpCancellation.CancelAsync().ConfigureAwait(false); + await _errorPump.WaitAsync(cancellationToken).ConfigureAwait(false); + } + + public void Dispose() + { + _errorPumpCancellation.Cancel(); + _process.Dispose(); + _errorPumpCancellation.Dispose(); + } + + private async Task PumpStandardErrorAsync(CancellationToken cancellationToken) + { + byte[] buffer = ArrayPool.Shared.Rent(8192); + try + { + while (true) + { + int read = await _process.StandardError.BaseStream + .ReadAsync(buffer, cancellationToken) + .ConfigureAwait(false); + if (read == 0) + { + return; + } + + _standardError.Append(buffer.AsSpan(0, read)); + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } +} + +internal sealed class RollingByteTail +{ + private readonly byte[] _buffer; + private readonly object _gate = new(); + private int _count; + private int _start; + + internal RollingByteTail(int capacity) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(capacity); + _buffer = new byte[capacity]; + } + + internal void Append(ReadOnlySpan bytes) + { + lock (_gate) + { + if (bytes.Length >= _buffer.Length) + { + bytes[^_buffer.Length..].CopyTo(_buffer); + _start = 0; + _count = _buffer.Length; + return; + } + + int writeAt = (_start + _count) % _buffer.Length; + int first = Math.Min(bytes.Length, _buffer.Length - writeAt); + bytes[..first].CopyTo(_buffer.AsSpan(writeAt)); + bytes[first..].CopyTo(_buffer); + int overflow = Math.Max(0, _count + bytes.Length - _buffer.Length); + _start = (_start + overflow) % _buffer.Length; + _count = Math.Min(_buffer.Length, _count + bytes.Length); + } + } + + internal byte[] Snapshot() + { + lock (_gate) + { + byte[] result = new byte[_count]; + int first = Math.Min(_count, _buffer.Length - _start); + _buffer.AsSpan(_start, first).CopyTo(result); + _buffer.AsSpan(0, _count - first).CopyTo(result.AsSpan(first)); + return result; + } + } +} diff --git a/tests/LibTmux.IntegrationTests/ControlMode/ControlModeSessionTests.cs b/tests/LibTmux.IntegrationTests/ControlMode/ControlModeSessionTests.cs index 47ba57c..caed16e 100644 --- a/tests/LibTmux.IntegrationTests/ControlMode/ControlModeSessionTests.cs +++ b/tests/LibTmux.IntegrationTests/ControlMode/ControlModeSessionTests.cs @@ -342,6 +342,62 @@ await WaitUntilAsync( } } + [UnixFact] + public async Task Startup_drains_standard_error_before_waiting_for_attach() + { + await using RawTmuxTestContext raw = await RawTmuxTestContext.StartAsync( + TestContext.Current.CancellationToken); + string directory = Path.Combine( + Path.GetTempPath(), + $"libtmux-control-stderr-{Guid.NewGuid():N}"); + string wrapper = Path.Combine(directory, "tmux-wrapper"); + Directory.CreateDirectory(directory); + + try + { + string script = $""" + #!/bin/sh + for argument in "$@"; do + if [ "$argument" = "-C" ]; then + dd if=/dev/zero bs=65536 count=4 1>&2 2>/dev/null + printf '%%begin 1 1 0\n%%end 1 1 0\n' + while IFS= read -r ignored; do :; done + exit 0 + fi + done + exec {ShellQuote(raw.TmuxBinaryPath)} "$@" + """; + await File.WriteAllTextAsync( + wrapper, + script, + TestContext.Current.CancellationToken); + File.SetUnixFileMode( + wrapper, + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + await WaitUntilAsync( + () => CanExecute(wrapper), + TestContext.Current.CancellationToken); + + Server server = await Server.ConnectAsync( + new ServerConnectionOptions( + tmuxBinaryPath: wrapper, + socketPath: raw.SocketPath, + configurationFile: "/dev/null"), + TestContext.Current.CancellationToken); + using var startupBudget = CancellationTokenSource.CreateLinkedTokenSource( + TestContext.Current.CancellationToken); + startupBudget.CancelAfter(TimeSpan.FromSeconds(3)); + + await using IControlModeSession control = await server.EnterControlModeAsync( + cancellationToken: startupBudget.Token); + Assert.True(control.IsRunning); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + private static Task ConnectAsync( RawTmuxTestContext raw, CancellationToken token) => diff --git a/tests/LibTmux.UnitTests/ControlMode/ControlModeProcessTests.cs b/tests/LibTmux.UnitTests/ControlMode/ControlModeProcessTests.cs new file mode 100644 index 0000000..e720d7e --- /dev/null +++ b/tests/LibTmux.UnitTests/ControlMode/ControlModeProcessTests.cs @@ -0,0 +1,43 @@ +using System.Text; + +namespace LibTmux.UnitTests.ControlMode; + +public sealed class ControlModeProcessTests +{ + [Fact] + public async Task Line_reader_handles_split_utf8_crlf_and_final_lines() + { + CancellationToken token = TestContext.Current.CancellationToken; + await using var input = new MemoryStream(Encoding.UTF8.GetBytes("alpha\r\nπ\nlast")); + var reader = new ControlModeLineReader(input, maxLineBytes: 16, bufferSize: 2); + + Assert.Equal("alpha", await reader.ReadLineAsync(token)); + Assert.Equal("π", await reader.ReadLineAsync(token)); + Assert.Equal("last", await reader.ReadLineAsync(token)); + Assert.Null(await reader.ReadLineAsync(token)); + } + + [Fact] + public async Task Line_reader_rejects_a_line_beyond_its_byte_limit() + { + CancellationToken token = TestContext.Current.CancellationToken; + await using var input = new MemoryStream("123456\n"u8.ToArray()); + var reader = new ControlModeLineReader(input, maxLineBytes: 5, bufferSize: 2); + + InvalidDataException error = await Assert.ThrowsAsync( + () => reader.ReadLineAsync(token)); + + Assert.Equal("A tmux control-mode line exceeded 5 bytes.", error.Message); + } + + [Fact] + public void Standard_error_tail_keeps_only_the_newest_bytes() + { + var tail = new RollingByteTail(capacity: 5); + + tail.Append("abc"u8); + tail.Append("defg"u8); + + Assert.Equal("cdefg"u8.ToArray(), tail.Snapshot()); + } +} From 108d85538b05e21a98a87a287f9c276ea6572a8e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 05:06:01 -0500 Subject: [PATCH 004/129] ControlMode(fix[lifecycle]): Preserve in-flight replies why: Disposal dequeued pending work before its active write finished, turning a valid reply into an orphaned protocol block. what: - let the protocol pump remain the terminal owner of queued requests - reproduce the send/dispose interleaving with the existing stalled-write fake --- src/LibTmux/ControlMode/ControlModeSession.cs | 4 -- .../ControlModeSessionFailureTests.cs | 65 +++++++++++++++++-- 2 files changed, 60 insertions(+), 9 deletions(-) diff --git a/src/LibTmux/ControlMode/ControlModeSession.cs b/src/LibTmux/ControlMode/ControlModeSession.cs index 44a08cb..39bbdc7 100644 --- a/src/LibTmux/ControlMode/ControlModeSession.cs +++ b/src/LibTmux/ControlMode/ControlModeSession.cs @@ -271,7 +271,6 @@ private async Task DisposeCoreAsync() bool writeLockHeld = false; try { - FailPending(new ObjectDisposedException(nameof(ControlModeSession))); writeLockHeld = await _writeLock.WaitAsync(_exitBudget).ConfigureAwait(false); if (!writeLockHeld) { @@ -288,9 +287,6 @@ private async Task DisposeCoreAsync() await StopProcessAsync(cleanupFailures, forceStop: false).ConfigureAwait(false); } - // A sender can pass its final stopping check immediately before - // disposal begins, then enqueue while disposal is waiting for it. - FailPending(new ObjectDisposedException(nameof(ControlModeSession))); } catch (Exception error) { diff --git a/tests/LibTmux.UnitTests/ControlMode/ControlModeSessionFailureTests.cs b/tests/LibTmux.UnitTests/ControlMode/ControlModeSessionFailureTests.cs index eceb3fe..548259d 100644 --- a/tests/LibTmux.UnitTests/ControlMode/ControlModeSessionFailureTests.cs +++ b/tests/LibTmux.UnitTests/ControlMode/ControlModeSessionFailureTests.cs @@ -87,6 +87,27 @@ public async Task Disposal_kills_a_client_whose_write_holds_the_dispatch_lock() }); } + [Fact] + public async Task Disposal_does_not_orphan_a_reply_from_an_enqueued_write() + { + CancellationToken token = TestContext.Current.CancellationToken; + var process = new StalledWriteProcess(replyOnRelease: true); + var session = new ControlModeSession(process); + await session.WaitForReadyAsync(token); + + Task> send = session.SendAsync( + TmuxCommand.Create("display-message", "-p", "reply"), + token); + await process.WriteStarted.Task.WaitAsync(token); + + Task disposal = session.DisposeAsync().AsTask(); + process.ReleaseWrite(); + + Assert.Equal(["reply"], await send.WaitAsync(token)); + await disposal.WaitAsync(token); + Assert.True(process.DisposeCalled); + } + [Fact] public async Task Terminal_eof_rejects_commands_when_the_process_still_claims_to_run() { @@ -698,10 +719,12 @@ private sealed class StalledWriteProcess : IControlModeProcess TaskCreationOptions.RunContinuationsAsynchronously); private readonly TaskCompletionSource _releaseWrite = new( TaskCreationOptions.RunContinuationsAsynchronously); + private readonly bool _replyOnRelease; private int _hasExited; - internal StalledWriteProcess() + internal StalledWriteProcess(bool replyOnRelease = false) { + _replyOnRelease = replyOnRelease; _output.Writer.TryWrite("%begin 1 1 0"); _output.Writer.TryWrite("%end 1 1 0"); } @@ -719,13 +742,33 @@ public async Task WriteLineAsync( ReadOnlyMemory command, CancellationToken cancellationToken) { + string sentinel = RequestFence(command); WriteStarted.TrySetResult(); await _releaseWrite.Task.ConfigureAwait(false); + if (_replyOnRelease) + { + _output.Writer.TryWrite("%begin 2 2 1"); + _output.Writer.TryWrite("reply"); + _output.Writer.TryWrite("%end 2 2 1"); + _output.Writer.TryWrite("%begin 2 3 1"); + _output.Writer.TryWrite($"parse error: unknown command: {sentinel}"); + _output.Writer.TryWrite("%error 2 3 1"); + return; + } + throw new IOException("The client was killed during its write."); } - public Task FlushAsync(CancellationToken cancellationToken) => - throw new InvalidOperationException("A stalled write must not be flushed."); + public Task FlushAsync(CancellationToken cancellationToken) + { + if (!_replyOnRelease) + { + throw new InvalidOperationException("A stalled write must not be flushed."); + } + + cancellationToken.ThrowIfCancellationRequested(); + return Task.CompletedTask; + } public async Task ReadLineAsync() { @@ -739,8 +782,18 @@ public Task FlushAsync(CancellationToken cancellationToken) => } } - public void CloseInput() => throw new InvalidOperationException( - "Forced disposal must kill rather than close behind an active writer."); + public void CloseInput() + { + if (!_replyOnRelease) + { + throw new InvalidOperationException( + "Forced disposal must kill rather than close behind an active writer."); + } + + Volatile.Write(ref _hasExited, 1); + _output.Writer.TryComplete(); + _exited.TrySetResult(); + } public void Kill() { @@ -755,6 +808,8 @@ public Task WaitForExitAsync(CancellationToken cancellationToken = default) => _exited.Task.WaitAsync(cancellationToken); public void Dispose() => DisposeCalled = true; + + internal void ReleaseWrite() => _releaseWrite.TrySetResult(); } private sealed class AmbiguousDispatchProcess : IControlModeProcess From 6605fd59197985a9a144cacd62c018218a0afe5b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 05:22:54 -0500 Subject: [PATCH 005/129] ControlMode(fix[bounds]): Cap outstanding work why: Unanswered calls, rendered requests, and accumulated replies could retain memory without a transport-level ceiling. what: - reject excess pending work and oversized framed requests before dispatch - bound every control block and aggregate reply while discarding canceled payloads - preserve capacity ownership through cancellation and document the bounded contract --- docs/modes/control-mode.md | 5 + .../ControlMode/ControlModeCommandRenderer.cs | 42 ++- src/LibTmux/ControlMode/ControlModeLimits.cs | 40 ++- src/LibTmux/ControlMode/ControlModeSession.cs | 271 ++++++++++------- .../ControlMode/IControlModeSession.cs | 7 +- .../ControlMode/PendingControlModeCommand.cs | 97 +++++++ .../ControlModeCorrelationTests.cs | 272 ++++++++++++++++++ .../ControlModeSessionFailureTests.cs | 32 +++ 8 files changed, 657 insertions(+), 109 deletions(-) create mode 100644 src/LibTmux/ControlMode/PendingControlModeCommand.cs diff --git a/docs/modes/control-mode.md b/docs/modes/control-mode.md index a1e7990..5fea4eb 100644 --- a/docs/modes/control-mode.md +++ b/docs/modes/control-mode.md @@ -82,6 +82,11 @@ await foreach (TmuxEvent observed in control.Events.WithCancellation(ct)) `SendAsync` is safe to call concurrently: tmux answers in the order it was asked, and each caller gets its own answer. +Outstanding calls are bounded. If the session has reached its pending limit, +`SendAsync` throws `InvalidOperationException` before dispatching another +command. Cancellation stops that caller's wait, not the command; the session +discards that answer until tmux finishes the command, preserving later replies. + ## When this is not the right mode For a single command it is more machinery than the job needs — use diff --git a/src/LibTmux/ControlMode/ControlModeCommandRenderer.cs b/src/LibTmux/ControlMode/ControlModeCommandRenderer.cs index 0cdf1dd..098db07 100644 --- a/src/LibTmux/ControlMode/ControlModeCommandRenderer.cs +++ b/src/LibTmux/ControlMode/ControlModeCommandRenderer.cs @@ -9,19 +9,28 @@ internal static string Render(TmuxCommand command) { ArgumentNullException.ThrowIfNull(command); var rendered = new StringBuilder(); - foreach (string token in command.ToArguments()) + AppendToken(rendered, command.Name); + foreach (string token in command.Arguments) { - if (rendered.Length > 0) - { - rendered.Append(' '); - } - + rendered.Append(' '); AppendToken(rendered, token); } return rendered.ToString(); } + internal static long GetRenderedByteCount(TmuxCommand command) + { + ArgumentNullException.ThrowIfNull(command); + long bytes = GetTokenByteCount(command.Name); + foreach (string token in command.Arguments) + { + bytes += 1 + GetTokenByteCount(token); + } + + return bytes; + } + private static void AppendToken(StringBuilder rendered, string token) { if (token.Contains('\r', StringComparison.Ordinal) @@ -44,4 +53,25 @@ private static void AppendToken(StringBuilder rendered, string token) rendered.Append('\''); } + + private static long GetTokenByteCount(string token) + { + int utf8Bytes = Encoding.UTF8.GetByteCount(token); + if (token.Contains('\r', StringComparison.Ordinal) + || token.Contains('\n', StringComparison.Ordinal)) + { + return (long)utf8Bytes * 4; + } + + long quotedBytes = utf8Bytes + 2L; + foreach (char character in token) + { + if (character == '\'') + { + quotedBytes += 4; + } + } + + return quotedBytes; + } } diff --git a/src/LibTmux/ControlMode/ControlModeLimits.cs b/src/LibTmux/ControlMode/ControlModeLimits.cs index 82e5df9..0c6dd63 100644 --- a/src/LibTmux/ControlMode/ControlModeLimits.cs +++ b/src/LibTmux/ControlMode/ControlModeLimits.cs @@ -3,19 +3,57 @@ namespace LibTmux; internal sealed class ControlModeLimits { private const int DefaultMaxLineBytes = 64 * 1024; + private const int DefaultMaxRequestBytes = 16 * 1024 * 1024; + private const int DefaultMaxBlockBytes = 4 * 1024 * 1024; + private const int DefaultMaxReplyBytes = 16 * 1024 * 1024; private const int DefaultStandardErrorTailBytes = 64 * 1024; internal ControlModeLimits( int maxLineBytes = DefaultMaxLineBytes, - int standardErrorTailBytes = DefaultStandardErrorTailBytes) + int standardErrorTailBytes = DefaultStandardErrorTailBytes, + int maxPendingCommands = 256, + int maxBlockLines = 4096, + int maxBlockBytes = DefaultMaxBlockBytes, + int maxReplyBlocks = 4096, + int maxReplyLines = 16384, + int maxReplyBytes = DefaultMaxReplyBytes, + int maxRequestBytes = DefaultMaxRequestBytes) { ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxLineBytes); ArgumentOutOfRangeException.ThrowIfNegativeOrZero(standardErrorTailBytes); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxPendingCommands); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxBlockLines); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxBlockBytes); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxReplyBlocks); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxReplyLines); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxReplyBytes); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxRequestBytes); MaxLineBytes = maxLineBytes; StandardErrorTailBytes = standardErrorTailBytes; + MaxPendingCommands = maxPendingCommands; + MaxBlockLines = maxBlockLines; + MaxBlockBytes = maxBlockBytes; + MaxReplyBlocks = maxReplyBlocks; + MaxReplyLines = maxReplyLines; + MaxReplyBytes = maxReplyBytes; + MaxRequestBytes = maxRequestBytes; } internal int MaxLineBytes { get; } internal int StandardErrorTailBytes { get; } + + internal int MaxPendingCommands { get; } + + internal int MaxBlockLines { get; } + + internal int MaxBlockBytes { get; } + + internal int MaxReplyBlocks { get; } + + internal int MaxReplyLines { get; } + + internal int MaxReplyBytes { get; } + + internal int MaxRequestBytes { get; } } diff --git a/src/LibTmux/ControlMode/ControlModeSession.cs b/src/LibTmux/ControlMode/ControlModeSession.cs index 39bbdc7..04ca335 100644 --- a/src/LibTmux/ControlMode/ControlModeSession.cs +++ b/src/LibTmux/ControlMode/ControlModeSession.cs @@ -2,6 +2,7 @@ using System.Runtime.ExceptionServices; using System.Runtime.Versioning; using System.Security.Cryptography; +using System.Text; using LibTmux.Internal; namespace LibTmux; @@ -19,6 +20,7 @@ internal sealed class ControlModeSession : IControlModeSession private readonly IControlModeProcess _process; private readonly ServerGeneration? _generation; private readonly TimeSpan _exitBudget; + private readonly ControlModeLimits _limits; private readonly Func _sentinelFactory; /// How many unread events are held before the oldest are dropped. /// @@ -27,11 +29,12 @@ internal sealed class ControlModeSession : IControlModeSession /// The buffer drops the oldest event instead of blocking, since blocking /// would also stall the reader that completes commands. /// - internal const int EventBufferCapacity = 4096; + internal const int EventBufferCapacity = 512; private readonly ControlModeEventBuffer _events = new(EventBufferCapacity); - private readonly Queue _pending = new(); + private readonly Queue _pending = new(); + private readonly SemaphoreSlim _pendingSlots; private readonly SemaphoreSlim _writeLock; private readonly object _disposeGate = new(); private readonly TaskCompletionSource _ready = @@ -54,10 +57,15 @@ internal ControlModeSession( SemaphoreSlim? writeLock = null, TimeSpan? exitBudget = null, ServerGeneration? generation = null, - Func? sentinelFactory = null) + Func? sentinelFactory = null, + ControlModeLimits? limits = null) { _process = process ?? throw new ArgumentNullException(nameof(process)); _generation = generation; + _limits = limits ?? new ControlModeLimits(); + _pendingSlots = new SemaphoreSlim( + _limits.MaxPendingCommands, + _limits.MaxPendingCommands); _writeLock = writeLock ?? new SemaphoreSlim(1, 1); _exitBudget = exitBudget ?? DefaultExitBudget; _sentinelFactory = sentinelFactory ?? CreateSentinel; @@ -108,9 +116,11 @@ internal static ControlModeSession Start( configureEnvironment(startInfo); Process process = Process.Start(startInfo) ?? throw new InvalidOperationException("The tmux control client did not start."); + var limits = new ControlModeLimits(); return new ControlModeSession( - new SystemControlModeProcess(process, new ControlModeLimits()), - generation: generation); + new SystemControlModeProcess(process, limits), + generation: generation, + limits: limits); } /// Waits until tmux has answered its own attach. @@ -129,87 +139,154 @@ public Task> SendAsync( throw new InvalidOperationException("The tmux control client has exited."); } - string sentinel = _sentinelFactory(); - var pending = new PendingCommand(command, sentinel); - Task> transaction = DispatchAndWaitAsync( - ControlModeCommandRenderer.Render(command), - pending, - cancellationToken); - return cancellationToken.CanBeCanceled - ? WaitForCallerAsync(transaction, cancellationToken) - : transaction; + cancellationToken.ThrowIfCancellationRequested(); + if (!_pendingSlots.Wait(0, CancellationToken.None)) + { + throw new InvalidOperationException( + $"The control-mode session reached its {_limits.MaxPendingCommands}-command pending limit."); + } + + return SendAdmittedAsync(command, cancellationToken); } - private async Task> DispatchAndWaitAsync( - string commandLine, - PendingCommand pending, + private async Task> SendAdmittedAsync( + TmuxCommand command, CancellationToken cancellationToken) { - Exception? dispatchFailure = null; - - // Queueing and writing happen together under one lock. tmux answers in - // the order it was asked, so a caller that queued second and wrote - // first would be handed the other caller's answer. - await _writeLock.WaitAsync(cancellationToken).ConfigureAwait(false); + bool transferredSlot = false; try { - lock (_pending) + cancellationToken.ThrowIfCancellationRequested(); + string sentinel = _sentinelFactory(); + if (string.IsNullOrEmpty(sentinel) + || sentinel.Contains('\0', StringComparison.Ordinal) + || sentinel.Contains('\r', StringComparison.Ordinal) + || sentinel.Contains('\n', StringComparison.Ordinal)) { - ThrowIfStopping(); - if (_process.HasExited) - { - throw new InvalidOperationException("The tmux control client has exited."); - } - - // Queued before the write: a command such as kill-server can end - // the client as its own answer, and the pump's exit sweep must - // find this waiter already queued to fail it. - _pending.Enqueue(pending); + throw new InvalidOperationException( + "The control-mode request fence is invalid."); } - try + long requestBytes = ControlModeCommandRenderer.GetRenderedByteCount(command) + + Encoding.UTF8.GetByteCount(sentinel) + + 2L; + if (requestBytes > _limits.MaxRequestBytes) { - string framedCommand = $"{commandLine}\n{pending.Sentinel}"; - await _process.WriteLineAsync(framedCommand.AsMemory(), CancellationToken.None) - .ConfigureAwait(false); - await _process.FlushAsync(CancellationToken.None).ConfigureAwait(false); - } - catch (Exception error) - { - // A failed pipe write may have dispatched any prefix, including - // the whole command. No later reply can be correlated safely. - Volatile.Write(ref _stopRequested, 1); - dispatchFailure = error; - FailPending(new InvalidOperationException( - "The control client lost command alignment after an ambiguous write failure.", - error)); - _ = pending.Completion.Task.Exception; + throw new ArgumentException( + $"The control-mode request exceeds its {_limits.MaxRequestBytes}-byte limit.", + nameof(command)); } + + var pending = new PendingControlModeCommand(command, sentinel); + Task> transaction = DispatchAndWaitAsync( + command, + pending, + cancellationToken); + transferredSlot = true; + return cancellationToken.CanBeCanceled + ? await WaitForCallerAsync(transaction, pending, cancellationToken) + .ConfigureAwait(false) + : await transaction.ConfigureAwait(false); } finally { - _writeLock.Release(); + if (!transferredSlot) + { + _pendingSlots.Release(); + } } + } - if (dispatchFailure is not null) + private async Task> DispatchAndWaitAsync( + TmuxCommand command, + PendingControlModeCommand pending, + CancellationToken cancellationToken) + { + Exception? dispatchFailure = null; + bool ownsPendingSlot = true; + + try { + // Queueing and writing happen together under one lock. tmux answers in + // the order it was asked, so a caller that queued second and wrote + // first would be handed the other caller's answer. + await _writeLock.WaitAsync(cancellationToken).ConfigureAwait(false); try { - await DisposeAsync().ConfigureAwait(false); + lock (_pending) + { + ThrowIfStopping(); + if (_process.HasExited) + { + throw new InvalidOperationException( + "The tmux control client has exited."); + } + + // Queued before the write: a command such as kill-server can end + // the client as its own answer, and the pump's exit sweep must + // find this waiter already queued to fail it. + _pending.Enqueue(pending); + ownsPendingSlot = false; + pending.MarkEnqueued(); + } + + try + { + await WriteRequestAsync(command, pending.Sentinel).ConfigureAwait(false); + } + catch (Exception error) + { + // A failed pipe write may have dispatched any prefix, including + // the whole command. No later reply can be correlated safely. + Volatile.Write(ref _stopRequested, 1); + dispatchFailure = error; + FailPending(new InvalidOperationException( + "The control client lost command alignment after an ambiguous write failure.", + error)); + _ = pending.Completion.Task.Exception; + } } - catch (Exception cleanupFailure) + finally { - dispatchFailure.Data["LibTmux.ControlModeCleanupFailure"] = cleanupFailure; + _writeLock.Release(); } - ExceptionDispatchInfo.Capture(dispatchFailure).Throw(); + if (dispatchFailure is not null) + { + try + { + await DisposeAsync().ConfigureAwait(false); + } + catch (Exception cleanupFailure) + { + dispatchFailure.Data["LibTmux.ControlModeCleanupFailure"] = cleanupFailure; + } + + ExceptionDispatchInfo.Capture(dispatchFailure).Throw(); + } + + return await pending.Completion.Task.ConfigureAwait(false); } + finally + { + if (ownsPendingSlot) + { + _pendingSlots.Release(); + } + } + } - return await pending.Completion.Task.ConfigureAwait(false); + private async Task WriteRequestAsync(TmuxCommand command, string sentinel) + { + string framedCommand = $"{ControlModeCommandRenderer.Render(command)}\n{sentinel}"; + await _process.WriteLineAsync(framedCommand.AsMemory(), CancellationToken.None) + .ConfigureAwait(false); + await _process.FlushAsync(CancellationToken.None).ConfigureAwait(false); } private static async Task> WaitForCallerAsync( Task> transaction, + PendingControlModeCommand pending, CancellationToken cancellationToken) { try @@ -218,6 +295,8 @@ private static async Task> WaitForCallerAsync( } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { + pending.Abandon(); + await Task.WhenAny(transaction, pending.Enqueued).ConfigureAwait(false); _ = transaction.ContinueWith( static completed => _ = completed.Exception, CancellationToken.None, @@ -490,21 +569,6 @@ private static void ThrowDisposalFailures( private void ThrowIfStopping() => ObjectDisposedException.ThrowIf(Volatile.Read(ref _stopRequested) != 0, this); - /// One waiting command. - private sealed class PendingCommand(TmuxCommand command, string sentinel) - { - internal TmuxCommand Command { get; } = command; - - internal List ErrorLines { get; } = []; - - internal List OutputLines { get; } = []; - - internal string Sentinel { get; } = sentinel; - - internal TaskCompletionSource> Completion { get; } = - new(TaskCreationOptions.RunContinuationsAsynchronously); - } - private async Task PumpAsync() { string? exitReason = null; @@ -565,6 +629,7 @@ private async Task ReadBlockAsync(ControlModeGuard begin) { // Only a matching %end or %error terminates a block; output may start with %. List lines = []; + int blockBytes = 0; bool failed = false; bool terminated = false; @@ -587,7 +652,21 @@ private async Task ReadBlockAsync(ControlModeGuard begin) } } + if (lines.Count >= _limits.MaxBlockLines) + { + throw new InvalidDataException( + $"A control-mode block exceeded its {_limits.MaxBlockLines}-line limit."); + } + + int lineBytes = Encoding.UTF8.GetByteCount(line); + if (lineBytes > _limits.MaxBlockBytes - blockBytes) + { + throw new InvalidDataException( + $"A control-mode block exceeded its {_limits.MaxBlockBytes}-byte limit."); + } + lines.Add(line); + blockBytes += lineBytes; } if (!terminated) @@ -616,7 +695,7 @@ private async Task ReadBlockAsync(ControlModeGuard begin) return; } - PendingCommand? pending; + PendingControlModeCommand? pending; lock (_pending) { pending = _pending.Count == 0 ? null : _pending.Peek(); @@ -646,24 +725,10 @@ private async Task ReadBlockAsync(ControlModeGuard begin) "The tmux control client returned an unrecognized request fence."); } - if (failed) - { - if (lines.Count == 0) - { - pending.ErrorLines.Add("The tmux command failed."); - } - else - { - pending.ErrorLines.AddRange(lines); - } - - return; - } - - pending.OutputLines.AddRange(lines); + pending.AddBlock(lines, blockBytes, failed, _limits); } - private void CompletePending(PendingCommand pending) + private void CompletePending(PendingControlModeCommand pending) { lock (_pending) { @@ -676,18 +741,8 @@ private void CompletePending(PendingCommand pending) _pending.Dequeue(); } - if (pending.ErrorLines.Count == 0) - { - pending.Completion.TrySetResult([.. pending.OutputLines]); - return; - } - - string reported = string.Join('\n', pending.ErrorLines); - pending.Completion.TrySetException(new ControlModeCommandException( - reported.Length == 0 ? "The tmux command failed." : reported, - pending.Command, - pending.OutputLines, - pending.ErrorLines)); + _pendingSlots.Release(); + pending.Complete(); } private static (string Name, IReadOnlyList Arguments) SplitNotification(string line) @@ -727,25 +782,39 @@ private void FailPending(Exception? failure = null) { failure ??= new InvalidOperationException( "The tmux control client exited before answering."); + int released = 0; lock (_pending) { while (_pending.Count > 0) { _pending.Dequeue().Completion.TrySetException(failure); + released++; } } + + if (released > 0) + { + _pendingSlots.Release(released); + } } private void StopAndFailPending(Exception failure) { + int released = 0; lock (_pending) { Volatile.Write(ref _stopRequested, 1); while (_pending.Count > 0) { _pending.Dequeue().Completion.TrySetException(failure); + released++; } } + + if (released > 0) + { + _pendingSlots.Release(released); + } } } diff --git a/src/LibTmux/ControlMode/IControlModeSession.cs b/src/LibTmux/ControlMode/IControlModeSession.cs index 5aa2479..373d5b4 100644 --- a/src/LibTmux/ControlMode/IControlModeSession.cs +++ b/src/LibTmux/ControlMode/IControlModeSession.cs @@ -40,10 +40,15 @@ public interface IControlModeSession : IAsyncDisposable /// /// Tmux reported the command failed. /// + /// + /// The rendered command is too large for one bounded request. + /// /// /// The command targets a different tmux server generation. /// - /// The client is no longer running. + /// + /// The client is no longer running or has too many unanswered commands. + /// public Task> SendAsync( TmuxCommand command, CancellationToken cancellationToken = default); diff --git a/src/LibTmux/ControlMode/PendingControlModeCommand.cs b/src/LibTmux/ControlMode/PendingControlModeCommand.cs new file mode 100644 index 0000000..e78116e --- /dev/null +++ b/src/LibTmux/ControlMode/PendingControlModeCommand.cs @@ -0,0 +1,97 @@ +namespace LibTmux; + +internal sealed class PendingControlModeCommand(TmuxCommand command, string sentinel) +{ + private readonly object _gate = new(); + private readonly TaskCompletionSource _enqueued = + new(TaskCreationOptions.RunContinuationsAsynchronously); + private bool _abandoned; + private bool _failed; + private int _replyBlocks; + private int _replyBytes; + private int _replyLines; + + private TmuxCommand Command { get; } = command; + + private List ErrorLines { get; } = []; + + private List OutputLines { get; } = []; + + internal string Sentinel { get; } = sentinel; + + internal TaskCompletionSource> Completion { get; } = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + internal Task Enqueued => _enqueued.Task; + + internal void MarkEnqueued() => _enqueued.TrySetResult(); + + internal void Abandon() + { + lock (_gate) + { + _abandoned = true; + _failed = false; + ErrorLines.Clear(); + OutputLines.Clear(); + } + } + + internal void AddBlock( + List lines, + int blockBytes, + bool failed, + ControlModeLimits limits) + { + lock (_gate) + { + _replyBlocks++; + if (_replyBlocks > limits.MaxReplyBlocks) + { + throw new InvalidDataException( + $"A control-mode reply exceeded its {limits.MaxReplyBlocks}-block limit."); + } + + if (lines.Count > limits.MaxReplyLines - _replyLines) + { + throw new InvalidDataException( + $"A control-mode reply exceeded its {limits.MaxReplyLines}-line limit."); + } + + if (blockBytes > limits.MaxReplyBytes - _replyBytes) + { + throw new InvalidDataException( + $"A control-mode reply exceeded its {limits.MaxReplyBytes}-byte limit."); + } + + _replyLines += lines.Count; + _replyBytes += blockBytes; + if (_abandoned) + { + return; + } + + _failed |= failed; + (failed ? ErrorLines : OutputLines).AddRange(lines); + } + } + + internal void Complete() + { + lock (_gate) + { + if (!_failed) + { + Completion.TrySetResult([.. OutputLines]); + return; + } + + string reported = string.Join('\n', ErrorLines); + Completion.TrySetException(new ControlModeCommandException( + reported.Length == 0 ? "The tmux command failed." : reported, + Command, + OutputLines, + ErrorLines)); + } + } +} diff --git a/tests/LibTmux.UnitTests/ControlMode/ControlModeCorrelationTests.cs b/tests/LibTmux.UnitTests/ControlMode/ControlModeCorrelationTests.cs index c1aefe2..80d10fd 100644 --- a/tests/LibTmux.UnitTests/ControlMode/ControlModeCorrelationTests.cs +++ b/tests/LibTmux.UnitTests/ControlMode/ControlModeCorrelationTests.cs @@ -69,6 +69,247 @@ public async Task A_failed_command_keeps_its_typed_diagnostics() Assert.Equal(["unknown command"], error.ErrorLines); } + [Fact] + public async Task Pending_admission_rejects_without_dispatching_past_its_limit() + { + CancellationToken token = TestContext.Current.CancellationToken; + string[] sentinels = + [ + "libtmux-control-first", + "libtmux-control-third", + ]; + int sentinelIndex = 0; + var process = new ScriptedProcess(expectedWrites: 1); + await using var session = new ControlModeSession( + process, + sentinelFactory: () => sentinels[sentinelIndex++], + limits: new ControlModeLimits(maxPendingCommands: 1)); + await session.WaitForReadyAsync(token); + + Task> first = session.SendAsync(TmuxCommand.Create("first"), token); + await process.WritesObserved.Task.WaitAsync(token); + InvalidOperationException error = Assert.Throws( + () => + { + _ = session.SendAsync(TmuxCommand.Create("second"), token); + }); + Assert.Contains("1-command pending limit", error.Message, StringComparison.Ordinal); + Assert.Single(process.Writes); + + process.EmitFence(number: 10, sentinels[0]); + Assert.Empty(await first); + Task> third = session.SendAsync(TmuxCommand.Create("third"), token); + Assert.Equal(2, process.Writes.Count); + process.EmitFence(number: 11, sentinels[1]); + Assert.Empty(await third); + } + + [Fact] + public async Task A_request_beyond_its_byte_limit_is_not_dispatched() + { + CancellationToken token = TestContext.Current.CancellationToken; + var process = new ScriptedProcess(expectedWrites: 1); + await using var session = new ControlModeSession( + process, + sentinelFactory: () => "f", + limits: new ControlModeLimits(maxPendingCommands: 1, maxRequestBytes: 8)); + await session.WaitForReadyAsync(token); + using var requestBudget = CancellationTokenSource.CreateLinkedTokenSource(token); + requestBudget.CancelAfter(TimeSpan.FromMilliseconds(100)); + + ArgumentException error = await Assert.ThrowsAsync( + () => session.SendAsync( + TmuxCommand.Create("display-message"), + requestBudget.Token)); + + Assert.Equal("command", error.ParamName); + Assert.Contains("8-byte limit", error.Message, StringComparison.Ordinal); + Assert.Empty(process.Writes); + Assert.True(session.IsRunning); + + Task> following = session.SendAsync(TmuxCommand.Create("x"), token); + await process.WritesObserved.Task.WaitAsync(token); + process.EmitFence(number: 10, "f"); + Assert.Empty(await following); + } + + [Fact] + public async Task A_canceled_request_keeps_its_slot_until_its_fence() + { + CancellationToken token = TestContext.Current.CancellationToken; + string[] sentinels = ["libtmux-control-canceled", "libtmux-control-following"]; + int sentinelIndex = 0; + var process = new ScriptedProcess(expectedWrites: 1); + await using var session = new ControlModeSession( + process, + sentinelFactory: () => sentinels[sentinelIndex++], + limits: new ControlModeLimits(maxPendingCommands: 1, maxReplyBytes: 20)); + await session.WaitForReadyAsync(token); + using var canceled = CancellationTokenSource.CreateLinkedTokenSource(token); + + Task> abandoned = session.SendAsync( + TmuxCommand.Create("first"), + canceled.Token); + await process.WritesObserved.Task.WaitAsync(token); + canceled.Cancel(); + await Assert.ThrowsAnyAsync(async () => await abandoned); + InvalidOperationException full = Assert.Throws( + () => + { + _ = session.SendAsync(TmuxCommand.Create("too-early"), token); + }); + Assert.Contains("1-command pending limit", full.Message, StringComparison.Ordinal); + Assert.Single(process.Writes); + + await using IAsyncEnumerator events = + session.Events.GetAsyncEnumerator(token); + process.EmitBlock(number: 10, flags: 1, failed: false, "discard-me"); + process.EmitFence(number: 11, sentinels[0]); + process.EmitNotification("test-fence-drained"); + Assert.True(await events.MoveNextAsync()); + Task> following = session.SendAsync( + TmuxCommand.Create("following"), + token); + Assert.Equal(2, process.Writes.Count); + process.EmitBlock(number: 12, flags: 1, failed: false, "ok"); + process.EmitFence(number: 13, sentinels[1]); + + Assert.Equal(["ok"], await following); + } + + [Fact] + public async Task A_canceled_request_still_enforces_aggregate_reply_limits() + { + CancellationToken token = TestContext.Current.CancellationToken; + const string Sentinel = "libtmux-control-canceled-bounded"; + var process = new ScriptedProcess(expectedWrites: 1); + var session = new ControlModeSession( + process, + sentinelFactory: () => Sentinel, + limits: new ControlModeLimits(maxReplyBytes: 5)); + await session.WaitForReadyAsync(token); + using var canceled = CancellationTokenSource.CreateLinkedTokenSource(token); + + Task> abandoned = session.SendAsync( + TmuxCommand.Create("bounded"), + canceled.Token); + await process.WritesObserved.Task.WaitAsync(token); + canceled.Cancel(); + await Assert.ThrowsAnyAsync(async () => await abandoned); + + process.EmitBlock(number: 10, flags: 1, failed: false, "123"); + process.EmitBlock(number: 11, flags: 1, failed: false, "456"); + + InvalidDataException error = await Assert.ThrowsAsync( + () => session.DisposeAsync().AsTask()); + Assert.Equal("A control-mode reply exceeded its 5-byte limit.", error.Message); + } + + [Theory] + [InlineData(ReplyLimit.Bytes)] + [InlineData(ReplyLimit.Lines)] + [InlineData(ReplyLimit.Blocks)] + public async Task Aggregate_reply_limits_fail_the_session(ReplyLimit limit) + { + CancellationToken token = TestContext.Current.CancellationToken; + const string Sentinel = "libtmux-control-bounded"; + ControlModeLimits limits = limit switch + { + ReplyLimit.Bytes => new ControlModeLimits(maxReplyBytes: 5), + ReplyLimit.Lines => new ControlModeLimits(maxReplyLines: 1), + ReplyLimit.Blocks => new ControlModeLimits(maxReplyBlocks: 1), + _ => throw new ArgumentOutOfRangeException(nameof(limit)), + }; + var process = new ScriptedProcess(expectedWrites: 1); + var session = new ControlModeSession( + process, + sentinelFactory: () => Sentinel, + limits: limits); + await session.WaitForReadyAsync(token); + Task> send = session.SendAsync(TmuxCommand.Create("bounded"), token); + await process.WritesObserved.Task.WaitAsync(token); + + switch (limit) + { + case ReplyLimit.Bytes: + process.EmitBlock(number: 10, flags: 1, failed: false, "123"); + process.EmitBlock(number: 11, flags: 1, failed: false, "456"); + break; + case ReplyLimit.Lines: + process.EmitBlock(number: 10, flags: 1, failed: false, string.Empty); + process.EmitBlock(number: 11, flags: 1, failed: false, string.Empty); + break; + case ReplyLimit.Blocks: + process.EmitBlock(number: 10, flags: 1, failed: false); + process.EmitBlock(number: 11, flags: 1, failed: false); + break; + } + + process.EmitFence(number: 12, Sentinel); + InvalidDataException error = await Assert.ThrowsAsync( + async () => await send); + InvalidDataException disposal = await Assert.ThrowsAsync( + () => session.DisposeAsync().AsTask()); + string unit = limit switch + { + ReplyLimit.Bytes => "5-byte", + ReplyLimit.Lines => "1-line", + ReplyLimit.Blocks => "1-block", + _ => throw new ArgumentOutOfRangeException(nameof(limit)), + }; + Assert.Equal($"A control-mode reply exceeded its {unit} limit.", error.Message); + Assert.Same(error, disposal); + } + + [Theory] + [InlineData(BlockLimit.Bytes)] + [InlineData(BlockLimit.Lines)] + public async Task Hook_block_limits_fail_the_session(BlockLimit limit) + { + CancellationToken token = TestContext.Current.CancellationToken; + ControlModeLimits limits = limit == BlockLimit.Bytes + ? new ControlModeLimits(maxBlockBytes: 5) + : new ControlModeLimits(maxBlockLines: 1); + var process = new ScriptedProcess(expectedWrites: 0); + var session = new ControlModeSession(process, limits: limits); + await session.WaitForReadyAsync(token); + + process.EmitBlock( + number: 10, + flags: 0, + failed: false, + limit == BlockLimit.Bytes ? ["123456"] : ["one", "two"]); + + InvalidDataException error = await Assert.ThrowsAsync( + () => session.DisposeAsync().AsTask()); + string unit = limit == BlockLimit.Bytes ? "5-byte" : "1-line"; + Assert.Equal($"A control-mode block exceeded its {unit} limit.", error.Message); + } + + [Fact] + public async Task An_empty_error_block_does_not_invent_a_reported_line() + { + CancellationToken token = TestContext.Current.CancellationToken; + const string Sentinel = "libtmux-control-empty-error"; + var process = new ScriptedProcess(expectedWrites: 1); + await using var session = new ControlModeSession( + process, + sentinelFactory: () => Sentinel); + await session.WaitForReadyAsync(token); + + Task> send = session.SendAsync( + TmuxCommand.Create("empty-error"), + token); + await process.WritesObserved.Task.WaitAsync(token); + process.EmitBlock(number: 10, flags: 1, failed: true); + process.EmitFence(number: 11, Sentinel); + + ControlModeCommandException error = + await Assert.ThrowsAsync(async () => await send); + Assert.Equal("The tmux command failed.", error.Message); + Assert.Empty(error.ErrorLines); + } + [Fact] public async Task A_stale_command_is_rejected_before_dispatch() { @@ -107,6 +348,35 @@ public void Typed_arguments_render_without_a_second_physical_line() Assert.EndsWith("\\145\\156\\144", rendered, StringComparison.Ordinal); } + [Fact] + public void Rendered_byte_count_matches_the_physical_utf8_line() + { + TmuxCommand command = TmuxCommand.Create( + "display-message", + "-p", + "a'b π", + "line\r\nend"); + + string rendered = ControlModeCommandRenderer.Render(command); + + Assert.Equal( + System.Text.Encoding.UTF8.GetByteCount(rendered), + ControlModeCommandRenderer.GetRenderedByteCount(command)); + } + + public enum BlockLimit + { + Bytes, + Lines, + } + + public enum ReplyLimit + { + Bytes, + Lines, + Blocks, + } + private sealed class ScriptedProcess : IControlModeProcess { private readonly Channel _output = Channel.CreateUnbounded(); @@ -207,6 +477,8 @@ internal void EmitFence(int number, string sentinel) => failed: true, $"parse error: unknown command: {sentinel}"); + internal void EmitNotification(string name) => _output.Writer.TryWrite($"%{name}"); + private void Stop(string exit) { if (Interlocked.Exchange(ref _hasExited, 1) != 0) diff --git a/tests/LibTmux.UnitTests/ControlMode/ControlModeSessionFailureTests.cs b/tests/LibTmux.UnitTests/ControlMode/ControlModeSessionFailureTests.cs index 548259d..1579806 100644 --- a/tests/LibTmux.UnitTests/ControlMode/ControlModeSessionFailureTests.cs +++ b/tests/LibTmux.UnitTests/ControlMode/ControlModeSessionFailureTests.cs @@ -108,6 +108,38 @@ public async Task Disposal_does_not_orphan_a_reply_from_an_enqueued_write() Assert.True(process.DisposeCalled); } + [Fact] + public async Task A_canceled_write_lock_wait_releases_its_unenqueued_slot() + { + CancellationToken token = TestContext.Current.CancellationToken; + var process = new StalledWriteProcess(); + var session = new ControlModeSession( + process, + exitBudget: TimeSpan.FromMilliseconds(25), + limits: new ControlModeLimits(maxPendingCommands: 2)); + await session.WaitForReadyAsync(token); + + Task> first = session.SendAsync( + TmuxCommand.Create("first"), + token); + await process.WriteStarted.Task.WaitAsync(token); + using var canceled = CancellationTokenSource.CreateLinkedTokenSource(token); + Task> waiting = session.SendAsync( + TmuxCommand.Create("waiting"), + canceled.Token); + + canceled.Cancel(); + await Assert.ThrowsAnyAsync(async () => await waiting); + Task> admitted = session.SendAsync( + TmuxCommand.Create("admitted"), + token); + Assert.False(admitted.IsCompleted); + + await session.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(2), token); + await Assert.ThrowsAsync(async () => await first); + await Assert.ThrowsAsync(async () => await admitted); + } + [Fact] public async Task Terminal_eof_rejects_commands_when_the_process_still_claims_to_run() { From 5378f4beafe87f6cbee0dcf1c917e0eed306bbee Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 05:39:54 -0500 Subject: [PATCH 006/129] ControlMode(fix[cleanup]): Bound asynchronous disposal why: Serial per-phase timeouts could multiply the nominal cleanup window and lose late task faults. what: - reserve grace time, then await forced exit, output, stderr, and writer cleanup concurrently under one deadline - preserve boundary faults and observe operations that complete after timeout - extract cleanup orchestration and verify timing with an injected manual clock --- .../ControlMode/ControlModeDisposer.cs | 386 ++++++++++++++++++ src/LibTmux/ControlMode/ControlModeSession.cs | 251 +----------- .../ControlModeDisposalDeadlineTests.cs | 263 ++++++++++++ .../ControlModeSessionFailureTests.cs | 4 +- 4 files changed, 670 insertions(+), 234 deletions(-) create mode 100644 src/LibTmux/ControlMode/ControlModeDisposer.cs create mode 100644 tests/LibTmux.UnitTests/ControlMode/ControlModeDisposalDeadlineTests.cs diff --git a/src/LibTmux/ControlMode/ControlModeDisposer.cs b/src/LibTmux/ControlMode/ControlModeDisposer.cs new file mode 100644 index 0000000..ec66a47 --- /dev/null +++ b/src/LibTmux/ControlMode/ControlModeDisposer.cs @@ -0,0 +1,386 @@ +using System.Runtime.ExceptionServices; + +namespace LibTmux; + +internal sealed class ControlModeDisposer +{ + private readonly TimeSpan _budget; + private readonly Task _outputPump; + private readonly IControlModeProcess _process; + private readonly TimeProvider _timeProvider; + private readonly SemaphoreSlim _writeLock; + + internal ControlModeDisposer( + IControlModeProcess process, + SemaphoreSlim writeLock, + Task outputPump, + TimeSpan budget, + TimeProvider timeProvider) + { + _process = process ?? throw new ArgumentNullException(nameof(process)); + _writeLock = writeLock ?? throw new ArgumentNullException(nameof(writeLock)); + _outputPump = outputPump ?? throw new ArgumentNullException(nameof(outputPump)); + _budget = budget; + _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); + } + + internal async Task DisposeAsync() + { + var cleanupFailures = new List(); + using var boundaryCancellation = new CancellationTokenSource(); + Exception? boundarySetupFailure = null; + Task deadlineBoundary; + Task graceBoundary; + try + { + deadlineBoundary = Task.Delay( + _budget, + _timeProvider, + boundaryCancellation.Token); + graceBoundary = Task.Delay( + TimeSpan.FromTicks(_budget.Ticks / 2), + _timeProvider, + boundaryCancellation.Token); + } + catch (Exception error) + { + boundarySetupFailure = error; + deadlineBoundary = Task.CompletedTask; + graceBoundary = Task.CompletedTask; + } + + bool writeLockHeld = false; + Task? writeLockWait = null; + Task exitWait = Task.CompletedTask; + Task errorPumpStop = Task.CompletedTask; + Task[] operations = []; + Task all = Task.CompletedTask; + bool deadlineExceeded = false; + try + { + writeLockHeld = _writeLock.Wait(0, CancellationToken.None); + if (!writeLockHeld) + { + writeLockWait = _writeLock.WaitAsync(CancellationToken.None); + if (await CompletesBeforeAsync(writeLockWait, graceBoundary) + .ConfigureAwait(false)) + { + if (writeLockWait.IsCompletedSuccessfully) + { + writeLockHeld = true; + } + else + { + AddTaskFailures(writeLockWait, cleanupFailures); + } + + writeLockWait = null; + } + } + + exitWait = await BeginProcessStopAsync( + cleanupFailures, + forceStop: !writeLockHeld, + graceBoundary) + .ConfigureAwait(false); + errorPumpStop = StartErrorPumpStop(); + operations = writeLockWait is null + ? [exitWait, _outputPump, errorPumpStop] + : [exitWait, _outputPump, errorPumpStop, writeLockWait!]; + all = Task.WhenAll(operations); + deadlineExceeded = !await CompletesBeforeAsync(all, deadlineBoundary) + .ConfigureAwait(false); + } + catch (Exception error) + { + cleanupFailures.Add(error); + } + finally + { + if (!all.IsCompleted) + { + ObserveFutureFailures([all, .. operations]); + } + + try + { + boundaryCancellation.Cancel(); + } + catch (Exception error) + { + cleanupFailures.Add(error); + } + + try + { + _process.Dispose(); + } + catch (Exception error) + { + cleanupFailures.Add(error); + } + + if (!writeLockHeld && writeLockWait?.IsCompletedSuccessfully == true) + { + writeLockHeld = true; + } + + DisposeWriteLock(writeLockHeld, cleanupFailures); + } + + Exception? pumpFailure = GetTaskFailure(_outputPump); + foreach (Task operation in operations) + { + if (!ReferenceEquals(operation, _outputPump)) + { + AddTaskFailures(operation, cleanupFailures); + } + } + + if (all.IsFaulted) + { + _ = all.Exception; + } + + if (boundarySetupFailure is not null) + { + cleanupFailures.Add(boundarySetupFailure); + } + else if (deadlineExceeded) + { + cleanupFailures.Add(new TimeoutException( + "Control-mode asynchronous cleanup exceeded its disposal deadline.")); + } + + ThrowFailures(pumpFailure, cleanupFailures); + } + + private async Task BeginProcessStopAsync( + List cleanupFailures, + bool forceStop, + Task graceBoundary) + { + if (ReadHasExited(cleanupFailures)) + { + return Task.CompletedTask; + } + + Task exitWait = StartExitWait(); + bool exitFailureRecorded = false; + if (!forceStop) + { + try + { + _process.CloseInput(); + } + catch (InvalidOperationException) when (ProcessHasExited()) + { + return Task.CompletedTask; + } + catch (Exception error) + { + cleanupFailures.Add(error); + } + + if (await CompletesBeforeAsync(exitWait, graceBoundary).ConfigureAwait(false)) + { + if (exitWait.IsCompletedSuccessfully) + { + return exitWait; + } + + if (IsBenignExitFailure(exitWait)) + { + return Task.CompletedTask; + } + + AddTaskFailures(exitWait, cleanupFailures); + exitFailureRecorded = true; + } + + forceStop = true; + } + + if (forceStop) + { + // Kill only the client; its server may still be serving other clients. + try + { + if (!ProcessHasExited()) + { + _process.Kill(); + } + } + catch (InvalidOperationException) when (ProcessHasExited()) + { + } + catch (Exception error) + { + cleanupFailures.Add(error); + } + } + + if (!exitWait.IsCompleted || exitWait.IsCompletedSuccessfully) + { + return exitWait; + } + + if (!exitFailureRecorded && !IsBenignExitFailure(exitWait)) + { + AddTaskFailures(exitWait, cleanupFailures); + } + + return ProcessHasExited() ? Task.CompletedTask : StartExitWait(); + } + + private bool ReadHasExited(List cleanupFailures) + { + try + { + return _process.HasExited; + } + catch (Exception error) + { + cleanupFailures.Add(error); + return false; + } + } + + private bool ProcessHasExited() + { + try + { + return _process.HasExited; + } + catch + { + return false; + } + } + + private bool IsBenignExitFailure(Task exitWait) => + exitWait.IsFaulted + && exitWait.Exception!.Flatten().InnerExceptions.All( + static error => error is InvalidOperationException) + && ProcessHasExited(); + + private Task StartExitWait() + { + try + { + return _process.WaitForExitAsync(CancellationToken.None); + } + catch (Exception error) + { + return Task.FromException(error); + } + } + + private Task StartErrorPumpStop() + { + try + { + return _process.StopErrorPumpAsync(CancellationToken.None); + } + catch (Exception error) + { + return Task.FromException(error); + } + } + + private static async Task CompletesBeforeAsync(Task operation, Task boundary) + { + if (!operation.IsCompleted) + { + await Task.WhenAny(operation, boundary).ConfigureAwait(false); + } + + return operation.IsCompleted; + } + + private void DisposeWriteLock(bool writeLockHeld, List cleanupFailures) + { + if (!writeLockHeld) + { + return; + } + + try + { + _writeLock.Release(); + } + catch (Exception error) + { + cleanupFailures.Add(error); + } + + try + { + _writeLock.Dispose(); + } + catch (Exception error) + { + cleanupFailures.Add(error); + } + } + + private static Exception? GetTaskFailure(Task operation) + { + if (operation.IsFaulted) + { + var failures = operation.Exception! + .Flatten() + .InnerExceptions; + return failures.Count == 1 ? failures[0] : new AggregateException(failures); + } + + return operation.IsCanceled ? new TaskCanceledException(operation) : null; + } + + private static void AddTaskFailures(Task operation, List failures) + { + Exception? failure = GetTaskFailure(operation); + if (failure is not null) + { + failures.Add(failure); + } + } + + private static void ObserveFutureFailures(IEnumerable operations) + { + foreach (Task operation in operations) + { + _ = operation.ContinueWith( + static completed => _ = completed.Exception, + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously + | TaskContinuationOptions.OnlyOnFaulted, + TaskScheduler.Default); + } + } + + private static void ThrowFailures( + Exception? pumpFailure, + List cleanupFailures) + { + if (pumpFailure is not null) + { + if (cleanupFailures.Count == 0) + { + ExceptionDispatchInfo.Capture(pumpFailure).Throw(); + } + + throw new AggregateException([pumpFailure, .. cleanupFailures]); + } + + if (cleanupFailures.Count == 1) + { + ExceptionDispatchInfo.Capture(cleanupFailures[0]).Throw(); + } + + if (cleanupFailures.Count > 1) + { + throw new AggregateException(cleanupFailures); + } + } +} diff --git a/src/LibTmux/ControlMode/ControlModeSession.cs b/src/LibTmux/ControlMode/ControlModeSession.cs index 04ca335..0d69976 100644 --- a/src/LibTmux/ControlMode/ControlModeSession.cs +++ b/src/LibTmux/ControlMode/ControlModeSession.cs @@ -19,9 +19,10 @@ internal sealed class ControlModeSession : IControlModeSession { private readonly IControlModeProcess _process; private readonly ServerGeneration? _generation; - private readonly TimeSpan _exitBudget; + private readonly TimeSpan _disposalBudget; private readonly ControlModeLimits _limits; private readonly Func _sentinelFactory; + private readonly TimeProvider _timeProvider; /// How many unread events are held before the oldest are dropped. /// /// A pane can outpace any reader, and a caller may never read @@ -44,21 +45,21 @@ internal sealed class ControlModeSession : IControlModeSession private Task? _disposeTask; private int _stopRequested; - /// How long disposal waits for the client to exit before killing it. + /// How long disposal awaits cleanup work. /// - /// Closing stdin asks tmux to leave. A client that does not answer -- wedged, - /// stopped, or waiting on something -- would otherwise hang the caller's - /// disposal forever, and disposal is the one operation that has to finish. + /// Process state, close, kill, and dispose calls are synchronous. This + /// bounds the waits they begin; it cannot preempt a synchronous process API. /// - private static readonly TimeSpan DefaultExitBudget = TimeSpan.FromSeconds(5); + private static readonly TimeSpan DefaultDisposalBudget = TimeSpan.FromSeconds(5); internal ControlModeSession( IControlModeProcess process, SemaphoreSlim? writeLock = null, - TimeSpan? exitBudget = null, + TimeSpan? disposalBudget = null, ServerGeneration? generation = null, Func? sentinelFactory = null, - ControlModeLimits? limits = null) + ControlModeLimits? limits = null, + TimeProvider? timeProvider = null) { _process = process ?? throw new ArgumentNullException(nameof(process)); _generation = generation; @@ -67,11 +68,12 @@ internal ControlModeSession( _limits.MaxPendingCommands, _limits.MaxPendingCommands); _writeLock = writeLock ?? new SemaphoreSlim(1, 1); - _exitBudget = exitBudget ?? DefaultExitBudget; + _disposalBudget = disposalBudget ?? DefaultDisposalBudget; _sentinelFactory = sentinelFactory ?? CreateSentinel; - if (_exitBudget <= TimeSpan.Zero) + _timeProvider = timeProvider ?? TimeProvider.System; + if (_disposalBudget <= TimeSpan.Zero) { - throw new ArgumentOutOfRangeException(nameof(exitBudget)); + throw new ArgumentOutOfRangeException(nameof(disposalBudget)); } _pump = Task.Run(PumpAsync); @@ -344,227 +346,12 @@ public async ValueTask DisposeAsync() await disposal.ConfigureAwait(false); } - private async Task DisposeCoreAsync() - { - var cleanupFailures = new List(); - bool writeLockHeld = false; - try - { - writeLockHeld = await _writeLock.WaitAsync(_exitBudget).ConfigureAwait(false); - if (!writeLockHeld) - { - await StopProcessAsync(cleanupFailures, forceStop: true).ConfigureAwait(false); - writeLockHeld = await _writeLock.WaitAsync(_exitBudget).ConfigureAwait(false); - if (!writeLockHeld) - { - cleanupFailures.Add(new TimeoutException( - "The active control-mode write did not stop after its client was killed.")); - } - } - else - { - await StopProcessAsync(cleanupFailures, forceStop: false).ConfigureAwait(false); - } - - } - catch (Exception error) - { - cleanupFailures.Add(error); - } - finally - { - if (writeLockHeld) - { - try - { - _writeLock.Release(); - } - catch (Exception error) - { - cleanupFailures.Add(error); - } - } - } - - Exception? pumpFailure = null; - try - { - await _pump.WaitAsync(_exitBudget).ConfigureAwait(false); - } - catch (Exception error) - { - pumpFailure = error; - } - - using (var errorPumpBudget = new CancellationTokenSource(_exitBudget)) - { - try - { - await _process.StopErrorPumpAsync(errorPumpBudget.Token).ConfigureAwait(false); - } - catch (Exception error) - { - cleanupFailures.Add(error); - } - } - - try - { - _process.Dispose(); - } - catch (Exception error) - { - cleanupFailures.Add(error); - } - - try - { - if (writeLockHeld) - { - _writeLock.Dispose(); - } - } - catch (Exception error) - { - cleanupFailures.Add(error); - } - - ThrowDisposalFailures(pumpFailure, cleanupFailures); - } - - private async Task StopProcessAsync( - List cleanupFailures, - bool forceStop) - { - bool hasExited; - try - { - hasExited = _process.HasExited; - } - catch (Exception error) - { - cleanupFailures.Add(error); - hasExited = false; - } - - if (hasExited) - { - return; - } - - if (!forceStop) - { - try - { - _process.CloseInput(); - } - catch (InvalidOperationException) when (ProcessHasExited()) - { - return; - } - catch (Exception error) - { - cleanupFailures.Add(error); - } - - using var budget = new CancellationTokenSource(_exitBudget); - try - { - await _process.WaitForExitAsync(budget.Token).ConfigureAwait(false); - return; - } - catch (OperationCanceledException) when (budget.IsCancellationRequested) - { - forceStop = true; - } - catch (InvalidOperationException) when (ProcessHasExited()) - { - return; - } - catch (Exception error) - { - cleanupFailures.Add(error); - forceStop = true; - } - } - - if (!forceStop) - { - return; - } - - // Kills only the client, not its process tree: its server may still be - // serving other clients. - try - { - if (!ProcessHasExited()) - { - _process.Kill(); - } - } - catch (InvalidOperationException) when (ProcessHasExited()) - { - } - catch (Exception error) - { - cleanupFailures.Add(error); - } - - using var forceBudget = new CancellationTokenSource(_exitBudget); - try - { - await _process.WaitForExitAsync(forceBudget.Token).ConfigureAwait(false); - } - catch (OperationCanceledException) when (forceBudget.IsCancellationRequested) - { - cleanupFailures.Add(new TimeoutException( - "The control-mode client did not exit after it was killed.")); - } - catch (InvalidOperationException) when (ProcessHasExited()) - { - } - catch (Exception error) - { - cleanupFailures.Add(error); - } - } - - private bool ProcessHasExited() - { - try - { - return _process.HasExited; - } - catch - { - return false; - } - } - - private static void ThrowDisposalFailures( - Exception? pumpFailure, - List cleanupFailures) - { - if (pumpFailure is not null) - { - if (cleanupFailures.Count == 0) - { - ExceptionDispatchInfo.Capture(pumpFailure).Throw(); - } - - throw new AggregateException([pumpFailure, .. cleanupFailures]); - } - - if (cleanupFailures.Count == 1) - { - ExceptionDispatchInfo.Capture(cleanupFailures[0]).Throw(); - } - - if (cleanupFailures.Count > 1) - { - throw new AggregateException(cleanupFailures); - } - } + private Task DisposeCoreAsync() => new ControlModeDisposer( + _process, + _writeLock, + _pump, + _disposalBudget, + _timeProvider).DisposeAsync(); private void ThrowIfStopping() => ObjectDisposedException.ThrowIf(Volatile.Read(ref _stopRequested) != 0, this); diff --git a/tests/LibTmux.UnitTests/ControlMode/ControlModeDisposalDeadlineTests.cs b/tests/LibTmux.UnitTests/ControlMode/ControlModeDisposalDeadlineTests.cs new file mode 100644 index 0000000..b1afe38 --- /dev/null +++ b/tests/LibTmux.UnitTests/ControlMode/ControlModeDisposalDeadlineTests.cs @@ -0,0 +1,263 @@ +using System.Runtime.Versioning; + +namespace LibTmux.UnitTests.ControlMode; + +[UnsupportedOSPlatform("windows")] +public sealed class ControlModeDisposalDeadlineTests +{ + [Fact] + public async Task Disposal_starts_forced_cleanup_before_one_async_deadline() + { + CancellationToken token = TestContext.Current.CancellationToken; + var clock = new ManualTimerTimeProvider(); + var process = new ConcurrentCleanupProcess(); + var session = new ControlModeSession( + process, + disposalBudget: TimeSpan.FromSeconds(10), + timeProvider: clock); + await session.WaitForReadyAsync(token); + + Task disposal = session.DisposeAsync().AsTask(); + await process.ExitWaitStarted.WaitAsync(TimeSpan.FromSeconds(1), token); + Assert.True(process.CloseInputCalled); + + clock.Advance(TimeSpan.FromSeconds(5)); + await process.KillStarted.WaitAsync(TimeSpan.FromSeconds(1), token); + await process.ErrorPumpStopStarted.WaitAsync(TimeSpan.FromSeconds(1), token); + Assert.Equal(TimeSpan.FromSeconds(5), clock.Elapsed); + Assert.Equal(2, clock.TimersCreated); + Assert.False(process.DisposeCalled); + + clock.Advance(TimeSpan.FromSeconds(5)); + TimeoutException error = await Assert.ThrowsAsync( + () => disposal.WaitAsync(TimeSpan.FromSeconds(2), token)); + + Assert.Equal( + "Control-mode asynchronous cleanup exceeded its disposal deadline.", + error.Message); + Assert.True(process.DisposeCalled); + process.FailPending(new IOException("late cleanup failure")); + } + + [Fact] + public async Task Disposal_preserves_a_cleanup_fault_at_the_deadline() + { + CancellationToken token = TestContext.Current.CancellationToken; + var clock = new ManualTimerTimeProvider(); + var process = new ConcurrentCleanupProcess(); + var session = new ControlModeSession( + process, + disposalBudget: TimeSpan.FromSeconds(10), + timeProvider: clock); + await session.WaitForReadyAsync(token); + + Task disposal = session.DisposeAsync().AsTask(); + await process.ExitWaitStarted.WaitAsync(TimeSpan.FromSeconds(1), token); + clock.Advance(TimeSpan.FromSeconds(5)); + await process.ErrorPumpStopStarted.WaitAsync(TimeSpan.FromSeconds(1), token); + var cleanupFailure = new IOException("error pump failed at the boundary"); + process.FailErrorPump(cleanupFailure); + + clock.Advance(TimeSpan.FromSeconds(5)); + AggregateException error = await Assert.ThrowsAsync( + () => disposal.WaitAsync(TimeSpan.FromSeconds(2), token)); + + Assert.Contains(cleanupFailure, error.InnerExceptions); + Assert.Contains(error.InnerExceptions, failure => failure is TimeoutException); + process.FailExit(new IOException("late exit failure")); + } + + private sealed class ConcurrentCleanupProcess : IControlModeProcess + { + private readonly TaskCompletionSource _errorPumpStop = new( + TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _errorPumpStopStarted = new( + TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _exit = new( + TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _exitWaitStarted = new( + TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _killStarted = new( + TaskCreationOptions.RunContinuationsAsynchronously); + private readonly Queue _output = new( + ["%begin 1 1 0", "%end 1 1 0"]); + private readonly TaskCompletionSource _terminalOutput = new( + TaskCreationOptions.RunContinuationsAsynchronously); + + internal bool CloseInputCalled { get; private set; } + + internal bool DisposeCalled { get; private set; } + + internal Task ErrorPumpStopStarted => _errorPumpStopStarted.Task; + + internal Task ExitWaitStarted => _exitWaitStarted.Task; + + internal Task KillStarted => _killStarted.Task; + + public bool HasExited => false; + + public Task WriteLineAsync( + ReadOnlyMemory command, + CancellationToken cancellationToken) => + throw new InvalidOperationException("The timeout probe does not dispatch commands."); + + public Task FlushAsync(CancellationToken cancellationToken) => + throw new InvalidOperationException("The timeout probe does not dispatch commands."); + + public Task ReadLineAsync() => _output.TryDequeue(out string? line) + ? Task.FromResult(line) + : _terminalOutput.Task; + + public void CloseInput() => CloseInputCalled = true; + + public void Kill() => _killStarted.TrySetResult(); + + public Task WaitForExitAsync(CancellationToken cancellationToken = default) + { + _exitWaitStarted.TrySetResult(); + return _exit.Task; + } + + public Task StopErrorPumpAsync(CancellationToken cancellationToken) + { + _errorPumpStopStarted.TrySetResult(); + return _errorPumpStop.Task; + } + + public void Dispose() + { + DisposeCalled = true; + _terminalOutput.TrySetResult(null); + } + + internal void FailErrorPump(Exception failure) => + _errorPumpStop.TrySetException(failure); + + internal void FailExit(Exception failure) => _exit.TrySetException(failure); + + internal void FailPending(Exception failure) + { + _errorPumpStop.TrySetException(failure); + _exit.TrySetException(failure); + } + } + + private sealed class ManualTimerTimeProvider : TimeProvider + { + private readonly object _gate = new(); + private readonly List _timers = []; + private TimeSpan _elapsed; + + internal TimeSpan Elapsed + { + get + { + lock (_gate) + { + return _elapsed; + } + } + } + + internal int TimersCreated + { + get + { + lock (_gate) + { + return _timers.Count; + } + } + } + + public override ITimer CreateTimer( + TimerCallback callback, + object? state, + TimeSpan dueTime, + TimeSpan period) + { + ArgumentNullException.ThrowIfNull(callback); + lock (_gate) + { + var timer = new ManualTimer(this, callback, state); + _timers.Add(timer); + Change(timer, dueTime, period); + return timer; + } + } + + internal void Advance(TimeSpan duration) + { + ArgumentOutOfRangeException.ThrowIfLessThan(duration, TimeSpan.Zero); + List<(TimerCallback Callback, object? State)> callbacks = []; + lock (_gate) + { + _elapsed += duration; + foreach (ManualTimer timer in _timers) + { + if (timer.Active && timer.DueAt <= _elapsed) + { + timer.Active = timer.Period != Timeout.InfiniteTimeSpan; + if (timer.Active) + { + timer.DueAt += timer.Period; + } + + callbacks.Add((timer.Callback, timer.State)); + } + } + } + + foreach ((TimerCallback callback, object? state) in callbacks) + { + callback(state); + } + } + + private void Change(ManualTimer timer, TimeSpan dueTime, TimeSpan period) + { + timer.Active = dueTime != Timeout.InfiniteTimeSpan; + timer.DueAt = timer.Active ? _elapsed + dueTime : TimeSpan.MaxValue; + timer.Period = period; + } + + private sealed class ManualTimer( + ManualTimerTimeProvider owner, + TimerCallback callback, + object? state) : ITimer + { + internal bool Active { get; set; } + + internal TimerCallback Callback { get; } = callback; + + internal TimeSpan DueAt { get; set; } + + internal TimeSpan Period { get; set; } + + internal object? State { get; } = state; + + public bool Change(TimeSpan dueTime, TimeSpan period) + { + lock (owner._gate) + { + owner.Change(this, dueTime, period); + return true; + } + } + + public void Dispose() + { + lock (owner._gate) + { + Active = false; + } + } + + public ValueTask DisposeAsync() + { + Dispose(); + return ValueTask.CompletedTask; + } + } + } +} diff --git a/tests/LibTmux.UnitTests/ControlMode/ControlModeSessionFailureTests.cs b/tests/LibTmux.UnitTests/ControlMode/ControlModeSessionFailureTests.cs index 1579806..226f159 100644 --- a/tests/LibTmux.UnitTests/ControlMode/ControlModeSessionFailureTests.cs +++ b/tests/LibTmux.UnitTests/ControlMode/ControlModeSessionFailureTests.cs @@ -66,7 +66,7 @@ public async Task Disposal_kills_a_client_whose_write_holds_the_dispatch_lock() var session = new ControlModeSession( process, writeLock, - TimeSpan.FromMilliseconds(25)); + TimeSpan.FromMilliseconds(250)); await session.WaitForReadyAsync(token); Task> send = session.SendAsync( @@ -115,7 +115,7 @@ public async Task A_canceled_write_lock_wait_releases_its_unenqueued_slot() var process = new StalledWriteProcess(); var session = new ControlModeSession( process, - exitBudget: TimeSpan.FromMilliseconds(25), + disposalBudget: TimeSpan.FromMilliseconds(250), limits: new ControlModeLimits(maxPendingCommands: 2)); await session.WaitForReadyAsync(token); From af608007d69c8bf18f76f309e8ea963e11490784 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 05:42:18 -0500 Subject: [PATCH 007/129] ControlMode(fix[framing]): Reject orphaned guards why: Reserved guards outside a block were exposed as notifications, hiding a corrupted control stream. what: - parse every well-formed guard before notifications and fail on malformed reserved names or orphaned terminators - preserve guard-looking command output inside its active block - cover the boundary with fake-process cases and real tmux 3.2a and 3.7c integrations --- src/LibTmux/ControlMode/ControlModeGuard.cs | 9 +++ src/LibTmux/ControlMode/ControlModeSession.cs | 15 +++-- .../ControlMode/ControlModeSessionTests.cs | 20 +++++++ .../ControlModeCorrelationTests.cs | 58 +++++++++++++++++++ 4 files changed, 97 insertions(+), 5 deletions(-) diff --git a/src/LibTmux/ControlMode/ControlModeGuard.cs b/src/LibTmux/ControlMode/ControlModeGuard.cs index 883aa9f..5f59b20 100644 --- a/src/LibTmux/ControlMode/ControlModeGuard.cs +++ b/src/LibTmux/ControlMode/ControlModeGuard.cs @@ -21,6 +21,11 @@ internal bool Matches(ControlModeGuard begin) => && Number == begin.Number && Flags == begin.Flags; + internal static bool HasReservedName(string line) => + HasName(line, "%begin") + || HasName(line, "%end") + || HasName(line, "%error"); + internal static bool TryParse(string line, out ControlModeGuard guard) { string[] fields = line.Split(' '); @@ -58,4 +63,8 @@ internal static bool TryParse(string line, out ControlModeGuard guard) guard = new ControlModeGuard(kind.Value, timestamp, number, flags); return true; } + + private static bool HasName(string line, string name) => + line.StartsWith(name, StringComparison.Ordinal) + && (line.Length == name.Length || char.IsWhiteSpace(line[name.Length])); } diff --git a/src/LibTmux/ControlMode/ControlModeSession.cs b/src/LibTmux/ControlMode/ControlModeSession.cs index 0d69976..3cfbefc 100644 --- a/src/LibTmux/ControlMode/ControlModeSession.cs +++ b/src/LibTmux/ControlMode/ControlModeSession.cs @@ -364,19 +364,24 @@ private async Task PumpAsync() { while (await _process.ReadLineAsync().ConfigureAwait(false) is string line) { - if (line.StartsWith("%begin ", StringComparison.Ordinal)) + if (ControlModeGuard.TryParse(line, out ControlModeGuard guard)) { - if (!ControlModeGuard.TryParse(line, out ControlModeGuard begin) - || begin.Kind != ControlModeGuardKind.Begin) + if (guard.Kind != ControlModeGuardKind.Begin) { throw new InvalidDataException( - "The tmux control client sent a malformed block guard."); + "The tmux control client sent a block guard outside a block."); } - await ReadBlockAsync(begin).ConfigureAwait(false); + await ReadBlockAsync(guard).ConfigureAwait(false); continue; } + if (ControlModeGuard.HasReservedName(line)) + { + throw new InvalidDataException( + "The tmux control client sent a malformed block guard."); + } + if (!line.StartsWith('%')) { // tmux prints nothing outside a block that is not a diff --git a/tests/LibTmux.IntegrationTests/ControlMode/ControlModeSessionTests.cs b/tests/LibTmux.IntegrationTests/ControlMode/ControlModeSessionTests.cs index caed16e..0a49266 100644 --- a/tests/LibTmux.IntegrationTests/ControlMode/ControlModeSessionTests.cs +++ b/tests/LibTmux.IntegrationTests/ControlMode/ControlModeSessionTests.cs @@ -63,6 +63,26 @@ public async Task A_pane_id_inside_a_block_is_data_rather_than_a_notification() await control.SendAsync(TmuxCommand.Create("display-message", "-p", "ok"), token)); } + [UnixFact] + public async Task A_guard_looking_line_inside_a_block_is_data() + { + await using RawTmuxTestContext raw = await RawTmuxTestContext.StartAsync( + TestContext.Current.CancellationToken); + CancellationToken token = TestContext.Current.CancellationToken; + Server server = await ConnectAsync(raw, token); + await using IControlModeSession control = await server.EnterControlModeAsync( + cancellationToken: token); + + IReadOnlyList reported = await control.SendAsync( + TmuxCommand.Create("display-message", "-p", "%%end 9 9 1"), + token); + + Assert.Equal(["%end 9 9 1"], reported); + Assert.Equal( + ["ok"], + await control.SendAsync(TmuxCommand.Create("display-message", "-p", "ok"), token)); + } + [UnixFact] public async Task A_failing_command_faults_only_its_own_caller() { diff --git a/tests/LibTmux.UnitTests/ControlMode/ControlModeCorrelationTests.cs b/tests/LibTmux.UnitTests/ControlMode/ControlModeCorrelationTests.cs index 80d10fd..4d8dff9 100644 --- a/tests/LibTmux.UnitTests/ControlMode/ControlModeCorrelationTests.cs +++ b/tests/LibTmux.UnitTests/ControlMode/ControlModeCorrelationTests.cs @@ -310,6 +310,62 @@ public async Task An_empty_error_block_does_not_invent_a_reported_line() Assert.Empty(error.ErrorLines); } + [Theory] + [InlineData("%begin")] + [InlineData("%begin malformed")] + [InlineData("%end 2 2 0")] + [InlineData("%error 2 2 1")] + public async Task A_reserved_guard_outside_a_block_fails_the_session(string line) + { + CancellationToken token = TestContext.Current.CancellationToken; + var process = new ScriptedProcess(expectedWrites: 0); + var session = new ControlModeSession(process); + await session.WaitForReadyAsync(token); + + process.EmitProtocolLine(line); + + InvalidDataException error = await Assert.ThrowsAsync( + () => session.DisposeAsync().AsTask()); + Assert.Contains("block guard", error.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task Guard_looking_output_inside_a_block_remains_data() + { + CancellationToken token = TestContext.Current.CancellationToken; + const string Sentinel = "libtmux-control-guard-data"; + var process = new ScriptedProcess(expectedWrites: 1); + await using var session = new ControlModeSession( + process, + sentinelFactory: () => Sentinel); + await session.WaitForReadyAsync(token); + + Task> send = session.SendAsync( + TmuxCommand.Create("guard-looking-output"), + token); + await process.WritesObserved.Task.WaitAsync(token); + process.EmitBlock( + number: 10, + flags: 1, + failed: false, + "%begin 9 9 1", + "%begin 2 10 1", + "%end 9 9 1", + "%begin malformed", + "%error 9 9 1"); + process.EmitFence(number: 11, Sentinel); + + Assert.Equal( + [ + "%begin 9 9 1", + "%begin 2 10 1", + "%end 9 9 1", + "%begin malformed", + "%error 9 9 1", + ], + await send); + } + [Fact] public async Task A_stale_command_is_rejected_before_dispatch() { @@ -479,6 +535,8 @@ internal void EmitFence(int number, string sentinel) => internal void EmitNotification(string name) => _output.Writer.TryWrite($"%{name}"); + internal void EmitProtocolLine(string line) => _output.Writer.TryWrite(line); + private void Stop(string exit) { if (Interlocked.Exchange(ref _hasExited, 1) != 0) From 4d575651408faec224d97812b0df8f8f8a4d3b98 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 05:49:48 -0500 Subject: [PATCH 008/129] ControlMode(fix[identity]): Attest the attached server why: A restart between discovery and attach could bind the control client to a replacement daemon, and command aliases could forge a normal generation query. what: - compare pid and start time through an alias-resistant parser condition - report a stale generation without inventing an unknown replacement identity - cover forged restarts on tmux 3.2a and current --- docs/api/README.md | 3 +- docs/public-api.json | 34 ++++++- docs/public-api.md | 3 +- src/LibTmux/Connection/TmuxConnection.cs | 2 +- src/LibTmux/ControlMode/ControlModeSession.cs | 88 +++++++++++++++++-- .../StaleServerGenerationException.cs | 15 +++- src/LibTmux/Internal/TmuxGenerationGuard.cs | 12 ++- src/LibTmux/PublicAPI.Unshipped.txt | 3 +- src/LibTmux/Server.ControlMode.cs | 4 + .../ControlMode/ControlModeSessionTests.cs | 77 +++++++++++++++- .../ControlModeCorrelationTests.cs | 86 ++++++++++++++++++ 11 files changed, 304 insertions(+), 23 deletions(-) diff --git a/docs/api/README.md b/docs/api/README.md index ea44441..7cbf3ee 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -389,6 +389,7 @@ modes differ. | `LibTmux.SetOptionRequest.#ctor(System.String,System.String,System.Nullable{LibTmux.OptionScope},System.Boolean,System.Boolean,System.Boolean,System.Boolean,System.Boolean)` | Initializes a request to set one option. | | `LibTmux.SplitPaneRequest.#ctor(System.String,System.String,System.Boolean,System.Nullable{LibTmux.PaneDirection},System.Boolean,System.Boolean,System.String,System.String,System.Nullable{System.Int32},System.Collections.Generic.IReadOnlyDictionary{System.String,System.String},System.Boolean,System.String,System.String,System.String,System.String,System.Boolean)` | Initializes a pane-split request. | | `LibTmux.StaleServerGenerationException.#ctor(System.String,LibTmux.ServerGeneration,LibTmux.ServerGeneration,System.Exception)` | Initializes a stale-generation exception. | +| `LibTmux.StaleServerGenerationException.#ctor(System.String,LibTmux.ServerGeneration,System.Exception)` | Initializes a stale-generation exception when the replacement is unknown. | | `LibTmux.SwapPaneRequest.#ctor(System.String,System.Nullable{LibTmux.PaneSwapDirection},System.Boolean,System.Boolean)` | Initializes a pane-swap request. | | `LibTmux.Testing.TestEnvironment.#ctor(System.String,System.Collections.Generic.IReadOnlyDictionary{System.String,System.String})` | Initializes a test environment. | | `LibTmux.Testing.TestEnvironment.WithVariable(System.String,System.String)` | Answers a copy that also sets one variable. | @@ -1021,7 +1022,7 @@ modes differ. | `LibTmux.SplitPaneRequest.Style` | Gets the pane style. | | `LibTmux.SplitPaneRequest.Target` | Gets the pane to split, or null for the active one. | | `LibTmux.SplitPaneRequest.Zoom` | Gets whether the new pane is zoomed. | -| `LibTmux.StaleServerGenerationException.Actual` | Gets the generation currently serving the endpoint. | +| `LibTmux.StaleServerGenerationException.Actual` | Gets the generation currently serving the endpoint, or when it could not be observed. | | `LibTmux.StaleServerGenerationException.Expected` | Gets the generation expected by the stale handle. | | `LibTmux.SwapPaneRequest.Detach` | Gets whether the swapped pane is left unselected. | | `LibTmux.SwapPaneRequest.Direction` | Gets the neighbour to swap with instead. | diff --git a/docs/public-api.json b/docs/public-api.json index fcdb4a9..9cc701d 100644 --- a/docs/public-api.json +++ b/docs/public-api.json @@ -12069,6 +12069,34 @@ "portable": true, "summary": "Creates SplitPaneRequest." }, + { + "id": "M:LibTmux.StaleServerGenerationException.#ctor(string,ServerGeneration,Exception?)", + "declaringType": "T:LibTmux.StaleServerGenerationException", + "name": ".ctor", + "kind": "constructor", + "visibility": "public", + "package": "LibTmux", + "static": false, + "returnType": "StaleServerGenerationException", + "parameters": [ + { + "name": "message", + "type": "string" + }, + { + "name": "expected", + "type": "ServerGeneration" + }, + { + "name": "innerException", + "type": "Exception?", + "default": "null" + } + ], + "signature": "StaleServerGenerationException(string message, ServerGeneration expected, Exception? innerException = null)", + "portable": true, + "summary": "Creates StaleServerGenerationException without a known replacement generation." + }, { "id": "M:LibTmux.StaleServerGenerationException.#ctor(string,ServerGeneration,ServerGeneration,Exception?)", "declaringType": "T:LibTmux.StaleServerGenerationException", @@ -21821,11 +21849,11 @@ "visibility": "public", "package": "LibTmux", "static": false, - "returnType": "ServerGeneration", + "returnType": "ServerGeneration?", "parameters": [], - "signature": "ServerGeneration LibTmux.StaleServerGenerationException.Actual { get; }", + "signature": "ServerGeneration? LibTmux.StaleServerGenerationException.Actual { get; }", "portable": true, - "summary": "Gets Actual." + "summary": "Gets Actual, or null when it could not be observed." }, { "id": "P:LibTmux.StaleServerGenerationException.Expected", diff --git a/docs/public-api.md b/docs/public-api.md index 57a4a8e..0009c23 100644 --- a/docs/public-api.md +++ b/docs/public-api.md @@ -1661,8 +1661,9 @@ internal static class Program | Member ID | Declaration | Visibility | Static | Platform | Notes | | --- | --- | --- | --- | --- | --- | +| `M:LibTmux.StaleServerGenerationException.#ctor(string,ServerGeneration,Exception?)` | `StaleServerGenerationException(string message, ServerGeneration expected, Exception? innerException = null)` | Public | No | Portable | Creates StaleServerGenerationException without a known replacement generation. | | `M:LibTmux.StaleServerGenerationException.#ctor(string,ServerGeneration,ServerGeneration,Exception?)` | `StaleServerGenerationException(string message, ServerGeneration expected, ServerGeneration actual, Exception? innerException = null)` | Public | No | Portable | Creates StaleServerGenerationException. | -| `P:LibTmux.StaleServerGenerationException.Actual` | `ServerGeneration LibTmux.StaleServerGenerationException.Actual { get; }` | Public | No | Portable | Gets Actual. | +| `P:LibTmux.StaleServerGenerationException.Actual` | `ServerGeneration? LibTmux.StaleServerGenerationException.Actual { get; }` | Public | No | Portable | Gets Actual, or null when it could not be observed. | | `P:LibTmux.StaleServerGenerationException.Expected` | `ServerGeneration LibTmux.StaleServerGenerationException.Expected { get; }` | Public | No | Portable | Gets Expected. | ### `T:LibTmux.SwapPaneRequest` diff --git a/src/LibTmux/Connection/TmuxConnection.cs b/src/LibTmux/Connection/TmuxConnection.cs index a609d08..00095c9 100644 --- a/src/LibTmux/Connection/TmuxConnection.cs +++ b/src/LibTmux/Connection/TmuxConnection.cs @@ -6,7 +6,7 @@ namespace LibTmux.Internal; internal sealed class TmuxConnection { - private const string GenerationFormat = "#{pid}:#{start_time}"; + internal const string GenerationFormat = "#{pid}:#{start_time}"; private readonly Func> _execute; private readonly Func> _executeVersion; diff --git a/src/LibTmux/ControlMode/ControlModeSession.cs b/src/LibTmux/ControlMode/ControlModeSession.cs index 3cfbefc..cae25f4 100644 --- a/src/LibTmux/ControlMode/ControlModeSession.cs +++ b/src/LibTmux/ControlMode/ControlModeSession.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using System.Globalization; using System.Runtime.ExceptionServices; using System.Runtime.Versioning; using System.Security.Cryptography; @@ -129,12 +130,65 @@ internal static ControlModeSession Start( internal Task WaitForReadyAsync(CancellationToken cancellationToken) => _ready.Task.WaitAsync(cancellationToken); + internal async Task VerifyAttachedGenerationAsync(CancellationToken cancellationToken) + { + ServerGeneration expected = _generation + ?? throw new InvalidOperationException( + "The control client has no expected server generation."); + string mismatchMarker = CreateGenerationMismatchMarker(); + string expectedText = expected.ProcessId.ToString(CultureInfo.InvariantCulture) + + ':' + + expected.StartTime.ToString(CultureInfo.InvariantCulture); + // Full command names can be replaced by command-alias. Parser conditions + // expand formats first, so only a mismatch exposes this random command. + string renderedProbe = + $"%if \"#{{!=:{TmuxConnection.GenerationFormat},{expectedText}}}\" {mismatchMarker} %endif"; + TmuxCommand mismatchCommand = TmuxCommand.Create(mismatchMarker); + + try + { + IReadOnlyList output = await SendRenderedAsync( + mismatchCommand, + renderedProbe, + Encoding.UTF8.GetByteCount(renderedProbe), + cancellationToken) + .ConfigureAwait(false); + if (output.Count != 0) + { + throw new InvalidDataException( + "The generation probe returned unexpected output."); + } + } + catch (ControlModeCommandException error) + when (IsGenerationMismatch(error, mismatchMarker)) + { + throw new StaleServerGenerationException( + "The control client attached to a different tmux server generation.", + expected, + error); + } + } + public Task> SendAsync( TmuxCommand command, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(command); ValidateGeneration(command); + string renderedCommand = ControlModeCommandRenderer.Render(command); + return SendRenderedAsync( + command, + renderedCommand, + ControlModeCommandRenderer.GetRenderedByteCount(command), + cancellationToken); + } + + private Task> SendRenderedAsync( + TmuxCommand command, + string renderedCommand, + long renderedByteCount, + CancellationToken cancellationToken) + { ThrowIfStopping(); if (_process.HasExited) { @@ -148,11 +202,17 @@ public Task> SendAsync( $"The control-mode session reached its {_limits.MaxPendingCommands}-command pending limit."); } - return SendAdmittedAsync(command, cancellationToken); + return SendAdmittedAsync( + command, + renderedCommand, + renderedByteCount, + cancellationToken); } private async Task> SendAdmittedAsync( TmuxCommand command, + string renderedCommand, + long renderedByteCount, CancellationToken cancellationToken) { bool transferredSlot = false; @@ -169,7 +229,7 @@ private async Task> SendAdmittedAsync( "The control-mode request fence is invalid."); } - long requestBytes = ControlModeCommandRenderer.GetRenderedByteCount(command) + long requestBytes = renderedByteCount + Encoding.UTF8.GetByteCount(sentinel) + 2L; if (requestBytes > _limits.MaxRequestBytes) @@ -181,7 +241,7 @@ private async Task> SendAdmittedAsync( var pending = new PendingControlModeCommand(command, sentinel); Task> transaction = DispatchAndWaitAsync( - command, + renderedCommand, pending, cancellationToken); transferredSlot = true; @@ -200,7 +260,7 @@ private async Task> SendAdmittedAsync( } private async Task> DispatchAndWaitAsync( - TmuxCommand command, + string renderedCommand, PendingControlModeCommand pending, CancellationToken cancellationToken) { @@ -234,7 +294,8 @@ private async Task> DispatchAndWaitAsync( try { - await WriteRequestAsync(command, pending.Sentinel).ConfigureAwait(false); + await WriteRequestAsync(renderedCommand, pending.Sentinel) + .ConfigureAwait(false); } catch (Exception error) { @@ -278,9 +339,9 @@ private async Task> DispatchAndWaitAsync( } } - private async Task WriteRequestAsync(TmuxCommand command, string sentinel) + private async Task WriteRequestAsync(string renderedCommand, string sentinel) { - string framedCommand = $"{ControlModeCommandRenderer.Render(command)}\n{sentinel}"; + string framedCommand = $"{renderedCommand}\n{sentinel}"; await _process.WriteLineAsync(framedCommand.AsMemory(), CancellationToken.None) .ConfigureAwait(false); await _process.FlushAsync(CancellationToken.None).ConfigureAwait(false); @@ -334,6 +395,19 @@ private void ValidateGeneration(TmuxCommand command) private static string CreateSentinel() => $"libtmux-control-{Convert.ToHexString(RandomNumberGenerator.GetBytes(32))}"; + private static string CreateGenerationMismatchMarker() => + $"libtmux-generation-{Convert.ToHexString(RandomNumberGenerator.GetBytes(32))}"; + + private static bool IsGenerationMismatch( + ControlModeCommandException error, + string mismatchMarker) => + error.OutputLines.Count == 0 + && error.ErrorLines.Count == 1 + && string.Equals( + error.ErrorLines[0], + $"parse error: unknown command: {mismatchMarker}", + StringComparison.Ordinal); + public async ValueTask DisposeAsync() { Task disposal; diff --git a/src/LibTmux/Exceptions/StaleServerGenerationException.cs b/src/LibTmux/Exceptions/StaleServerGenerationException.cs index 4016686..a6b280a 100644 --- a/src/LibTmux/Exceptions/StaleServerGenerationException.cs +++ b/src/LibTmux/Exceptions/StaleServerGenerationException.cs @@ -3,6 +3,14 @@ namespace LibTmux; /// Reports a stale server generation. public sealed class StaleServerGenerationException : InvalidOperationException { + /// Initializes a stale-generation exception when the replacement is unknown. + public StaleServerGenerationException( + string message, + ServerGeneration expected, + Exception? innerException = null) + : base(message, innerException) + => Expected = expected; + /// Initializes a stale-generation exception. public StaleServerGenerationException( string message, @@ -18,6 +26,9 @@ public StaleServerGenerationException( /// Gets the generation expected by the stale handle. public ServerGeneration Expected { get; } - /// Gets the generation currently serving the endpoint. - public ServerGeneration Actual { get; } + /// + /// Gets the generation currently serving the endpoint, or + /// when it could not be observed. + /// + public ServerGeneration? Actual { get; } } diff --git a/src/LibTmux/Internal/TmuxGenerationGuard.cs b/src/LibTmux/Internal/TmuxGenerationGuard.cs index 1d72170..3fe0a54 100644 --- a/src/LibTmux/Internal/TmuxGenerationGuard.cs +++ b/src/LibTmux/Internal/TmuxGenerationGuard.cs @@ -8,8 +8,6 @@ internal sealed class TmuxGenerationGuard( Func> execute, Func markerFactory) { - private const string GenerationFormat = "#{pid}:#{start_time}"; - internal async Task ExecuteAsync( ServerGeneration expected, IReadOnlyList> commands, @@ -23,8 +21,14 @@ internal async Task ExecuteAsync( + expected.StartTime.ToString(CultureInfo.InvariantCulture); IReadOnlyList[] guarded = [ - ["display-message", "-p", GenerationFormat], - ["if-shell", "-F", $"#{{==:{GenerationFormat},{generationText}}}", string.Empty, marker], + ["display-message", "-p", TmuxConnection.GenerationFormat], + [ + "if-shell", + "-F", + $"#{{==:{TmuxConnection.GenerationFormat},{generationText}}}", + string.Empty, + marker, + ], .. commands, ]; diff --git a/src/LibTmux/PublicAPI.Unshipped.txt b/src/LibTmux/PublicAPI.Unshipped.txt index 2f1f454..cfec304 100644 --- a/src/LibTmux/PublicAPI.Unshipped.txt +++ b/src/LibTmux/PublicAPI.Unshipped.txt @@ -955,8 +955,9 @@ LibTmux.SplitPaneRequest.Style.get -> string? LibTmux.SplitPaneRequest.Target.get -> string? LibTmux.SplitPaneRequest.Zoom.get -> bool LibTmux.StaleServerGenerationException -LibTmux.StaleServerGenerationException.Actual.get -> LibTmux.ServerGeneration +LibTmux.StaleServerGenerationException.Actual.get -> LibTmux.ServerGeneration? LibTmux.StaleServerGenerationException.Expected.get -> LibTmux.ServerGeneration +LibTmux.StaleServerGenerationException.StaleServerGenerationException(string! message, LibTmux.ServerGeneration expected, System.Exception? innerException = null) -> void LibTmux.StaleServerGenerationException.StaleServerGenerationException(string! message, LibTmux.ServerGeneration expected, LibTmux.ServerGeneration actual, System.Exception? innerException = null) -> void LibTmux.SwapPaneRequest LibTmux.SwapPaneRequest.$() -> LibTmux.SwapPaneRequest! diff --git a/src/LibTmux/Server.ControlMode.cs b/src/LibTmux/Server.ControlMode.cs index 485882f..4377795 100644 --- a/src/LibTmux/Server.ControlMode.cs +++ b/src/LibTmux/Server.ControlMode.cs @@ -21,6 +21,9 @@ public sealed partial class Server /// to see the rest. /// /// The handle has no connection. + /// + /// The endpoint changed servers while the control client was attaching. + /// [UnsupportedOSPlatform("windows")] public async Task EnterControlModeAsync( string? target = null, @@ -54,6 +57,7 @@ public async Task EnterControlModeAsync( try { await session.WaitForReadyAsync(cancellationToken).ConfigureAwait(false); + await session.VerifyAttachedGenerationAsync(cancellationToken).ConfigureAwait(false); return session; } catch (Exception startupFailure) diff --git a/tests/LibTmux.IntegrationTests/ControlMode/ControlModeSessionTests.cs b/tests/LibTmux.IntegrationTests/ControlMode/ControlModeSessionTests.cs index 0a49266..8b8a25f 100644 --- a/tests/LibTmux.IntegrationTests/ControlMode/ControlModeSessionTests.cs +++ b/tests/LibTmux.IntegrationTests/ControlMode/ControlModeSessionTests.cs @@ -282,6 +282,79 @@ public async Task The_event_stream_ends_with_an_exit() await control.DisposeAsync(); } + [UnixFact] + public async Task Startup_rejects_a_server_restart_between_discovery_and_attach() + { + CancellationToken token = TestContext.Current.CancellationToken; + await using RawTmuxTestContext raw = await RawTmuxTestContext.StartAsync(token); + Server original = await ConnectAsync(raw, token); + ServerGeneration expected = original.Generation + ?? throw new InvalidOperationException("The test server was not materialized."); + string directory = Path.Combine( + Path.GetTempPath(), + $"libtmux-control-generation-{Guid.NewGuid():N}"); + string wrapper = Path.Combine(directory, "tmux-wrapper"); + string forgedGenerationAlias = + $"display-message=display-message -p {expected.ProcessId}:{expected.StartTime} ; send-keys -l --"; + Directory.CreateDirectory(directory); + Task? startup = null; + + try + { + string script = $$""" + #!/bin/sh + set -eu + generation_probe=0 + for argument in "$@"; do + if [ "$argument" = '#{pid}:#{start_time}' ]; then + generation_probe=1 + fi + done + if [ "$generation_probe" = 1 ]; then + {{ShellQuote(raw.TmuxBinaryPath)}} "$@" + {{ShellQuote(raw.TmuxBinaryPath)}} \ + -S {{ShellQuote(raw.SocketPath)}} \ + kill-server + {{ShellQuote(raw.TmuxBinaryPath)}} \ + -S {{ShellQuote(raw.SocketPath)}} \ + -f /dev/null \ + new-session -d -s successor + {{ShellQuote(raw.TmuxBinaryPath)}} \ + -S {{ShellQuote(raw.SocketPath)}} \ + set-option -s 'command-alias[200]' \ + {{ShellQuote(forgedGenerationAlias)}} + exit 0 + fi + exec {{ShellQuote(raw.TmuxBinaryPath)}} "$@" + """; + await File.WriteAllTextAsync(wrapper, script, token); + File.SetUnixFileMode( + wrapper, + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + await WaitUntilAsync(() => CanExecute(wrapper), token); + + Server server = Server.Open(new ServerConnectionOptions( + tmuxBinaryPath: wrapper, + socketPath: raw.SocketPath, + configurationFile: "/dev/null")); + startup = server.EnterControlModeAsync(cancellationToken: token); + + StaleServerGenerationException error = + await Assert.ThrowsAsync(async () => await startup); + Assert.Equal(expected, error.Expected); + Assert.Null(error.Actual); + } + finally + { + if (startup?.IsCompletedSuccessfully == true) + { + await startup.Result.DisposeAsync(); + } + + Directory.Delete(directory, recursive: true); + } + } + [UnixFact] public async Task A_canceled_attach_is_disposed_before_the_call_returns() { @@ -380,9 +453,7 @@ public async Task Startup_drains_standard_error_before_waiting_for_attach() for argument in "$@"; do if [ "$argument" = "-C" ]; then dd if=/dev/zero bs=65536 count=4 1>&2 2>/dev/null - printf '%%begin 1 1 0\n%%end 1 1 0\n' - while IFS= read -r ignored; do :; done - exit 0 + exec {ShellQuote(raw.TmuxBinaryPath)} "$@" fi done exec {ShellQuote(raw.TmuxBinaryPath)} "$@" diff --git a/tests/LibTmux.UnitTests/ControlMode/ControlModeCorrelationTests.cs b/tests/LibTmux.UnitTests/ControlMode/ControlModeCorrelationTests.cs index 4d8dff9..76bd3e3 100644 --- a/tests/LibTmux.UnitTests/ControlMode/ControlModeCorrelationTests.cs +++ b/tests/LibTmux.UnitTests/ControlMode/ControlModeCorrelationTests.cs @@ -388,6 +388,92 @@ await Assert.ThrowsAsync( Assert.Empty(process.Writes); } + [Fact] + public async Task Attached_generation_probe_uses_an_alias_resistant_parser_condition() + { + CancellationToken token = TestContext.Current.CancellationToken; + var expected = new ServerGeneration(processId: 10, startTime: 20); + const string Fence = "libtmux-control-generation-match"; + var process = new ScriptedProcess(expectedWrites: 1); + await using var session = new ControlModeSession( + process, + generation: expected, + sentinelFactory: () => Fence); + await session.WaitForReadyAsync(token); + + Task probe = session.VerifyAttachedGenerationAsync(token); + await process.WritesObserved.Task.WaitAsync(token); + + string[] requestLines = Assert.Single(process.Writes).Split('\n'); + Assert.Equal(2, requestLines.Length); + Assert.StartsWith( + "%if \"#{!=:#{pid}:#{start_time},10:20}\" libtmux-generation-", + requestLines[0], + StringComparison.Ordinal); + Assert.EndsWith(" %endif", requestLines[0], StringComparison.Ordinal); + Assert.Equal(Fence, requestLines[1]); + + process.EmitFence(number: 10, Fence); + await probe; + } + + [Fact] + public async Task Generation_mismatch_does_not_invent_the_attached_identity() + { + CancellationToken token = TestContext.Current.CancellationToken; + var expected = new ServerGeneration(processId: 10, startTime: 20); + const string Fence = "libtmux-control-generation-mismatch"; + var process = new ScriptedProcess(expectedWrites: 1); + await using var session = new ControlModeSession( + process, + generation: expected, + sentinelFactory: () => Fence); + await session.WaitForReadyAsync(token); + + Task probe = session.VerifyAttachedGenerationAsync(token); + await process.WritesObserved.Task.WaitAsync(token); + string condition = Assert.Single(process.Writes).Split('\n')[0]; + string mismatchMarker = condition.Split(' ')[2]; + process.EmitBlock( + number: 10, + flags: 1, + failed: true, + $"parse error: unknown command: {mismatchMarker}"); + process.EmitFence(number: 11, Fence); + + StaleServerGenerationException error = + await Assert.ThrowsAsync(async () => await probe); + Assert.Equal(expected, error.Expected); + Assert.Null(error.Actual); + ControlModeCommandException cause = + Assert.IsType(error.InnerException); + Assert.Equal(mismatchMarker, cause.Command.Name); + Assert.Empty(cause.Command.Arguments); + } + + [Fact] + public async Task Generation_probe_rejects_unexpected_command_output() + { + CancellationToken token = TestContext.Current.CancellationToken; + var expected = new ServerGeneration(processId: 10, startTime: 20); + const string Fence = "libtmux-control-generation-output"; + var process = new ScriptedProcess(expectedWrites: 1); + await using var session = new ControlModeSession( + process, + generation: expected, + sentinelFactory: () => Fence); + await session.WaitForReadyAsync(token); + + Task probe = session.VerifyAttachedGenerationAsync(token); + await process.WritesObserved.Task.WaitAsync(token); + process.EmitBlock(number: 10, flags: 1, failed: false, "unexpected"); + process.EmitFence(number: 11, Fence); + + InvalidDataException error = + await Assert.ThrowsAsync(async () => await probe); + Assert.Equal("The generation probe returned unexpected output.", error.Message); + } + [Fact] public void Typed_arguments_render_without_a_second_physical_line() { From e78b720c8c107ef2c9031b629e8df106f5fea6dd Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 06:02:37 -0500 Subject: [PATCH 009/129] Versioning(fix[capabilities]): Support stable releases why: Exact profiles silently disabled valid behavior on stable tmux releases that were not listed verbatim. what: - Replace cumulative exact profiles with named support intervals - Keep prerelease, development, next, and below-minimum states unknown - Migrate production gates and real-server proofs to strict lookups --- src/LibTmux/Pane.Operations.cs | 20 +- src/LibTmux/Pane.Options.cs | 3 +- src/LibTmux/Server.Clients.cs | 3 +- src/LibTmux/Server.Options.cs | 3 +- src/LibTmux/Server.Utilities.cs | 3 +- src/LibTmux/Session.Lifecycle.cs | 3 +- src/LibTmux/Session.Options.cs | 3 +- src/LibTmux/Versioning/TmuxCapabilities.cs | 177 +++++++++--------- src/LibTmux/Versioning/TmuxVersion.cs | 6 + src/LibTmux/Window.Options.cs | 3 +- src/LibTmux/Window.Topology.cs | 3 +- .../Chaining/TmuxChainTests.cs | 42 ++--- .../Clients/ClientAdministrationTests.cs | 5 +- .../Hierarchy/PaneOperationsTests.cs | 13 +- .../Hierarchy/ServerSessionLifecycleTests.cs | 3 +- .../Hierarchy/WindowTopologyTests.cs | 5 +- .../Hooks/HookOperationsTests.cs | 10 +- .../Parity/Component11ParityTests.cs | 5 +- .../Parity/Component12ParityTests.cs | 3 +- .../Parity/Component16ParityTests.cs | 7 +- .../Utilities/ServerUtilitiesTests.cs | 2 +- .../Versioning/VersionParityTests.cs | 67 ++++--- .../Versioning/TmuxCapabilitiesTests.cs | 155 +++++++-------- 23 files changed, 263 insertions(+), 281 deletions(-) diff --git a/src/LibTmux/Pane.Operations.cs b/src/LibTmux/Pane.Operations.cs index ecd0a1d..0cf7140 100644 --- a/src/LibTmux/Pane.Operations.cs +++ b/src/LibTmux/Pane.Operations.cs @@ -604,8 +604,7 @@ public async Task BreakAsync( // tmux 3.7 dereferences a null window name here and takes the whole // server with it, so that one version always gets a name: the caller's // if there is one, otherwise a placeholder that is renamed away after. - bool needsPlaceholder = Supports(owner, out TmuxCapabilityProfile? profile) - && profile.RequiresBreakPane37Workaround; + bool needsPlaceholder = Supports(owner, "break_pane_3_7_workaround"); List arguments = ["break-pane", "-P", "-F", "#{window_id}"]; if (detach) { @@ -1242,21 +1241,8 @@ private static void AddEnvironment( } private static bool Supports(Server owner, string capability) => - Supports(owner, out TmuxCapabilityProfile? profile) - && profile.Capabilities.Contains(capability); - - private static bool Supports(Server owner, out TmuxCapabilityProfile profile) - { - if (owner.Version is TmuxVersion version - && TmuxCapabilities.TryGetExact(version, out TmuxCapabilityProfile? found)) - { - profile = found; - return true; - } - - profile = null!; - return false; - } + owner.Version is TmuxVersion version + && TmuxCapabilities.IsSupported(version, capability); private static string? SortOrder(ChooseTreeSort? sort) => sort switch { diff --git a/src/LibTmux/Pane.Options.cs b/src/LibTmux/Pane.Options.cs index 821cedc..f5635b8 100644 --- a/src/LibTmux/Pane.Options.cs +++ b/src/LibTmux/Pane.Options.cs @@ -18,6 +18,5 @@ public sealed partial class Pane private static bool DoubleEscapesDollar(Server? owner) => owner?.Version is TmuxVersion version - && TmuxCapabilities.TryGetExact(version, out TmuxCapabilityProfile? profile) - && profile.Capabilities.Contains("option_dollar_double_escape"); + && TmuxCapabilities.IsSupported(version, "option_dollar_double_escape"); } diff --git a/src/LibTmux/Server.Clients.cs b/src/LibTmux/Server.Clients.cs index f1b7e32..01e62fd 100644 --- a/src/LibTmux/Server.Clients.cs +++ b/src/LibTmux/Server.Clients.cs @@ -141,8 +141,7 @@ private static void AddTargetClient(List arguments, string? targetClient private bool SupportsClipboardQuery() { if (Version is TmuxVersion version - && TmuxCapabilities.TryGetExact(version, out TmuxCapabilityProfile? profile) - && profile.Capabilities.Contains(ClipboardQueryCapability)) + && TmuxCapabilities.IsSupported(version, ClipboardQueryCapability)) { return true; } diff --git a/src/LibTmux/Server.Options.cs b/src/LibTmux/Server.Options.cs index 75bc071..a2bedac 100644 --- a/src/LibTmux/Server.Options.cs +++ b/src/LibTmux/Server.Options.cs @@ -22,6 +22,5 @@ public sealed partial class Server private static bool DoubleEscapesDollar(Server? owner) => owner?.Version is TmuxVersion version - && TmuxCapabilities.TryGetExact(version, out TmuxCapabilityProfile? profile) - && profile.Capabilities.Contains("option_dollar_double_escape"); + && TmuxCapabilities.IsSupported(version, "option_dollar_double_escape"); } diff --git a/src/LibTmux/Server.Utilities.cs b/src/LibTmux/Server.Utilities.cs index e967244..56dc004 100644 --- a/src/LibTmux/Server.Utilities.cs +++ b/src/LibTmux/Server.Utilities.cs @@ -810,8 +810,7 @@ public async Task> GetBufferLinesAsync( private bool Supports(string capability) => Version is TmuxVersion version - && TmuxCapabilities.TryGetExact(version, out TmuxCapabilityProfile? profile) - && profile.Capabilities.Contains(capability); + && TmuxCapabilities.IsSupported(version, capability); private bool SupportsMenuStyles() => RequiresCapability(ServerUtilities.DisplayMenuStylesCapability, LogMenuStyles); diff --git a/src/LibTmux/Session.Lifecycle.cs b/src/LibTmux/Session.Lifecycle.cs index 0859f16..4e559a2 100644 --- a/src/LibTmux/Session.Lifecycle.cs +++ b/src/LibTmux/Session.Lifecycle.cs @@ -444,8 +444,7 @@ private bool SupportsGroupKill() { Server owner = RequireOwner("group"); if (owner.Version is TmuxVersion version - && TmuxCapabilities.TryGetExact(version, out TmuxCapabilityProfile? profile) - && profile.Capabilities.Contains(GroupKillCapability)) + && TmuxCapabilities.IsSupported(version, GroupKillCapability)) { return true; } diff --git a/src/LibTmux/Session.Options.cs b/src/LibTmux/Session.Options.cs index 2dbbb52..307358f 100644 --- a/src/LibTmux/Session.Options.cs +++ b/src/LibTmux/Session.Options.cs @@ -18,6 +18,5 @@ public sealed partial class Session private static bool DoubleEscapesDollar(Server? owner) => owner?.Version is TmuxVersion version - && TmuxCapabilities.TryGetExact(version, out TmuxCapabilityProfile? profile) - && profile.Capabilities.Contains("option_dollar_double_escape"); + && TmuxCapabilities.IsSupported(version, "option_dollar_double_escape"); } diff --git a/src/LibTmux/Versioning/TmuxCapabilities.cs b/src/LibTmux/Versioning/TmuxCapabilities.cs index 4bc997e..e43ee00 100644 --- a/src/LibTmux/Versioning/TmuxCapabilities.cs +++ b/src/LibTmux/Versioning/TmuxCapabilities.cs @@ -1,32 +1,12 @@ using System.Collections.Frozen; -using System.Diagnostics.CodeAnalysis; namespace LibTmux.Internal; -internal sealed record TmuxCapabilityProfile +internal enum TmuxCapabilityState { - internal TmuxCapabilityProfile( - TmuxVersion version, - IReadOnlySet capabilities) - { - if (!version.IsValid) - { - throw new ArgumentException( - "A capability profile requires a valid tmux version.", - nameof(version)); - } - - ArgumentNullException.ThrowIfNull(capabilities); - Version = version; - Capabilities = capabilities.ToFrozenSet(StringComparer.Ordinal); - } - - internal TmuxVersion Version { get; } - - internal IReadOnlySet Capabilities { get; } - - internal bool RequiresBreakPane37Workaround => - Capabilities.Contains("break_pane_3_7_workaround"); + Unknown, + Unsupported, + Supported, } internal static class TmuxCapabilities @@ -35,7 +15,6 @@ internal static class TmuxCapabilities [ "attachment_accounting", "byte_length_framing", - "choose_tree_sort_time", "control_notifications", "format_fields_and_operators", "semicolon_grouping", @@ -55,7 +34,6 @@ internal static class TmuxCapabilities private static readonly string[] Added34 = [ "capture_pane_trim_trailing", - "option_dollar_double_escape", "clear_history_hyperlinks", "confirm_before_acceptance", "display_menu_styles", @@ -89,79 +67,100 @@ internal static class TmuxCapabilities "split_window_appearance", "split_window_empty", ]; - private static readonly FrozenDictionary Profiles = - CreateProfiles(); + private static readonly FrozenDictionary Intervals = + CreateIntervals(); - internal static bool TryGetExact( + internal static TmuxCapabilityState GetState( TmuxVersion version, - [NotNullWhen(true)] out TmuxCapabilityProfile? profile) + string capability) { - if (!version.IsValid) + ArgumentException.ThrowIfNullOrWhiteSpace(capability); + if (!Intervals.TryGetValue(capability, out CapabilityInterval interval)) { - profile = null; - return false; + throw new KeyNotFoundException($"Unknown tmux capability '{capability}'."); } - return Profiles.TryGetValue(version, out profile); + if (!version.IsStableRelease || version < LibTmuxInfo.MinimumTmuxVersion) + { + return TmuxCapabilityState.Unknown; + } + + return interval.Contains(version) + ? TmuxCapabilityState.Supported + : TmuxCapabilityState.Unsupported; } - internal static TmuxCapabilityProfile GetRequired(TmuxVersion version) => - TryGetExact(version, out TmuxCapabilityProfile? profile) - ? profile - : throw new NotSupportedException( - version.IsValid - ? $"tmux {version} has no approved capability profile." - : "An invalid tmux version has no approved capability profile."); + internal static bool IsSupported(TmuxVersion version, string capability) => + GetState(version, capability) is TmuxCapabilityState.Supported; + + private static FrozenDictionary CreateIntervals() + { + TmuxVersion minimum = LibTmuxInfo.MinimumTmuxVersion; + TmuxVersion version33 = TmuxVersion.Parse("3.3"); + TmuxVersion version34 = TmuxVersion.Parse("3.4"); + TmuxVersion version35 = TmuxVersion.Parse("3.5"); + TmuxVersion version36 = TmuxVersion.Parse("3.6"); + TmuxVersion version37 = TmuxVersion.Parse("3.7"); + TmuxVersion version37a = TmuxVersion.Parse("3.7a"); + var intervals = new Dictionary(StringComparer.Ordinal); + + Add(intervals, Baseline, minimum); + Add(intervals, ["choose_tree_sort_time"], minimum, version37); + Add(intervals, Added33, version33); + Add(intervals, Added34, version34); + Add(intervals, ["option_dollar_double_escape"], version34, version35); + Add(intervals, Added35, version35); + Add(intervals, Added36, version36); + Add(intervals, Added37, version37); + Add(intervals, ["break_pane_3_7_workaround"], version37, version37a); + + return intervals.ToFrozenDictionary(StringComparer.Ordinal); + } - private static FrozenDictionary CreateProfiles() + private static void Add( + IDictionary intervals, + IEnumerable capabilities, + TmuxVersion supportedFrom, + TmuxVersion? unsupportedFrom = null) { - FrozenSet capabilities32 = Freeze(Baseline); - FrozenSet capabilities33 = Freeze(capabilities32, Added33); - FrozenSet capabilities34 = Freeze(capabilities33, Added34); - // tmux 3.4 alone escapes a dollar sign twice when it shows an option - // back, so the quirk arrives at 3.4 and is gone again at 3.5. - FrozenSet capabilities35 = Without( - Freeze(capabilities34, Added35), - "option_dollar_double_escape"); - FrozenSet capabilities36 = Freeze(capabilities35, Added36); - // tmux 3.7 dropped the activity-time sort order and rejects it by name. - FrozenSet capabilities37a = Without( - Freeze(capabilities36, Added37), - "choose_tree_sort_time"); - FrozenSet capabilities37 = Freeze( - capabilities37a, - ["break_pane_3_7_workaround"]); - - TmuxCapabilityProfile[] profiles = - [ - Create("3.2a", capabilities32), - Create("3.3a", capabilities33), - Create("3.4", capabilities34), - Create("3.5", capabilities35), - Create("3.6", capabilities36), - Create("3.7", capabilities37), - Create("3.7a", capabilities37a), - Create("3.7b", capabilities37a), - ]; - return profiles.ToFrozenDictionary(static profile => profile.Version); + var interval = new CapabilityInterval(supportedFrom, unsupportedFrom); + foreach (string capability in capabilities) + { + intervals.Add(capability, interval); + } } - private static TmuxCapabilityProfile Create( - string rawVersion, - IReadOnlySet capabilities) => - new(TmuxVersion.Parse(rawVersion), capabilities); - - private static FrozenSet Freeze( - IEnumerable existing, - IEnumerable? additions = null) => - additions is null - ? existing.ToFrozenSet(StringComparer.Ordinal) - : existing.Concat(additions).ToFrozenSet(StringComparer.Ordinal); - - // Capability sets are additive by default; Without exists so a version - // that drops a flag can still say so explicitly. - private static FrozenSet Without( - IEnumerable existing, - params string[] removals) => - existing.Except(removals, StringComparer.Ordinal).ToFrozenSet(StringComparer.Ordinal); + private readonly struct CapabilityInterval + { + internal CapabilityInterval( + TmuxVersion supportedFrom, + TmuxVersion? unsupportedFrom) + { + if (!supportedFrom.IsStableRelease) + { + throw new ArgumentException( + "A capability interval requires a stable starting version.", + nameof(supportedFrom)); + } + + if (unsupportedFrom is TmuxVersion end + && (!end.IsStableRelease || end <= supportedFrom)) + { + throw new ArgumentException( + "A capability interval must end at a later stable version.", + nameof(unsupportedFrom)); + } + + SupportedFrom = supportedFrom; + UnsupportedFrom = unsupportedFrom; + } + + private TmuxVersion SupportedFrom { get; } + + private TmuxVersion? UnsupportedFrom { get; } + + internal bool Contains(TmuxVersion version) => + version >= SupportedFrom + && (UnsupportedFrom is not TmuxVersion end || version < end); + } } diff --git a/src/LibTmux/Versioning/TmuxVersion.cs b/src/LibTmux/Versioning/TmuxVersion.cs index 3375e31..b89399e 100644 --- a/src/LibTmux/Versioning/TmuxVersion.cs +++ b/src/LibTmux/Versioning/TmuxVersion.cs @@ -43,6 +43,12 @@ public TmuxVersion(string raw) /// Gets whether this value contains a parsed tmux version. public bool IsValid { get; } + internal bool IsStableRelease => + IsValid + && _kind is VersionKind.Release + or VersionKind.MicroRelease + or VersionKind.PatchRelease; + /// Gets the parsed major version. public int Major { get; } diff --git a/src/LibTmux/Window.Options.cs b/src/LibTmux/Window.Options.cs index b4f2c65..7758c85 100644 --- a/src/LibTmux/Window.Options.cs +++ b/src/LibTmux/Window.Options.cs @@ -23,6 +23,5 @@ public sealed partial class Window private static bool DoubleEscapesDollar(Server? owner) => owner?.Version is TmuxVersion version - && TmuxCapabilities.TryGetExact(version, out TmuxCapabilityProfile? profile) - && profile.Capabilities.Contains("option_dollar_double_escape"); + && TmuxCapabilities.IsSupported(version, "option_dollar_double_escape"); } diff --git a/src/LibTmux/Window.Topology.cs b/src/LibTmux/Window.Topology.cs index bf999f3..2228c1e 100644 --- a/src/LibTmux/Window.Topology.cs +++ b/src/LibTmux/Window.Topology.cs @@ -911,8 +911,7 @@ private static void AddEnvironment( private static bool Supports(Server owner, string capability) => owner.Version is TmuxVersion version - && TmuxCapabilities.TryGetExact(version, out TmuxCapabilityProfile? profile) - && profile.Capabilities.Contains(capability); + && TmuxCapabilities.IsSupported(version, capability); [LoggerMessage( EventId = 2, diff --git a/tests/LibTmux.IntegrationTests/Chaining/TmuxChainTests.cs b/tests/LibTmux.IntegrationTests/Chaining/TmuxChainTests.cs index 24cc077..129bc14 100644 --- a/tests/LibTmux.IntegrationTests/Chaining/TmuxChainTests.cs +++ b/tests/LibTmux.IntegrationTests/Chaining/TmuxChainTests.cs @@ -591,9 +591,9 @@ [new TmuxMenuItem("Build", "b", "display-message built")], Assert.Contains("Build", command.Arguments); Assert.Contains("chained", command.Arguments); - bool carriesMouse = TmuxCapabilities - .GetRequired(server.Version!.Value) - .Capabilities.Contains("display_menu_mouse"); + bool carriesMouse = TmuxCapabilities.IsSupported( + server.Version!.Value, + "display_menu_mouse"); Assert.Equal(carriesMouse, command.Arguments.Contains("-M")); } @@ -619,9 +619,9 @@ public async Task A_popup_chains_and_drops_the_options_this_tmux_lacks() Assert.Equal("display-popup", command.Name); Assert.Contains("40", command.Arguments); - bool carriesOptions = TmuxCapabilities - .GetRequired(server.Version!.Value) - .Capabilities.Contains("display_popup_3_3_options"); + bool carriesOptions = TmuxCapabilities.IsSupported( + server.Version!.Value, + "display_popup_3_3_options"); // The close mode maps to a flag every supported tmux carries; what // 3.3 added is the titling and styling, so that is what tracks the @@ -737,9 +737,9 @@ public async Task A_confirmation_chains_and_drops_the_keys_this_tmux_lacks() Assert.Equal("confirm-before", command.Name); Assert.Contains("sure?", command.Arguments); - bool carriesKeys = TmuxCapabilities - .GetRequired(server.Version!.Value) - .Capabilities.Contains("confirm_before_acceptance"); + bool carriesKeys = TmuxCapabilities.IsSupported( + server.Version!.Value, + "confirm_before_acceptance"); Assert.Equal(carriesKeys, command.Arguments.Contains("-c")); } @@ -752,9 +752,9 @@ public async Task A_prompt_chains_and_is_still_refused_below_its_floor() CancellationToken token = TestContext.Current.CancellationToken; Server server = await ConnectAsync(raw, token); - bool carriesTypes = TmuxCapabilities - .GetRequired(server.Version!.Value) - .Capabilities.Contains("command_prompt_background"); + bool carriesTypes = TmuxCapabilities.IsSupported( + server.Version!.Value, + "command_prompt_background"); CommandPromptRequest typed = new("display-message %%", type: PromptType.Command); @@ -854,9 +854,9 @@ public async Task A_chooser_chains_and_keeps_the_dropped_sort_order_out() Assert.Equal("choose-tree", command.Name); - bool carriesTime = TmuxCapabilities - .GetRequired(server.Version!.Value) - .Capabilities.Contains("choose_tree_sort_time"); + bool carriesTime = TmuxCapabilities.IsSupported( + server.Version!.Value, + "choose_tree_sort_time"); Assert.Equal(carriesTime, command.Arguments.Contains("time")); @@ -882,9 +882,9 @@ public async Task Buffer_listing_and_access_chain() Assert.Contains("ltlist", listed.StandardOutputLines); - bool carriesAccess = TmuxCapabilities - .GetRequired(server.Version!.Value) - .Capabilities.Contains("server_access_command"); + bool carriesAccess = TmuxCapabilities.IsSupported( + server.Version!.Value, + "server_access_command"); ServerAccessRequest access = new(list: true); @@ -997,9 +997,9 @@ public async Task Floating_panes_and_attachment_chain() Assert.Equal("attach-session", attach.Name); Assert.Contains("-d", attach.Arguments); - bool carriesFloats = TmuxCapabilities - .GetRequired(server.Version!.Value) - .Capabilities.Contains("new_pane_command"); + bool carriesFloats = TmuxCapabilities.IsSupported( + server.Version!.Value, + "new_pane_command"); NewPaneRequest floating = new(width: 20, height: 5); diff --git a/tests/LibTmux.IntegrationTests/Clients/ClientAdministrationTests.cs b/tests/LibTmux.IntegrationTests/Clients/ClientAdministrationTests.cs index 9327d0c..971cfe2 100644 --- a/tests/LibTmux.IntegrationTests/Clients/ClientAdministrationTests.cs +++ b/tests/LibTmux.IntegrationTests/Clients/ClientAdministrationTests.cs @@ -149,8 +149,9 @@ public async Task RefreshClientClipboardQueryVersionPolicy() token); Client client = await WaitForClientAsync(server, token); - bool supported = TmuxCapabilities.GetRequired(server.Version!.Value) - .Capabilities.Contains("refresh_client_clipboard_query"); + bool supported = TmuxCapabilities.IsSupported( + server.Version!.Value, + "refresh_client_clipboard_query"); await server.RefreshClientAsync(client.Name, requestClipboard: true, cancellationToken: token); diff --git a/tests/LibTmux.IntegrationTests/Hierarchy/PaneOperationsTests.cs b/tests/LibTmux.IntegrationTests/Hierarchy/PaneOperationsTests.cs index dd50517..e556957 100644 --- a/tests/LibTmux.IntegrationTests/Hierarchy/PaneOperationsTests.cs +++ b/tests/LibTmux.IntegrationTests/Hierarchy/PaneOperationsTests.cs @@ -431,8 +431,9 @@ public async Task NewPaneCommandVersionPolicy() RecordingLogger logger = new(); Server server = await ConnectAsync(raw, token, logger); Pane pane = await FirstPaneAsync(server, token); - bool supported = TmuxCapabilities.GetRequired(server.Version!.Value) - .Capabilities.Contains("new_pane_command"); + bool supported = TmuxCapabilities.IsSupported( + server.Version!.Value, + "new_pane_command"); if (supported) { @@ -464,8 +465,9 @@ public async Task BreakPane37WorkaroundVersionPolicy() // tmux 3.7 alone dereferences a null window name here and crashes the // whole server, so that version always gets a placeholder name. - bool workaround = TmuxCapabilities.GetRequired(server.Version!.Value) - .RequiresBreakPane37Workaround; + bool workaround = TmuxCapabilities.IsSupported( + server.Version!.Value, + "break_pane_3_7_workaround"); Pane named = await pane.SplitAsync(cancellationToken: token); Assert.Equal("wanted", (await named.BreakAsync("wanted", cancellationToken: token)).Name); @@ -490,8 +492,7 @@ private static async Task GatedAsync( RecordingLogger logger = new(); Server server = await ConnectAsync(raw, token, logger); Pane pane = await FirstPaneAsync(server, token); - bool supported = TmuxCapabilities.GetRequired(server.Version!.Value) - .Capabilities.Contains(capability); + bool supported = TmuxCapabilities.IsSupported(server.Version!.Value, capability); await operation(pane, token); diff --git a/tests/LibTmux.IntegrationTests/Hierarchy/ServerSessionLifecycleTests.cs b/tests/LibTmux.IntegrationTests/Hierarchy/ServerSessionLifecycleTests.cs index 67e2eee..847e0a4 100644 --- a/tests/LibTmux.IntegrationTests/Hierarchy/ServerSessionLifecycleTests.cs +++ b/tests/LibTmux.IntegrationTests/Hierarchy/ServerSessionLifecycleTests.cs @@ -267,8 +267,7 @@ await RequireRawSuccessAsync( await RequireRawSuccessAsync(raw, ["new-session", "-d", "-s", "solo"], token); TmuxVersion version = Assert.NotNull(server.Version); - bool supported = TmuxCapabilities.GetRequired(version) - .Capabilities.Contains("kill_session_group"); + bool supported = TmuxCapabilities.IsSupported(version, "kill_session_group"); Session grouped = (await server.GetSessionsAsync(token)) .Single(session => session.Name == "grouped"); diff --git a/tests/LibTmux.IntegrationTests/Hierarchy/WindowTopologyTests.cs b/tests/LibTmux.IntegrationTests/Hierarchy/WindowTopologyTests.cs index 015420b..79d5b9a 100644 --- a/tests/LibTmux.IntegrationTests/Hierarchy/WindowTopologyTests.cs +++ b/tests/LibTmux.IntegrationTests/Hierarchy/WindowTopologyTests.cs @@ -215,8 +215,9 @@ public async Task DisplayMessageLiteralVersionPolicy() Session session = await TestHierarchy.RequireFirstSessionAsync(server, token); Window window = await TestHierarchy.RequireFirstWindowAsync(session, token); - bool supported = TmuxCapabilities.GetRequired(server.Version!.Value) - .Capabilities.Contains("display_message_literal"); + bool supported = TmuxCapabilities.IsSupported( + server.Version!.Value, + "display_message_literal"); IReadOnlyList? literal = await window.DisplayMessageAsync( new DisplayMessageRequest("#{window_id}", returnText: true, noExpand: true), diff --git a/tests/LibTmux.IntegrationTests/Hooks/HookOperationsTests.cs b/tests/LibTmux.IntegrationTests/Hooks/HookOperationsTests.cs index 9030e55..5a70335 100644 --- a/tests/LibTmux.IntegrationTests/Hooks/HookOperationsTests.cs +++ b/tests/LibTmux.IntegrationTests/Hooks/HookOperationsTests.cs @@ -144,8 +144,9 @@ public async Task HookScopePaneWindowSetVersionPolicy() // Every supported tmux carries the window and pane hook scopes, so // there is no older spelling to fall back to and nothing to warn about. Assert.True( - TmuxCapabilities.GetRequired(server.Version!.Value) - .Capabilities.Contains("hook_scope_pane_window_set")); + TmuxCapabilities.IsSupported( + server.Version!.Value, + "hook_scope_pane_window_set")); Assert.Equal("-w", CommandFlagCatalog.GetHookScopeFlag(OptionScope.Window)); Assert.Equal("-p", CommandFlagCatalog.GetHookScopeFlag(OptionScope.Pane)); @@ -188,8 +189,9 @@ public async Task HookScopePaneWindowShowVersionPolicy() Pane pane = await TestHierarchy.RequireFirstPaneAsync(window, token); Assert.True( - TmuxCapabilities.GetRequired(server.Version!.Value) - .Capabilities.Contains("hook_scope_pane_window_show")); + TmuxCapabilities.IsSupported( + server.Version!.Value, + "hook_scope_pane_window_show")); // Reading a scope that holds nothing is an empty answer, not a failure, // on every supported version. diff --git a/tests/LibTmux.IntegrationTests/Parity/Component11ParityTests.cs b/tests/LibTmux.IntegrationTests/Parity/Component11ParityTests.cs index bbfa7ba..74721f6 100644 --- a/tests/LibTmux.IntegrationTests/Parity/Component11ParityTests.cs +++ b/tests/LibTmux.IntegrationTests/Parity/Component11ParityTests.cs @@ -290,8 +290,9 @@ private static async Task ProvesCreatePaneAsync( Window window, CancellationToken token) { - bool supported = TmuxCapabilities.GetRequired(server.Version!.Value) - .Capabilities.Contains("new_pane_command"); + bool supported = TmuxCapabilities.IsSupported( + server.Version!.Value, + "new_pane_command"); if (!supported) { // The command does not exist before 3.7, so there is nothing to diff --git a/tests/LibTmux.IntegrationTests/Parity/Component12ParityTests.cs b/tests/LibTmux.IntegrationTests/Parity/Component12ParityTests.cs index 55cb7d3..9e1d0e0 100644 --- a/tests/LibTmux.IntegrationTests/Parity/Component12ParityTests.cs +++ b/tests/LibTmux.IntegrationTests/Parity/Component12ParityTests.cs @@ -206,8 +206,7 @@ private static async Task ProvesCreatePaneAsync( Pane pane, CancellationToken token) { - if (!TmuxCapabilities.GetRequired(server.Version!.Value) - .Capabilities.Contains("new_pane_command")) + if (!TmuxCapabilities.IsSupported(server.Version!.Value, "new_pane_command")) { // The command does not exist before 3.7, so a typed refusal is the // whole behaviour on those lanes. diff --git a/tests/LibTmux.IntegrationTests/Parity/Component16ParityTests.cs b/tests/LibTmux.IntegrationTests/Parity/Component16ParityTests.cs index beb630f..e1e1f5d 100644 --- a/tests/LibTmux.IntegrationTests/Parity/Component16ParityTests.cs +++ b/tests/LibTmux.IntegrationTests/Parity/Component16ParityTests.cs @@ -133,7 +133,7 @@ private static async Task ProvesPromptHistoryAsync( string capability = clearing ? ServerUtilities.ClearPromptHistoryCapability : ServerUtilities.ShowPromptHistoryCapability; - if (!TmuxCapabilities.GetRequired(server.Version!.Value).Capabilities.Contains(capability)) + if (!TmuxCapabilities.IsSupported(server.Version!.Value, capability)) { // The command does not exist yet, so nothing is sent. await Assert.ThrowsAsync( @@ -268,8 +268,9 @@ private static async Task ProvesWaitForAsync(Server server, CancellationTo private static async Task ProvesServerAccessAsync(Server server, CancellationToken token) { - if (!TmuxCapabilities.GetRequired(server.Version!.Value) - .Capabilities.Contains(ServerUtilities.ServerAccessCapability)) + if (!TmuxCapabilities.IsSupported( + server.Version!.Value, + ServerUtilities.ServerAccessCapability)) { await Assert.ThrowsAsync( () => server.ConfigureAccessAsync(new ServerAccessRequest(list: true), token)); diff --git a/tests/LibTmux.IntegrationTests/Utilities/ServerUtilitiesTests.cs b/tests/LibTmux.IntegrationTests/Utilities/ServerUtilitiesTests.cs index 5599662..0e86766 100644 --- a/tests/LibTmux.IntegrationTests/Utilities/ServerUtilitiesTests.cs +++ b/tests/LibTmux.IntegrationTests/Utilities/ServerUtilitiesTests.cs @@ -519,7 +519,7 @@ private static async Task ReadMessagesAsync( } private static bool Supports(Server server, string capability) => - TmuxCapabilities.GetRequired(server.Version!.Value).Capabilities.Contains(capability); + TmuxCapabilities.IsSupported(server.Version!.Value, capability); private static async Task ProvesWholeCommandGateAsync( string capability, diff --git a/tests/LibTmux.IntegrationTests/Versioning/VersionParityTests.cs b/tests/LibTmux.IntegrationTests/Versioning/VersionParityTests.cs index 90352b1..caa1601 100644 --- a/tests/LibTmux.IntegrationTests/Versioning/VersionParityTests.cs +++ b/tests/LibTmux.IntegrationTests/Versioning/VersionParityTests.cs @@ -64,8 +64,8 @@ public sealed class VersionParityTests public async Task AttachmentAccounting() { await using RawTmuxTestContext context = await StartAsync(); - TmuxCapabilityProfile profile = await GetProfileAsync(context); - Assert.Contains("attachment_accounting", profile.Capabilities); + TmuxVersion version = await GetVersionAsync(context); + Assert.True(TmuxCapabilities.IsSupported(version, "attachment_accounting")); RawTmuxResult before = await ExecuteAsync( context, @@ -101,8 +101,8 @@ await WriteProtocolTranscriptAsync( public async Task ByteLengthFraming() { await using RawTmuxTestContext context = await StartAsync(); - TmuxCapabilityProfile profile = await GetProfileAsync(context); - Assert.Contains("byte_length_framing", profile.Capabilities); + TmuxVersion version = await GetVersionAsync(context); + Assert.True(TmuxCapabilities.IsSupported(version, "byte_length_framing")); RawTmuxResult result = await ExecuteAsync( context, @@ -116,8 +116,8 @@ public async Task ByteLengthFraming() public async Task ControlNotifications() { await using RawTmuxTestContext context = await StartAsync(); - TmuxCapabilityProfile profile = await GetProfileAsync(context); - Assert.Contains("control_notifications", profile.Capabilities); + TmuxVersion version = await GetVersionAsync(context); + Assert.True(TmuxCapabilities.IsSupported(version, "control_notifications")); await using ControlModeClientScope client = await ControlModeClientScope.StartAsync( context, TestContext.Current.CancellationToken); @@ -161,8 +161,8 @@ await WriteProtocolTranscriptAsync( public async Task FormatFieldsAndOperators() { await using RawTmuxTestContext context = await StartAsync(); - TmuxCapabilityProfile profile = await GetProfileAsync(context); - Assert.Contains("format_fields_and_operators", profile.Capabilities); + TmuxVersion version = await GetVersionAsync(context); + Assert.True(TmuxCapabilities.IsSupported(version, "format_fields_and_operators")); RawTmuxResult result = await ExecuteAsync( context, @@ -180,8 +180,8 @@ public async Task FormatFieldsAndOperators() public async Task OptionDollarDoubleEscape() { await using RawTmuxTestContext context = await StartAsync(); - TmuxCapabilityProfile profile = await GetProfileAsync(context); - bool doubled = profile.Capabilities.Contains("option_dollar_double_escape"); + TmuxVersion version = await GetVersionAsync(context); + bool doubled = TmuxCapabilities.IsSupported(version, "option_dollar_double_escape"); RawTmuxResult stored = await ExecuteAsync( context, @@ -200,8 +200,8 @@ public async Task OptionDollarDoubleEscape() public async Task SemicolonGrouping() { await using RawTmuxTestContext context = await StartAsync(); - TmuxCapabilityProfile profile = await GetProfileAsync(context); - Assert.Contains("semicolon_grouping", profile.Capabilities); + TmuxVersion version = await GetVersionAsync(context); + Assert.True(TmuxCapabilities.IsSupported(version, "semicolon_grouping")); RawTmuxResult grouped = await ExecuteAsync( context, @@ -219,10 +219,13 @@ public async Task SemicolonGrouping() public async Task BreakPane37Workaround() { await using RawTmuxTestContext context = await StartAsync(); - TmuxCapabilityProfile profile = await GetProfileAsync(context); + TmuxVersion version = await GetVersionAsync(context); + bool workaround = TmuxCapabilities.IsSupported( + version, + "break_pane_3_7_workaround"); string sourcePane = await SplitPaneAsync(context, []); List arguments = ["break-pane", "-d", "-P", "-F", "#{window_name}"]; - if (profile.RequiresBreakPane37Workaround) + if (workaround) { arguments.AddRange(["-n", "libtmux-transition"]); } @@ -234,7 +237,7 @@ public async Task BreakPane37Workaround() Assert.False(string.IsNullOrWhiteSpace(result.StandardOutputLines[0])); Assert.NotEqual("libtmux-transition", result.StandardOutputLines[0]); - await WriteTransitionRecordAsync(profile); + await WriteTransitionRecordAsync(version, workaround); } [UnixFact] @@ -347,10 +350,10 @@ public async Task CommandFlags() Assert.False(SyntaxSupportsFlag("example [-ab] [-t=client]", "-c")); Assert.True(SyntaxSupportsFlag("command-prompt [-1CbeFiklN]", "-F")); await using RawTmuxTestContext context = await StartAsync(); - TmuxCapabilityProfile profile = await GetProfileAsync(context); + TmuxVersion version = await GetVersionAsync(context); foreach (Gate gate in Gates) { - await AssertCommandSurfaceAsync(context, profile, gate); + await AssertCommandSurfaceAsync(context, version, gate); } } @@ -362,9 +365,9 @@ private static async Task ExerciseGateAsync(string capability) capability, StringComparison.Ordinal)); await using RawTmuxTestContext context = await StartAsync(); - TmuxCapabilityProfile profile = await GetProfileAsync(context); - await AssertCommandSurfaceAsync(context, profile, gate); - if (profile.Capabilities.Contains(capability)) + TmuxVersion version = await GetVersionAsync(context); + await AssertCommandSurfaceAsync(context, version, gate); + if (TmuxCapabilities.IsSupported(version, capability)) { await ExerciseSupportedBehaviorAsync(context, capability); } @@ -372,7 +375,7 @@ private static async Task ExerciseGateAsync(string capability) private static async Task AssertCommandSurfaceAsync( RawTmuxTestContext context, - TmuxCapabilityProfile profile, + TmuxVersion version, Gate gate) { RawTmuxResult syntax = await ExecuteAsync( @@ -419,7 +422,7 @@ private static async Task AssertCommandSurfaceAsync( Assert.True( HasSingleNonemptyLine(syntax) && SyntaxSupportsFlag(syntax.StandardOutputLines[0], "-l"), - $"tmux {profile.Version} must expose the historical refresh-client -l surface."); + $"tmux {version} must expose the historical refresh-client -l surface."); RawTmuxResult getClipboard = await ExecuteAsync( context, ["show-options", "-s", "-v", "get-clipboard"]); @@ -438,10 +441,10 @@ private static async Task AssertCommandSurfaceAsync( gate.Capability, "break_pane_3_7_workaround", StringComparison.Ordinal) - || profile.Capabilities.Contains(gate.Capability); + || TmuxCapabilities.IsSupported(version, gate.Capability); Assert.True( expected == isPresent, - $"tmux {profile.Version} capability '{gate.Capability}' expected surface " + $"tmux {version} capability '{gate.Capability}' expected surface " + $"presence {expected}, observed {isPresent}: {syntax.StandardOutputText}"); } @@ -1642,7 +1645,7 @@ private static async Task ExecuteAsync( private static Task StartAsync() => RawTmuxTestContext.StartAsync(TestContext.Current.CancellationToken); - private static async Task GetProfileAsync( + private static async Task GetVersionAsync( RawTmuxTestContext context) { TmuxVersion version = await TmuxVersion.DetectAsync( @@ -1658,7 +1661,7 @@ private static async Task GetProfileAsync( context, ["display-message", "-p", "#{version}"]); Assert.Equal([version.Raw], serverVersion.StandardOutputLines); - return TmuxCapabilities.GetRequired(version); + return version; } private static async Task ShowOptionAsync( @@ -1758,7 +1761,9 @@ private static bool SyntaxSupportsFlag(string syntax, string flag) private static string TargetPane(RawTmuxTestContext context) => $"{context.SessionName}:0.0"; - private static async Task WriteTransitionRecordAsync(TmuxCapabilityProfile profile) + private static async Task WriteTransitionRecordAsync( + TmuxVersion version, + bool workaround) { if (!string.Equals( Environment.GetEnvironmentVariable("LIBTMUX_BREAK_PANE_TRANSITION_PROOF"), @@ -1768,20 +1773,20 @@ private static async Task WriteTransitionRecordAsync(TmuxCapabilityProfile profi return; } - Assert.Contains(profile.Version.Raw, TransitionVersions); + Assert.Contains(version.Raw, TransitionVersions); string framework = RequiredEnvironment("LIBTMUX_TEST_FRAMEWORK"); Assert.Contains(framework, TransitionFrameworks); string sourceCommit = RequiredEnvironment("LIBTMUX_TMUX_SOURCE_COMMIT"); Assert.Matches("^[0-9a-f]{40}$", sourceCommit); string transcriptDirectory = RequiredEnvironment("LIBTMUX_PROTOCOL_TRANSCRIPT_DIR"); - string workaround = profile.RequiresBreakPane37Workaround ? "applied" : "omitted"; + string workaroundState = workaround ? "applied" : "omitted"; string record = string.Join( ' ', "event=break-pane-transition", $"framework={framework}", $"tmux-source-commit={sourceCommit}", - $"tmux-version={profile.Version.Raw}", - $"workaround={workaround}", + $"tmux-version={version.Raw}", + $"workaround={workaroundState}", "outcome=passed"); Directory.CreateDirectory(transcriptDirectory); await File.AppendAllTextAsync( diff --git a/tests/LibTmux.UnitTests/Versioning/TmuxCapabilitiesTests.cs b/tests/LibTmux.UnitTests/Versioning/TmuxCapabilitiesTests.cs index 47788d3..2ebacb1 100644 --- a/tests/LibTmux.UnitTests/Versioning/TmuxCapabilitiesTests.cs +++ b/tests/LibTmux.UnitTests/Versioning/TmuxCapabilitiesTests.cs @@ -362,99 +362,88 @@ public void Format_descriptor_validates_and_copies_ordinal_scopes() new HashSet())); } - [Fact] - public void Capability_profiles_are_exact_and_never_floor_selected() + [Theory] + [InlineData("3.2a", "attachment_accounting", "Supported")] + [InlineData("3.2a", "display_message_client", "Unsupported")] + [InlineData("3.3", "display_message_client", "Supported")] + [InlineData("3.3a", "capture_pane_trim_trailing", "Unsupported")] + [InlineData("3.4", "capture_pane_trim_trailing", "Supported")] + [InlineData("3.4", "display_menu_mouse", "Unsupported")] + [InlineData("3.5", "display_menu_mouse", "Supported")] + [InlineData("3.5", "capture_pane_mode_screen", "Unsupported")] + [InlineData("3.6", "capture_pane_mode_screen", "Supported")] + [InlineData("3.6", "new_pane_command", "Unsupported")] + [InlineData("3.7", "new_pane_command", "Supported")] + public void Capability_cohorts_start_at_their_recorded_version( + string rawVersion, + string capability, + string expected) => + Assert.Equal( + Enum.Parse(expected), + TmuxCapabilities.GetState(TmuxVersion.Parse(rawVersion), capability)); + + [Theory] + [InlineData("3.3", "display_message_client")] + [InlineData("3.3.7", "display_message_client")] + [InlineData("3.7c", "new_pane_command")] + [InlineData("4.0", "new_pane_command")] + public void Capability_intervals_cover_unlisted_stable_releases( + string rawVersion, + string capability) { - string[] approved = ["3.2a", "3.3a", "3.4", "3.5", "3.6", "3.7", "3.7a", "3.7b"]; - foreach (string raw in approved) - { - TmuxVersion version = TmuxVersion.Parse(raw); - Assert.True(TmuxCapabilities.TryGetExact(version, out TmuxCapabilityProfile? profile)); - Assert.NotNull(profile); - Assert.Equal(version, profile.Version); - Assert.Same(profile, TmuxCapabilities.GetRequired(version)); - } + TmuxVersion version = TmuxVersion.Parse(rawVersion); - Assert.False(TmuxCapabilities.TryGetExact(TmuxVersion.Parse("3.3"), out _)); - Assert.False(TmuxCapabilities.TryGetExact(TmuxVersion.Parse("3.3.7"), out _)); - Assert.False(TmuxCapabilities.TryGetExact(TmuxVersion.Parse("next-3.8"), out _)); - Assert.False(TmuxCapabilities.TryGetExact(default, out _)); - Assert.Throws( - () => TmuxCapabilities.GetRequired(TmuxVersion.Parse("3.3"))); - Assert.Throws(() => TmuxCapabilities.GetRequired(default)); + Assert.Equal( + TmuxCapabilityState.Supported, + TmuxCapabilities.GetState(version, capability)); + Assert.True(TmuxCapabilities.IsSupported(version, capability)); } - [Fact] - public void Capability_profiles_gate_the_exact_37_workaround() - { - Assert.Equal(8, TmuxCapabilities.GetRequired(TmuxVersion.Parse("3.2a")).Capabilities.Count); - Assert.Equal(15, TmuxCapabilities.GetRequired(TmuxVersion.Parse("3.3a")).Capabilities.Count); - Assert.Equal(23, TmuxCapabilities.GetRequired(TmuxVersion.Parse("3.4")).Capabilities.Count); - Assert.Equal(24, TmuxCapabilities.GetRequired(TmuxVersion.Parse("3.5")).Capabilities.Count); - Assert.Equal(29, TmuxCapabilities.GetRequired(TmuxVersion.Parse("3.6")).Capabilities.Count); - Assert.Equal(39, TmuxCapabilities.GetRequired(TmuxVersion.Parse("3.7")).Capabilities.Count); - Assert.Equal(38, TmuxCapabilities.GetRequired(TmuxVersion.Parse("3.7a")).Capabilities.Count); - Assert.Equal(38, TmuxCapabilities.GetRequired(TmuxVersion.Parse("3.7b")).Capabilities.Count); - - // 3.7 gains ten and drops one, so counting alone would read the drop - // as a smaller gain and never notice it. - foreach (string carries in new[] { "3.2a", "3.3a", "3.4", "3.5", "3.6" }) - { - Assert.Contains( - "choose_tree_sort_time", - TmuxCapabilities.GetRequired(TmuxVersion.Parse(carries)).Capabilities); - } + [Theory] + [InlineData("3.3a", "option_dollar_double_escape", "Unsupported")] + [InlineData("3.4", "option_dollar_double_escape", "Supported")] + [InlineData("3.4.1", "option_dollar_double_escape", "Supported")] + [InlineData("3.5", "option_dollar_double_escape", "Unsupported")] + [InlineData("3.6a", "choose_tree_sort_time", "Supported")] + [InlineData("3.7", "choose_tree_sort_time", "Unsupported")] + [InlineData("3.7", "break_pane_3_7_workaround", "Supported")] + [InlineData("3.7a", "break_pane_3_7_workaround", "Unsupported")] + [InlineData("3.7c", "break_pane_3_7_workaround", "Unsupported")] + public void Capability_intervals_name_both_ends( + string rawVersion, + string capability, + string expected) => + Assert.Equal( + Enum.Parse(expected), + TmuxCapabilities.GetState(TmuxVersion.Parse(rawVersion), capability)); - // The dollar-escape quirk arrives at 3.4 and is gone at 3.5, so it is - // the one capability that both appears and disappears mid-range. - Assert.Contains( - "option_dollar_double_escape", - TmuxCapabilities.GetRequired(TmuxVersion.Parse("3.4")).Capabilities); - Assert.DoesNotContain( - "option_dollar_double_escape", - TmuxCapabilities.GetRequired(TmuxVersion.Parse("3.3a")).Capabilities); - Assert.DoesNotContain( - "option_dollar_double_escape", - TmuxCapabilities.GetRequired(TmuxVersion.Parse("3.5")).Capabilities); - - // tmux 3.2a's usage text advertises display-message's target-client - // flag but refuses it at runtime, so the boundary is named, not counted. - Assert.DoesNotContain( - "display_message_client", - TmuxCapabilities.GetRequired(TmuxVersion.Parse("3.2a")).Capabilities); - Assert.Contains( - "display_message_client", - TmuxCapabilities.GetRequired(TmuxVersion.Parse("3.3a")).Capabilities); - - foreach (string dropped in new[] { "3.7", "3.7a", "3.7b" }) - { - Assert.DoesNotContain( - "choose_tree_sort_time", - TmuxCapabilities.GetRequired(TmuxVersion.Parse(dropped)).Capabilities); - } + [Theory] + [InlineData(null)] + [InlineData("3.2")] + [InlineData("3.7-dev")] + [InlineData("3.7-rc1")] + [InlineData("next-3.8")] + public void Capability_intervals_preserve_unknown_versions(string? rawVersion) + { + TmuxVersion version = rawVersion is null ? default : TmuxVersion.Parse(rawVersion); - Assert.True(TmuxCapabilities.GetRequired( - TmuxVersion.Parse("3.7")).RequiresBreakPane37Workaround); - Assert.False(TmuxCapabilities.GetRequired( - TmuxVersion.Parse("3.7a")).RequiresBreakPane37Workaround); - Assert.False(TmuxCapabilities.GetRequired( - TmuxVersion.Parse("3.7b")).RequiresBreakPane37Workaround); + Assert.Equal( + TmuxCapabilityState.Unknown, + TmuxCapabilities.GetState(version, "new_pane_command")); + Assert.False(TmuxCapabilities.IsSupported(version, "new_pane_command")); } [Fact] - public void Capability_profiles_copy_and_freeze_their_capability_sets() + public void Capability_names_are_strict() { - var source = new HashSet(StringComparer.OrdinalIgnoreCase) { "feature" }; - var profile = new TmuxCapabilityProfile(TmuxVersion.Parse("3.7"), source); - - source.Add("later"); - - Assert.True(profile.Capabilities.Contains("feature")); - Assert.False(profile.Capabilities.Contains("FEATURE")); - Assert.False(profile.Capabilities.Contains("later")); - Assert.Throws(() => new TmuxCapabilityProfile(default, source)); - Assert.Throws( - () => new TmuxCapabilityProfile(TmuxVersion.Parse("3.7"), null!)); + Assert.Throws(() => + TmuxCapabilities.GetState(TmuxVersion.Parse("3.7c"), "display_mesage_client")); + Assert.Throws(() => + TmuxCapabilities.GetState(TmuxVersion.Parse("3.7c"), "DISPLAY_MESSAGE_CLIENT")); + Assert.Throws(() => + TmuxCapabilities.GetState(TmuxVersion.Parse("3.7c"), null!)); + Assert.Throws(() => + TmuxCapabilities.GetState(TmuxVersion.Parse("3.7c"), " ")); } [Fact] From 56a7b17fdaf24e8aca14e783f81c2fd196ca4950 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 06:10:19 -0500 Subject: [PATCH 010/129] Versioning(test[capabilities]): Bind intervals to evidence why: The compatibility contract and validator still described closed exact profiles after stable interval selection replaced them. what: - Document stable tmux 3.2a-and-newer support and unknown build kinds - Record the interval-model decision and the separate psmux boundary - Verify every capability interval against its real-server delta ledger --- README.md | 6 +- docs/README.md | 6 +- docs/decisions/0004-public-api-approval.md | 3 + .../0005-stable-capability-intervals.md | 69 +++++++++ docs/modes/matrix.md | 4 +- docs/psmux.md | 4 +- docs/public-api.json | 6 +- docs/public-api.md | 9 +- eng/parity/render_public_api.py | 9 +- eng/parity/tests/test_capabilities.py | 28 +++- eng/parity/tests/test_public_api.py | 28 +++- eng/parity/verify_capabilities.py | 141 +++++++++++++++++- eng/parity/verify_public_api.py | 17 ++- 13 files changed, 291 insertions(+), 39 deletions(-) create mode 100644 docs/decisions/0005-stable-capability-intervals.md diff --git a/README.md b/README.md index 27b35b7..d684cfd 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Drive [tmux](https://github.com/tmux/tmux) from .NET. Servers, sessions, windows, panes, clients, options, hooks and buffers, typed and asynchronous, -against every tmux from **3.2a to 3.7b** on **net8.0** and **net10.0**. +for stable tmux **3.2a and newer** on **net8.0** and **net10.0**. > **Alpha.** Releases carry an `-alpha` prerelease tag. The API is not > settled, and any release may change or remove exported identifiers without a @@ -227,7 +227,7 @@ TmuxVersion? version = server.Version; Console.WriteLine($"tmux {version?.Raw} 3.4-or-newer={version?.IsAtLeast(TmuxVersion.Parse("3.4"))}"); ``` -Every difference between 3.2a and 3.7b is [recorded with the test that proves +Every measured difference between 3.2a and 3.7b is [recorded with the test that proves it](docs/parity/version-deltas.json), and [dotnet-tmux.yml](.github/workflows/dotnet-tmux.yml) builds all seven from source on every commit. @@ -296,7 +296,7 @@ never reaches the model's list. | | | |---|---| -| tmux | 3.2a, 3.3a, 3.4, 3.5, 3.6, 3.7a, 3.7b | +| tmux | Stable 3.2a and newer. CI builds 3.2a, 3.3a, 3.4, 3.5, 3.6, 3.7a, and 3.7b; development, release-candidate, and `next-*` versions have unknown capability state | | .NET | net8.0, net10.0 | | OS | Linux, macOS. The bounded [`Psmux*` native-Windows and WSL query preview](docs/psmux.md) is experimental; its release gate runs both paths on net8.0 and net10.0 | | Trimming / NativeAOT | `LibTmux` core is analyzer-gated and its smoke app is published and run for `linux-x64` on net8.0 and net10.0. That proof does not cover the other packages, macOS, or native Windows/psmux | diff --git a/docs/README.md b/docs/README.md index 17918be..af9cc30 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,8 +1,8 @@ # LibTmux -> **Alpha.** Ordinary tmux behavior is gated against tmux 3.2a through 3.7b; -> the API shape is not settled. The [psmux preview](psmux.md) is experimental -> and query-only. +> **Alpha.** Stable tmux 3.2a and newer are supported; the required matrix +> builds 3.2a through 3.7b. The API shape is not settled. The +> [psmux preview](psmux.md) is experimental and query-only. A .NET class library for tmux. The three ordinary execution modes reach a real tmux server, and the mode is visible where the call starts. The psmux preview diff --git a/docs/decisions/0004-public-api-approval.md b/docs/decisions/0004-public-api-approval.md index 20323a1..9e84cd6 100644 --- a/docs/decisions/0004-public-api-approval.md +++ b/docs/decisions/0004-public-api-approval.md @@ -4,6 +4,9 @@ Accepted as the production implementation contract. +ADR 0005 supersedes this decision's exact capability-profile selection rule +and closed stable-version support boundary. + This decision approves names, signatures, ownership, package placement, and parity destinations. It does not claim that production code or behavioral evidence exists. Every parity row remains `implementationStatus=not_started` diff --git a/docs/decisions/0005-stable-capability-intervals.md b/docs/decisions/0005-stable-capability-intervals.md new file mode 100644 index 0000000..e3ce792 --- /dev/null +++ b/docs/decisions/0005-stable-capability-intervals.md @@ -0,0 +1,69 @@ +# ADR 0005: Stable capability intervals + +## Status + +Accepted for tmux version-dependent behavior. + +This decision supersedes ADR 0004's exact capability-profile selection rule. +It does not change exact `TmuxVersion` identity or the required compatibility +matrix. + +## Context + +Minimum support is tmux 3.2a. `MaximumTestedTmuxVersion` is informational, not +a support ceiling. The earlier implementation nevertheless selected only eight +exact capability snapshots. Stable releases such as 3.3, 3.3 micro releases, +and 3.7c therefore appeared to carry no capabilities. Callers omitted valid +flags or rejected valid operations. + +The cumulative snapshots also made a profile's version serve two meanings: the +server version for exact matches and an older provenance marker for any proposed +floor lookup. Whole-set copies hid the individual version where a behavior was +removed. A misspelled capability name read as ordinary absence. + +## Decision + +Represent each named capability by the first supported stable version and an +optional first unsupported stable version. Stable final, micro, and patch +releases at or above 3.2a are evaluated against those intervals. A capability +without a recorded end remains supported on later stable releases; this does +not infer capabilities that are absent from the ledger. + +Invalid values, versions below 3.2a, development builds, release candidates, +and `next-*` builds have unknown capability state. Ordinary internal callers +treat unknown as unsupported. Unknown capability names throw, so spelling drift +does not silently disable behavior. + +The psmux preview remains separate. Its exact binary attestation, typed facade, +and command allowlist define that surface; its numeric compatibility banner +does not widen it through the tmux capability model. + +## Alternatives + +Exact snapshots were rejected because they contradict the open-ended minimum +support contract. + +Selecting the nearest older snapshot was smaller, but retained cumulative +copies, ambiguous profile identity, implicit removal handling, and silent name +misses. + +## Evidence + +Both contenders ran sequentially with five-CPU process affinity. Each passed a +zero-warning Release build and both unit target frameworks. Deliberately moving +the 3.3 boundary to 3.3a made the interval regression fail for 3.3 and 3.3.7. + +The converged implementation passed 608 unit tests with three expected skips +on each target framework. The 42-test real-server version suite passed on tmux +3.2a and 3.7c. A further 214 real-server tests covering every migrated +production consumer passed on tmux 3.7c. + +## Consequences + +Adding a tmux behavior records one starting boundary. Removing one records the +ending boundary on the same capability. Both changes require source evidence +and a real-server regression in the parity ledger. + +Stable releases newer than the required matrix receive established behavior +whose interval remains open. Development-line behavior stays unknown until a +stable release and evidence establish its boundary. diff --git a/docs/modes/matrix.md b/docs/modes/matrix.md index 0eafe81..6a6aef4 100644 --- a/docs/modes/matrix.md +++ b/docs/modes/matrix.md @@ -101,6 +101,6 @@ and for the warmup mistake that once put an impossible number in this table. ## Version differences -Behavior that differs across tmux 3.2a to 3.7b goes through the capability -model, and each difference has a row with a real-server proof in +Known behavior differences from tmux 3.2a onward go through the capability +model. Each measured difference has a row with a real-server proof in [the parity ledger](../parity/version-deltas.json). diff --git a/docs/psmux.md b/docs/psmux.md index af2de1d..94208bf 100644 --- a/docs/psmux.md +++ b/docs/psmux.md @@ -280,8 +280,8 @@ depends on a login profile or the non-login `wsl.exe --exec` search path. raw commands are absent from the public preview. - `Server.FromEnvironment()` rejects psmux markers and fake psmux `TMUX` paths; it never falls back to an installed `psmux.exe` or fake tmux `-S` routing. -- Numeric version `3.3.8` has no tmux capability profile. Optional tmux flags - remain disabled rather than being inferred from a nearby release. +- The numeric `3.3.8` banner does not widen the psmux surface. Its separate + typed facade and command allowlist expose only the audited query operations. This is a core one-shot query preview. `LibTmux.Workspace` and the creation helpers in `LibTmux.Testing` are unavailable. `LibTmux.Mcp` is also unavailable diff --git a/docs/public-api.json b/docs/public-api.json index 9cc701d..28c8e11 100644 --- a/docs/public-api.json +++ b/docs/public-api.json @@ -22,6 +22,8 @@ "net10.0" ], "supportedTmuxVersions": { + "minimum": "3.2a", + "stableSupport": "every canonical stable release at or above the minimum", "required": [ "3.2a", "3.3a", @@ -2782,8 +2784,8 @@ "maximumTestedSemantics": "informational; not a support ceiling", "minimumChecks": "enforce only the minimum; newer untested versions may satisfy them", "exactVersionIdentity": "3.7, 3.7a, and 3.7b are distinct", - "capabilityProfileSelection": "exact parsed version identity only; no nearest-lower fallback", - "unknownCapabilityProfile": "a version may satisfy the minimum without an approved profile" + "capabilitySelection": "named support intervals apply to every stable release at or above the minimum; capabilities without a recorded end remain supported on later stable releases", + "unknownCapabilityVersion": "invalid, below-minimum, development, release-candidate, and next versions have unknown capability state" } } }, diff --git a/docs/public-api.md b/docs/public-api.md index 0009c23..1604c54 100644 --- a/docs/public-api.md +++ b/docs/public-api.md @@ -3,7 +3,8 @@ > This is a reviewed contract. Production implementation and passing > evidence are intentionally absent at this boundary. -The API targets `net8.0` and `net10.0`. Required tmux compatibility is +The API targets `net8.0` and `net10.0`. Stable tmux releases from `3.2a` onward are supported. +The required compatibility matrix covers 3.2a, 3.3a, 3.4, 3.5, 3.6, 3.7a, 3.7b; tmux master is advisory and `unknown`. Native Windows tmux execution is unsupported. IDs, snapshots, local query evaluation, JSON, and pure test helpers remain portable. @@ -16,7 +17,7 @@ zero-argument methods. ## TmuxVersion semantic contract Minimum support is `3.2a` inclusive; `3.7b` is informational, not a support ceiling. -Exact capability profiles never use nearest-lower fallback. +Stable releases use named capability intervals. The detection line starts with the exact lowercase prefix `tmux `. The complete parsing, ordering, detection, and support contract follows. @@ -114,8 +115,8 @@ The complete parsing, ordering, detection, and support contract follows. "maximumTestedSemantics": "informational; not a support ceiling", "minimumChecks": "enforce only the minimum; newer untested versions may satisfy them", "exactVersionIdentity": "3.7, 3.7a, and 3.7b are distinct", - "capabilityProfileSelection": "exact parsed version identity only; no nearest-lower fallback", - "unknownCapabilityProfile": "a version may satisfy the minimum without an approved profile" + "capabilitySelection": "named support intervals apply to every stable release at or above the minimum; capabilities without a recorded end remain supported on later stable releases", + "unknownCapabilityVersion": "invalid, below-minimum, development, release-candidate, and next versions have unknown capability state" } } ``` diff --git a/eng/parity/render_public_api.py b/eng/parity/render_public_api.py index 2c50cb3..9e29521 100644 --- a/eng/parity/render_public_api.py +++ b/eng/parity/render_public_api.py @@ -230,6 +230,7 @@ def render(contract: dict[str, t.Any]) -> str: True """ required = ", ".join(contract["supportedTmuxVersions"]["required"]) + minimum = contract["supportedTmuxVersions"]["minimum"] version_type = next( entry for entry in contract["types"] if entry["id"] == "T:LibTmux.TmuxVersion" ) @@ -240,7 +241,11 @@ def render(contract: dict[str, t.Any]) -> str: "> This is a reviewed contract. Production implementation and passing", "> evidence are intentionally absent at this boundary.", "", - "The API targets `net8.0` and `net10.0`. Required tmux compatibility is", + ( + "The API targets `net8.0` and `net10.0`. Stable tmux releases from " + f"`{minimum}` onward are supported." + ), + "The required compatibility matrix covers", ( f"{required}; tmux master is advisory and " f"`{contract['supportedTmuxVersions']['advisoryStatus']}`." @@ -259,7 +264,7 @@ def render(contract: dict[str, t.Any]) -> str: "Minimum support is `3.2a` inclusive; `3.7b` is informational, " "not a support ceiling." ), - "Exact capability profiles never use nearest-lower fallback.", + "Stable releases use named capability intervals.", "The detection line starts with the exact lowercase prefix `tmux `.", "The complete parsing, ordering, detection, and support contract follows.", "", diff --git a/eng/parity/tests/test_capabilities.py b/eng/parity/tests/test_capabilities.py index c447f7f..19468b1 100644 --- a/eng/parity/tests/test_capabilities.py +++ b/eng/parity/tests/test_capabilities.py @@ -16,10 +16,10 @@ def load_verifier() -> dict[str, t.Any]: def checked_in_source() -> str: - """Return the checked-in capability profile source.""" + """Return the checked-in capability interval source.""" return t.cast( pathlib.Path, - load_verifier()["PROFILE_PATH"], + load_verifier()["MODEL_PATH"], ).read_text(encoding="utf-8") @@ -30,7 +30,7 @@ def checked_in_document() -> dict[str, t.Any]: def test_checked_in_capability_model_matches_the_recorded_deltas() -> None: - """Keep the profiles the library ships and the version matrix in step.""" + """Keep the intervals the library ships and the version matrix in step.""" namespace = load_verifier() violations = namespace["validate"]( checked_in_source(), @@ -71,7 +71,7 @@ def test_recorded_delta_without_a_capability_is_rejected() -> None: def test_gate_naming_an_unknown_capability_is_rejected() -> None: - """Reject a gate whose name no profile carries, which can never fire.""" + """Reject a gate whose name the model does not carry.""" namespace = load_verifier() violations = namespace["validate"]( @@ -109,6 +109,26 @@ def test_the_dollar_escape_gate_is_read_from_the_option_scopes() -> None: } +def test_interval_boundary_drift_is_rejected() -> None: + """Reject a source boundary that disagrees with the real-server ledger.""" + namespace = load_verifier() + source = checked_in_source().replace( + "Add(intervals, Added37, version37);", + "Add(intervals, Added37, version36);", + ) + + violations = namespace["validate"]( + source, + namespace["referenced_capabilities"](namespace["SOURCE_ROOT"]), + checked_in_document(), + ) + + assert ( + "capability interval differs from recorded delta: new_pane_command " + "(model ('3.6', None), ledger ('3.7', None))" + ) in violations + + def test_every_recorded_capability_names_a_proof_that_exists() -> None: """Resolve the proofs against the tests rather than their spelling.""" namespace = load_verifier() diff --git a/eng/parity/tests/test_public_api.py b/eng/parity/tests/test_public_api.py index af56f23..8f739fa 100644 --- a/eng/parity/tests/test_public_api.py +++ b/eng/parity/tests/test_public_api.py @@ -124,11 +124,14 @@ "enforce only the minimum; newer untested versions may satisfy them" ), "exactVersionIdentity": "3.7, 3.7a, and 3.7b are distinct", - "capabilityProfileSelection": ( - "exact parsed version identity only; no nearest-lower fallback" + "capabilitySelection": ( + "named support intervals apply to every stable release at or above the " + "minimum; capabilities without a recorded end remain supported on later " + "stable releases" ), - "unknownCapabilityProfile": ( - "a version may satisfy the minimum without an approved profile" + "unknownCapabilityVersion": ( + "invalid, below-minimum, development, release-candidate, and next versions " + "have unknown capability state" ), }, } @@ -1192,7 +1195,7 @@ def test_rendered_api_exposes_declaration_and_invariant_details() -> None: assert "## TmuxVersion semantic contract" in markdown assert "`3.2a` inclusive" in markdown assert "`3.7b` is informational, not a support ceiling" in markdown - assert "Exact capability profiles never use nearest-lower fallback." in markdown + assert "Stable releases use named capability intervals." in markdown assert "the exact lowercase prefix `tmux `" in markdown assert '"nonzeroExit": "TmuxCommandException carrying Result"' in markdown assert "exact preserved patch, prerelease, development, vendor, or next" in markdown @@ -2103,6 +2106,10 @@ def test_tmux_version_semantics_are_canonical_and_ledger_adaptation_is_explicit( "suffix projection." ) support = version_type["versionContract"]["support"] + assert public_api["supportedTmuxVersions"]["minimum"] == "3.2a" + assert public_api["supportedTmuxVersions"]["stableSupport"] == ( + "every canonical stable release at or above the minimum" + ) required = public_api["supportedTmuxVersions"]["required"] assert support["minimum"] == required[0] == "3.2a" assert support["maximumTested"] == required[-1] == "3.7b" @@ -2125,6 +2132,17 @@ def test_tmux_version_semantics_are_canonical_and_ledger_adaptation_is_explicit( ) +def test_validator_rejects_stable_tmux_support_drift() -> None: + """Reject an exact-list interpretation of the open-ended support floor.""" + public_api = load_json(csharp_docs_root() / "public-api.json") + ledger = load_json(csharp_docs_root() / "parity" / "parity-ledger.json") + public_api["supportedTmuxVersions"]["stableSupport"] = "required versions only" + + violations = api_validator()(public_api, ledger) + + assert "invalid stable tmux support boundary" in violations + + def test_examples_are_canonical_coherent_and_executable_sources() -> None: """Keep examples in JSON and show one coherent query JSON round trip.""" public_api = load_json(csharp_docs_root() / "public-api.json") diff --git a/eng/parity/verify_capabilities.py b/eng/parity/verify_capabilities.py index ab76bfe..5f6e756 100644 --- a/eng/parity/verify_capabilities.py +++ b/eng/parity/verify_capabilities.py @@ -11,17 +11,39 @@ SOURCE_ROOT = pathlib.Path(__file__).parents[2] / "src" / "LibTmux" TESTS_ROOT = pathlib.Path(__file__).parents[2] / "tests" REPOSITORY_ROOT = pathlib.Path(__file__).parents[2] -PROFILE_PATH = SOURCE_ROOT / "Versioning" / "TmuxCapabilities.cs" +MODEL_PATH = SOURCE_ROOT / "Versioning" / "TmuxCapabilities.cs" DELTAS_PATH = ( pathlib.Path(__file__).parents[2] / "docs" / "parity" / "version-deltas.json" ) # Every capability name carries an underscore, which the other literals in the # profile source -- versions, messages, platform names -- do not. CAPABILITY_LITERAL = re.compile(r'"([a-z][a-z0-9]*(?:_[a-z0-9]+)+)"') -CONTAINS_LITERAL = re.compile(r'Capabilities\.Contains\("([^"]+)"\)') +CAPABILITY_CALL_LITERAL = re.compile( + r"\b(?:TmuxCapabilities\.(?:GetState|IsSupported)|Supports)\(" + r'[^;]*?,\s*"([a-z][a-z0-9]*(?:_[a-z0-9]+)+)"\s*\)', + re.DOTALL, +) CAPABILITY_CONST = re.compile( r'const\s+string\s+\w*Capability\s*=\s*\n?\s*"([^"]+)"', ) +CAPABILITY_GROUP = re.compile( + r"private static readonly string\[\] (?P\w+)\s*=\s*" + r"\[(?P.*?)\];", + re.DOTALL, +) +VERSION_BINDING = re.compile( + r"TmuxVersion\s+(?P\w+)\s*=\s*" + r'(?:LibTmuxInfo\.MinimumTmuxVersion|TmuxVersion\.Parse\("(?P[^"]+)"\));', +) +INTERVAL_ADD = re.compile( + r"Add\(intervals,\s*(?P\w+|\[[^]]+\]),\s*" + r"(?P\w+)(?:,\s*(?P\w+))?\);", +) +STABLE_VERSION = re.compile( + r"(?P0|[1-9][0-9]*)\.(?P0|[1-9][0-9]*)" + r"(?:\.(?P0|[1-9][0-9]*)|(?P[a-z]+))?", +) +MINIMUM_VERSION = "3.2a" # A proof is an xunit fact, which is a public method on a test class. The # modifiers are listed rather than skipped over, so the return type cannot be # mistaken for the name. @@ -33,12 +55,12 @@ def declared_capabilities(source: str) -> set[str]: - """Return the capability names the profiles are built from. + """Return the capability names the interval model is built from. Parameters ---------- source : str - Contents of the capability profile source file. + Contents of the capability model source file. Returns ------- @@ -53,6 +75,95 @@ def declared_capabilities(source: str) -> set[str]: return set(CAPABILITY_LITERAL.findall(source)) +def declared_intervals(source: str) -> dict[str, tuple[str, str | None]]: + """Return each checked-in capability interval. + + Parameters + ---------- + source : str + Contents of the capability model source file. + + Returns + ------- + dict[str, tuple[str, str | None]] + Capability name to inclusive start and exclusive end. + + Examples + -------- + >>> sample = ''' + ... private static readonly string[] Base = ["a_b"]; + ... TmuxVersion minimum = LibTmuxInfo.MinimumTmuxVersion; + ... TmuxVersion end = TmuxVersion.Parse("3.7"); + ... Add(intervals, Base, minimum, end); + ... ''' + >>> declared_intervals(sample) + {'a_b': ('3.2a', '3.7')} + """ + groups = { + match.group("name"): CAPABILITY_LITERAL.findall(match.group("body")) + for match in CAPABILITY_GROUP.finditer(source) + } + versions = { + match.group("name"): match.group("raw") or MINIMUM_VERSION + for match in VERSION_BINDING.finditer(source) + } + intervals: dict[str, tuple[str, str | None]] = {} + for match in INTERVAL_ADD.finditer(source): + expression = match.group("capabilities") + capabilities = ( + CAPABILITY_LITERAL.findall(expression) + if expression.startswith("[") + else groups.get(expression, []) + ) + start = versions.get(match.group("start")) + end_name = match.group("end") + end = versions.get(end_name) if end_name else None + if start is None or (end_name and end is None): + continue + for capability in capabilities: + intervals[capability] = (start, end) + return intervals + + +def stable_version_key(raw: str) -> tuple[int, int, int, int | str]: + """Return the ordering key used by stable boundaries in the ledger.""" + match = STABLE_VERSION.fullmatch(raw) + if match is None: + raise ValueError(f"not a stable tmux boundary: {raw}") + if match.group("micro") is not None: + kind = 1 + suffix: int | str = int(match.group("micro")) + elif match.group("patch") is not None: + kind = 2 + patch = match.group("patch") + suffix = f"{len(patch):08d}:{patch}" + else: + kind = 0 + suffix = 0 + return int(match.group("major")), int(match.group("minor")), kind, suffix + + +def recorded_intervals( + document: dict[str, t.Any], +) -> dict[str, tuple[str, str | None]]: + """Project recorded deltas onto the supported stable version floor.""" + minimum_key = stable_version_key(MINIMUM_VERSION) + intervals = {} + for row in t.cast(list[dict[str, t.Any]], document.get("capabilities", [])): + introduced = t.cast(str, row.get("introducedIn", "unknown")) + supported_from = ( + MINIMUM_VERSION + if introduced == "unknown" or stable_version_key(introduced) < minimum_key + else introduced + ) + removed = t.cast(str, row.get("removedIn", "unknown")) + intervals[t.cast(str, row["capability"])] = ( + supported_from, + None if removed == "unknown" else removed, + ) + return intervals + + def referenced_capabilities(root: pathlib.Path) -> dict[str, set[str]]: """Return each capability a version gate names, and where it names it. @@ -74,7 +185,8 @@ def referenced_capabilities(root: pathlib.Path) -> dict[str, set[str]]: references: dict[str, set[str]] = {} for path in sorted(root.rglob("*.cs")): text = path.read_text(encoding="utf-8") - for name in CONTAINS_LITERAL.findall(text) + CAPABILITY_CONST.findall(text): + names = CAPABILITY_CALL_LITERAL.findall(text) + CAPABILITY_CONST.findall(text) + for name in names: references.setdefault(name, set()).add(path.name) return references @@ -198,7 +310,7 @@ def validate( Parameters ---------- source : str - Contents of the capability profile source file. + Contents of the capability model source file. references : dict[str, set[str]] Capability name to the file names that gate on it. document : dict[str, typing.Any] @@ -211,13 +323,20 @@ def validate( Examples -------- - >>> validate('"a_b"', {}, {"capabilities": [{"capability": "a_b"}]}) + >>> sample = ''' + ... private static readonly string[] Base = ["a_b"]; + ... TmuxVersion minimum = LibTmuxInfo.MinimumTmuxVersion; + ... Add(intervals, Base, minimum); + ... ''' + >>> validate(sample, {}, {"capabilities": [{"capability": "a_b"}]}) [] >>> validate('"a_b"', {}, {"capabilities": []}) ['capability is declared but not recorded: a_b'] """ declared = declared_capabilities(source) recorded = recorded_capabilities(document) + model_intervals = declared_intervals(source) + delta_intervals = recorded_intervals(document) violations = [ f"capability is declared but not recorded: {name}" for name in sorted(declared - recorded) @@ -231,6 +350,12 @@ def validate( f"({', '.join(sorted(references[name]))})" for name in sorted(set(references) - declared) ) + violations.extend( + f"capability interval differs from recorded delta: {name} " + f"(model {model_intervals.get(name)}, ledger {delta_intervals[name]})" + for name in sorted(recorded & declared) + if model_intervals.get(name) != delta_intervals[name] + ) return violations @@ -249,7 +374,7 @@ def main() -> int: """ document = json.loads(DELTAS_PATH.read_text(encoding="utf-8")) violations = validate( - PROFILE_PATH.read_text(encoding="utf-8"), + MODEL_PATH.read_text(encoding="utf-8"), referenced_capabilities(SOURCE_ROOT), document, ) diff --git a/eng/parity/verify_public_api.py b/eng/parity/verify_public_api.py index 5283adc..b0157ea 100644 --- a/eng/parity/verify_public_api.py +++ b/eng/parity/verify_public_api.py @@ -168,11 +168,14 @@ "enforce only the minimum; newer untested versions may satisfy them" ), "exactVersionIdentity": "3.7, 3.7a, and 3.7b are distinct", - "capabilityProfileSelection": ( - "exact parsed version identity only; no nearest-lower fallback" + "capabilitySelection": ( + "named support intervals apply to every stable release at or above the " + "minimum; capabilities without a recorded end remain supported on later " + "stable releases" ), - "unknownCapabilityProfile": ( - "a version may satisfy the minimum without an approved profile" + "unknownCapabilityVersion": ( + "invalid, below-minimum, development, release-candidate, and next versions " + "have unknown capability state" ), }, } @@ -431,6 +434,12 @@ def validate_header(contract: dict[str, t.Any], violations: list[str]) -> None: if contract.get("supportedTargetFrameworks") != ["net8.0", "net10.0"]: violations.append("invalid target framework boundary") versions = t.cast(dict[str, t.Any], contract.get("supportedTmuxVersions", {})) + if ( + versions.get("minimum") != "3.2a" + or versions.get("stableSupport") + != "every canonical stable release at or above the minimum" + ): + violations.append("invalid stable tmux support boundary") if versions.get("required") != [ "3.2a", "3.3a", From b124b93511d346a37ee9c649a0794befa3704317 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 06:19:33 -0500 Subject: [PATCH 011/129] QueryJson(fix[validation]): Fail closed at wire boundary why: The writer silently changed forged enum values, emitted documents its reader rejected, and rejected valid supplementary Unicode. The reader also converted malformed values into valid defaults and leaked structural exceptions. what: - Validate document identity, enum values, regex metadata, Unicode scalars, and encoded size while writing - Reject unknown quantifiers, null text, malformed shapes, and invalid tightened limits while reading - Add red-first coverage for every corrected boundary --- .../QueryDocumentJsonConverter.cs | 184 +++++++++++++----- .../QueryJsonSerializerContext.cs | 70 ++++--- .../LibTmux.UnitTests/Query/QueryJsonTests.cs | 103 ++++++++++ .../Query/QueryJsonTrustBoundaryTests.cs | 46 +++++ 4 files changed, 330 insertions(+), 73 deletions(-) diff --git a/src/LibTmux.Query.Json/QueryDocumentJsonConverter.cs b/src/LibTmux.Query.Json/QueryDocumentJsonConverter.cs index e2185ed..b527e97 100644 --- a/src/LibTmux.Query.Json/QueryDocumentJsonConverter.cs +++ b/src/LibTmux.Query.Json/QueryDocumentJsonConverter.cs @@ -1,8 +1,68 @@ using System.Text.Json; using System.Text.Json.Serialization; +using System.Text.RegularExpressions; namespace LibTmux.Query.Json; +internal static class QueryJsonWireRules +{ + internal const string RegexDialect = "dotnet"; + + internal const RegexOptions AllowedRegexOptions = + RegexOptions.None + | RegexOptions.IgnoreCase + | RegexOptions.Multiline + | RegexOptions.Singleline + | RegexOptions.CultureInvariant; + + internal static int ScalarLength(string? value, string description) + { + if (value is null) + { + throw new JsonException($"{description} is null."); + } + + int scalars = 0; + for (int index = 0; index < value.Length; index++, scalars++) + { + char character = value[index]; + if (char.IsHighSurrogate(character)) + { + if (index + 1 >= value.Length || !char.IsLowSurrogate(value[index + 1])) + { + throw new JsonException($"{description} contains an unpaired surrogate."); + } + + index++; + } + else if (char.IsLowSurrogate(character)) + { + throw new JsonException($"{description} contains an unpaired surrogate."); + } + } + + return scalars; + } + + internal static void ValidateRegex(RegexNode regex, QueryJsonLimits limits) + { + if (!string.Equals(regex.Dialect, RegexDialect, StringComparison.Ordinal)) + { + throw new JsonException($"Regex dialect '{regex.Dialect}' is not supported."); + } + + if (ScalarLength(regex.Pattern, "Regex pattern") > limits.MaximumPatternLength) + { + throw new JsonException("Regex pattern exceeds the maximum length."); + } + + if ((regex.SemanticOptions & ~AllowedRegexOptions) != 0) + { + throw new JsonException("Regex names options this writer does not support."); + } + } +} + /// Reads and writes the stable v1 wire form of a query document. /// /// The wire form is hand-written rather than reflection-derived so the schema @@ -29,6 +89,19 @@ public override void Write( { ArgumentNullException.ThrowIfNull(writer); ArgumentNullException.ThrowIfNull(value); + if (!string.Equals(value.Schema, QueryDocument.CurrentSchema, StringComparison.Ordinal)) + { + throw new JsonException( + $"Query document names schema '{value.Schema}', which this writer does not know."); + } + + if (value.Version != QueryDocument.CurrentVersion) + { + throw new JsonException( + $"Query document is version {value.Version}; this writer understands " + + $"{QueryDocument.CurrentVersion}."); + } + _nodes = 0; writer.WriteStartObject(); writer.WriteString("schema", value.Schema); @@ -44,7 +117,8 @@ public override void Write( QueryTarget.Session => "session", QueryTarget.Window => "window", QueryTarget.Pane => "pane", - _ => "client", + QueryTarget.Client => "client", + _ => throw new JsonException("Query document names an unknown target."), }; private static string Wire(QueryComparison comparison) => comparison switch @@ -54,7 +128,8 @@ public override void Write( QueryComparison.LessThan => "lt", QueryComparison.LessThanOrEqual => "le", QueryComparison.GreaterThan => "gt", - _ => "ge", + QueryComparison.GreaterThanOrEqual => "ge", + _ => throw new JsonException("Query document names an unknown comparison."), }; private static string Wire(QueryStringOperation operation) => operation switch @@ -63,11 +138,24 @@ public override void Write( QueryStringOperation.EqualsOrdinalIgnoreCase => "equalsIgnoreCase", QueryStringOperation.StartsWithOrdinal => "startsWith", QueryStringOperation.EndsWithOrdinal => "endsWith", - _ => "contains", + QueryStringOperation.ContainsOrdinal => "contains", + _ => throw new JsonException("Query document names an unknown string operation."), + }; + + private static string Wire(QueryQuantifier quantifier) => quantifier switch + { + QueryQuantifier.Any => "any", + QueryQuantifier.All => "all", + _ => throw new JsonException("Query document names an unknown quantifier."), }; private void WriteNode(Utf8JsonWriter writer, QueryNode node, int depth) { + if (node is null) + { + throw new JsonException("Query document contains a null node."); + } + if (depth > _limits.MaximumDepth) { throw new JsonException("Query document exceeds the maximum nesting depth."); @@ -109,7 +197,7 @@ private void WriteNode(Utf8JsonWriter writer, QueryNode node, int depth) writer.WriteString("kind", "quantifier"); writer.WriteString( "quantifier", - quantifier.Quantifier == QueryQuantifier.Any ? "any" : "all"); + Wire(quantifier.Quantifier)); writer.WritePropertyName("relation"); WriteNode(writer, quantifier.Relation, depth + 1); writer.WritePropertyName("predicate"); @@ -118,7 +206,7 @@ private void WriteNode(Utf8JsonWriter writer, QueryNode node, int depth) case FieldNode field: writer.WriteString("kind", "field"); writer.WriteString("target", Wire(field.Target)); - writer.WriteString("name", field.WireName); + WriteBoundedString(writer, "name", field.WireName, "Field wire name"); break; case ConstantNode constant: writer.WriteString("kind", "constant"); @@ -133,10 +221,7 @@ private void WriteNode(Utf8JsonWriter writer, QueryNode node, int depth) private void WriteRegex(Utf8JsonWriter writer, RegexNode regex, int depth) { - if (regex.Pattern.Length > _limits.MaximumPatternLength) - { - throw new JsonException("Regex pattern exceeds the maximum length."); - } + QueryJsonWireRules.ValidateRegex(regex, _limits); writer.WriteString("kind", "regex"); writer.WriteString("dialect", regex.Dialect); @@ -172,6 +257,11 @@ private void WritePair(Utf8JsonWriter writer, QueryNode left, QueryNode right, i private void WriteConstant(Utf8JsonWriter writer, QueryConstant constant) { + if (constant is null) + { + throw new JsonException("Query document contains a null constant."); + } + switch (constant) { case NullConstant: @@ -188,7 +278,7 @@ private void WriteConstant(Utf8JsonWriter writer, QueryConstant constant) break; case StringConstant text: writer.WriteString("type", "string"); - WriteBoundedString(writer, text.Value); + WriteBoundedString(writer, "value", text.Value, "String value"); break; case InstantConstant instant: writer.WriteString("type", "instant"); @@ -196,13 +286,13 @@ private void WriteConstant(Utf8JsonWriter writer, QueryConstant constant) break; case EnumConstant member: writer.WriteString("type", "enum"); - writer.WriteString("enumType", member.Type); - WriteBoundedString(writer, member.Value); + WriteBoundedString(writer, "enumType", member.Type, "Enum type"); + WriteBoundedString(writer, "value", member.Value, "Enum value"); break; case TypedIdConstant id: writer.WriteString("type", "typedId"); writer.WriteString("target", Wire(id.Target)); - WriteBoundedString(writer, id.Value); + WriteBoundedString(writer, "value", id.Value, "Typed ID value"); break; default: throw new JsonException( @@ -210,24 +300,18 @@ private void WriteConstant(Utf8JsonWriter writer, QueryConstant constant) } } - private void WriteBoundedString(Utf8JsonWriter writer, string value) + private void WriteBoundedString( + Utf8JsonWriter writer, + string propertyName, + string? value, + string description) { - if (value.Length > _limits.MaximumStringLength) + if (QueryJsonWireRules.ScalarLength(value, description) > _limits.MaximumStringLength) { throw new JsonException("String value exceeds the maximum length."); } - // A lone surrogate cannot round-trip through UTF-8, so it must never - // reach the wire. - foreach (char character in value) - { - if (char.IsSurrogate(character) && !char.IsSurrogatePair(value, value.IndexOf(character, StringComparison.Ordinal))) - { - throw new JsonException("String value contains an unpaired surrogate."); - } - } - - writer.WriteString("value", value); + writer.WriteString(propertyName, value); } } @@ -280,15 +364,12 @@ internal QueryNode ReadNode(JsonElement element, int depth) ReadPattern(element.GetProperty("pattern")), ReadRegexOptions(element.GetProperty("semanticOptions"))), "quantifier" => new QuantifierNode( - element.GetProperty("quantifier").GetString() == "any" - ? QueryQuantifier.Any - : QueryQuantifier.All, + ReadQuantifier(element.GetProperty("quantifier")), (FieldNode)ReadNode(element.GetProperty("relation"), depth + 1), ReadNode(element.GetProperty("predicate"), depth + 1)), "field" => new FieldNode( ReadTarget(element.GetProperty("target")), - element.GetProperty("name").GetString() - ?? throw new JsonException("Field names no wire name.")), + ReadBoundedString(element.GetProperty("name"), "Field wire name")), "constant" => new ConstantNode(ReadConstant(element)), _ => throw new JsonException("Query document names an unknown node kind."), }; @@ -302,8 +383,9 @@ internal QueryNode ReadNode(JsonElement element, int depth) /// private static string ReadDialect(JsonElement element) { - string dialect = element.GetString() ?? "dotnet"; - return string.Equals(dialect, "dotnet", StringComparison.Ordinal) + string dialect = element.GetString() + ?? throw new JsonException("Regex names no dialect."); + return string.Equals(dialect, QueryJsonWireRules.RegexDialect, StringComparison.Ordinal) ? dialect : throw new JsonException($"Regex dialect '{dialect}' is not supported."); } @@ -313,7 +395,8 @@ private string ReadPattern(JsonElement element) { string pattern = element.GetString() ?? throw new JsonException("Regex names no pattern."); - return pattern.Length <= _limits.MaximumPatternLength + return QueryJsonWireRules.ScalarLength(pattern, "Regex pattern") + <= _limits.MaximumPatternLength ? pattern : throw new JsonException("Regex pattern exceeds the maximum length."); } @@ -326,15 +409,8 @@ private string ReadPattern(JsonElement element) private static System.Text.RegularExpressions.RegexOptions ReadRegexOptions( JsonElement element) { - const System.Text.RegularExpressions.RegexOptions Allowed = - System.Text.RegularExpressions.RegexOptions.None - | System.Text.RegularExpressions.RegexOptions.IgnoreCase - | System.Text.RegularExpressions.RegexOptions.Multiline - | System.Text.RegularExpressions.RegexOptions.Singleline - | System.Text.RegularExpressions.RegexOptions.CultureInvariant; - - var options = (System.Text.RegularExpressions.RegexOptions)element.GetInt32(); - return (options & ~Allowed) == 0 + var options = (RegexOptions)element.GetInt32(); + return (options & ~QueryJsonWireRules.AllowedRegexOptions) == 0 ? options : throw new JsonException("Regex names options this reader does not support."); } @@ -344,10 +420,12 @@ private static System.Text.RegularExpressions.RegexOptions ReadRegexOptions( /// Re-checked here because the writer's own limit does not bound documents /// produced elsewhere, which are exactly the ones this limit exists for. /// - private string ReadBoundedString(JsonElement element) + private string ReadBoundedString(JsonElement element, string description = "String value") { - string value = element.GetString() ?? string.Empty; - return value.Length <= _limits.MaximumStringLength + string value = element.GetString() + ?? throw new JsonException($"{description} is null."); + return QueryJsonWireRules.ScalarLength(value, description) + <= _limits.MaximumStringLength ? value : throw new JsonException("String value exceeds the maximum length."); } @@ -375,6 +453,14 @@ private static QueryStringOperation ReadStringOperation(JsonElement element) => _ => throw new JsonException("Query document names an unknown string operation."), }; + private static QueryQuantifier ReadQuantifier(JsonElement element) => + element.GetString() switch + { + "any" => QueryQuantifier.Any, + "all" => QueryQuantifier.All, + _ => throw new JsonException("Query document names an unknown quantifier."), + }; + private QueryConstant ReadConstant(JsonElement element) => element.GetProperty("type").GetString() switch { @@ -384,11 +470,11 @@ private QueryConstant ReadConstant(JsonElement element) => "string" => new StringConstant(ReadBoundedString(element.GetProperty("value"))), "instant" => new InstantConstant(element.GetProperty("value").GetInt64()), "enum" => new EnumConstant( - element.GetProperty("enumType").GetString() ?? string.Empty, - element.GetProperty("value").GetString() ?? string.Empty), + ReadBoundedString(element.GetProperty("enumType"), "Enum type"), + ReadBoundedString(element.GetProperty("value"), "Enum value")), "typedId" => new TypedIdConstant( ReadTarget(element.GetProperty("target")), - element.GetProperty("value").GetString() ?? string.Empty), + ReadBoundedString(element.GetProperty("value"), "Typed ID value")), _ => throw new JsonException("Query document names an unknown constant type."), }; diff --git a/src/LibTmux.Query.Json/QueryJsonSerializerContext.cs b/src/LibTmux.Query.Json/QueryJsonSerializerContext.cs index 5af4931..f3844ee 100644 --- a/src/LibTmux.Query.Json/QueryJsonSerializerContext.cs +++ b/src/LibTmux.Query.Json/QueryJsonSerializerContext.cs @@ -36,7 +36,12 @@ internal QueryJsonLimits Clamp() || MaximumNodes > V1.MaximumNodes || MaximumStringLength > V1.MaximumStringLength || MaximumPatternLength > V1.MaximumPatternLength - || MaximumUtf8Bytes > V1.MaximumUtf8Bytes) + || MaximumUtf8Bytes > V1.MaximumUtf8Bytes + || MaximumDepth < 0 + || MaximumNodes < 0 + || MaximumStringLength < 0 + || MaximumPatternLength < 0 + || MaximumUtf8Bytes < 0) { throw new ArgumentOutOfRangeException( nameof(QueryJsonLimits), @@ -67,7 +72,13 @@ public static string Serialize(QueryDocument document) .Write(writer, document, new JsonSerializerOptions()); } - return Encoding.UTF8.GetString(buffer.ToArray()); + byte[] encoded = buffer.ToArray(); + if (encoded.Length > QueryJsonLimits.V1.MaximumUtf8Bytes) + { + throw new JsonException("Query document exceeds the maximum encoded size."); + } + + return Encoding.UTF8.GetString(encoded); } /// Reads one v1 JSON document. @@ -87,31 +98,42 @@ public static QueryDocument Deserialize(string json, QueryJsonLimits? limits = n using JsonDocument parsed = JsonDocument.Parse( json, new JsonDocumentOptions { MaxDepth = bounds.MaximumDepth }); - JsonElement root = parsed.RootElement; - - // Schema and version must be checked before anything else is read, or - // a v2 payload gets silently parsed under v1 rules. - string schema = root.GetProperty("schema").GetString() - ?? throw new JsonException("Query document names no schema."); - if (!string.Equals(schema, QueryDocument.CurrentSchema, StringComparison.Ordinal)) + try { - throw new JsonException( - $"Query document names schema '{schema}', which this reader does not know."); - } + JsonElement root = parsed.RootElement; + + // Schema and version must be checked before anything else is read, or + // a v2 payload gets silently parsed under v1 rules. + string schema = root.GetProperty("schema").GetString() + ?? throw new JsonException("Query document names no schema."); + if (!string.Equals(schema, QueryDocument.CurrentSchema, StringComparison.Ordinal)) + { + throw new JsonException( + $"Query document names schema '{schema}', which this reader does not know."); + } + + int version = root.GetProperty("version").GetInt32(); + if (version != QueryDocument.CurrentVersion) + { + throw new JsonException( + $"Query document is version {version}; this reader understands " + + $"{QueryDocument.CurrentVersion}."); + } - int version = root.GetProperty("version").GetInt32(); - if (version != QueryDocument.CurrentVersion) + var reader = new QueryDocumentJsonReader(bounds); + return new QueryDocument( + schema, + version, + QueryDocumentJsonReader.ReadTarget(root.GetProperty("target")), + reader.ReadNode(root.GetProperty("predicate"), depth: 1)); + } + catch (Exception exception) when ( + exception is KeyNotFoundException + or InvalidCastException + or InvalidOperationException + or FormatException) { - throw new JsonException( - $"Query document is version {version}; this reader understands " - + $"{QueryDocument.CurrentVersion}."); + throw new JsonException("Query document does not match the v1 wire form.", exception); } - - var reader = new QueryDocumentJsonReader(bounds); - return new QueryDocument( - schema, - version, - QueryDocumentJsonReader.ReadTarget(root.GetProperty("target")), - reader.ReadNode(root.GetProperty("predicate"), depth: 1)); } } diff --git a/tests/LibTmux.UnitTests/Query/QueryJsonTests.cs b/tests/LibTmux.UnitTests/Query/QueryJsonTests.cs index 975aba7..095399a 100644 --- a/tests/LibTmux.UnitTests/Query/QueryJsonTests.cs +++ b/tests/LibTmux.UnitTests/Query/QueryJsonTests.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Text.RegularExpressions; using LibTmux.Query; using LibTmux.Query.Json; @@ -8,6 +9,12 @@ public sealed class QueryJsonTests { private sealed record Row(string SessionName, long SessionWindows); + private static readonly FieldNode SessionName = + new(QueryTarget.Session, "session_name"); + + private static readonly ConstantNode True = + new(new BooleanConstant(true)); + public static TheoryData Goldens => new() { @@ -81,4 +88,100 @@ public void An_unknown_node_kind_is_refused_rather_than_guessed() Assert.Throws(() => QueryJson.Deserialize(json)); } + + [Fact] + public void Supplementary_unicode_round_trips_as_one_string_value() + { + QueryDocument document = + QueryEdgeParser.ParseNameContains(QueryTarget.Session, "build-\U0001F680"); + + string json = QueryJson.Serialize(document); + + Assert.Equal(document, QueryJson.Deserialize(json)); + } + + public static TheoryData InvalidWriterDocuments => + new() + { + { + "target", + Document(True, target: (QueryTarget)99) + }, + { + "comparison", + Document(new ComparisonNode((QueryComparison)99, True, True)) + }, + { + "string operation", + Document(new StringNode((QueryStringOperation)99, SessionName, True)) + }, + { + "quantifier", + Document(new QuantifierNode( + (QueryQuantifier)99, + new FieldNode(QueryTarget.Session, "session_windows"), + True)) + }, + { + "regex options", + Document(new RegexNode( + SessionName, + "dotnet", + "^build", + RegexOptions.NonBacktracking)) + }, + { + "regex dialect", + Document(new RegexNode(SessionName, "pcre", "^build", RegexOptions.None)) + }, + }; + + [Theory] + [MemberData(nameof(InvalidWriterDocuments))] + public void Serialization_refuses_values_with_no_version_one_wire_form( + string name, + QueryDocument document) + { + Assert.NotEmpty(name); + + Assert.Throws(() => QueryJson.Serialize(document)); + } + + [Theory] + [InlineData("someone.else", QueryDocument.CurrentVersion)] + [InlineData(QueryDocument.CurrentSchema, QueryDocument.CurrentVersion + 1)] + public void Serialization_refuses_a_document_from_another_contract( + string schema, + int version) + { + QueryDocument document = new(schema, version, QueryTarget.Session, True); + + Assert.Throws(() => QueryJson.Serialize(document)); + } + + [Fact] + public void Serialization_enforces_the_version_one_encoded_size_limit() + { + string value = new('a', QueryJsonLimits.V1.MaximumStringLength); + QueryNode[] operands = + [ + .. Enumerable.Range(0, 64).Select( + _ => new StringNode( + QueryStringOperation.ContainsOrdinal, + SessionName, + new ConstantNode(new StringConstant(value)))), + ]; + QueryDocument document = Document(new OrNode(operands)); + + Assert.Throws(() => QueryJson.Serialize(document)); + } + + private static QueryDocument Document( + QueryNode predicate, + QueryTarget target = QueryTarget.Session) => + new( + QueryDocument.CurrentSchema, + QueryDocument.CurrentVersion, + target, + predicate); } diff --git a/tests/LibTmux.UnitTests/Query/QueryJsonTrustBoundaryTests.cs b/tests/LibTmux.UnitTests/Query/QueryJsonTrustBoundaryTests.cs index f7c4296..da068b3 100644 --- a/tests/LibTmux.UnitTests/Query/QueryJsonTrustBoundaryTests.cs +++ b/tests/LibTmux.UnitTests/Query/QueryJsonTrustBoundaryTests.cs @@ -87,6 +87,52 @@ public void Regex_options_outside_the_supported_set_are_refused() Assert.Throws(() => QueryJson.Deserialize(json)); } + [Fact] + public void An_unknown_quantifier_is_refused_rather_than_treated_as_all() + { + string json = Document( + """ + {"kind":"quantifier","quantifier":"sometimes", + "relation":{"kind":"field","target":"session","name":"session_windows"}, + "predicate":{"kind":"constant","type":"boolean","value":true}} + """); + + Assert.Throws(() => QueryJson.Deserialize(json)); + } + + [Theory] + [InlineData("string", "\"value\":null")] + [InlineData("enum", "\"enumType\":null,\"value\":\"Ready\"")] + [InlineData("enum", "\"enumType\":\"State\",\"value\":null")] + [InlineData("typedId", "\"target\":\"session\",\"value\":null")] + public void Null_constant_text_is_refused(string type, string members) + { + string json = Document( + $$"""{"kind":"constant","type":"{{type}}",{{members}}}"""); + + Assert.Throws(() => QueryJson.Deserialize(json)); + } + + [Fact] + public void A_null_regex_dialect_is_refused() + { + string json = Document( + """ + {"kind":"regex","input":{"kind":"field","target":"session","name":"session_name"}, + "dialect":null,"pattern":"^a","semanticOptions":0} + """); + + Assert.Throws(() => QueryJson.Deserialize(json)); + } + + [Fact] + public void A_structurally_malformed_document_reports_a_json_error() + { + string json = Document("{}"); + + Assert.Throws(() => QueryJson.Deserialize(json)); + } + [Fact] public void A_field_outside_the_catalog_cannot_be_read_from_an_element() { From 2818ea9af9d86dde6efe918592edb40202d8f1a9 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 06:23:49 -0500 Subject: [PATCH 012/129] QueryJson(fix[contract]): Restore canonical v1 wire why: The production port emitted a private wire shape that disagreed with the accepted architecture decision, retained goldens, and schema shipped in every package. what: - Restore the libtmux-query identifier and canonical field, operator, and constant encodings - Read canonical comparison and tagged-constant nodes back into the production AST - Bind the implementation to an external byte-for-byte golden --- .../QueryDocumentJsonConverter.cs | 124 +++++++++--------- src/LibTmux/PublicAPI.Unshipped.txt | 2 +- src/LibTmux/Query/QueryDocument.cs | 2 +- .../LibTmux.UnitTests/Query/QueryJsonTests.cs | 16 ++- .../Query/QueryJsonTrustBoundaryTests.cs | 34 ++--- 5 files changed, 99 insertions(+), 79 deletions(-) diff --git a/src/LibTmux.Query.Json/QueryDocumentJsonConverter.cs b/src/LibTmux.Query.Json/QueryDocumentJsonConverter.cs index b527e97..29b4fcd 100644 --- a/src/LibTmux.Query.Json/QueryDocumentJsonConverter.cs +++ b/src/LibTmux.Query.Json/QueryDocumentJsonConverter.cs @@ -123,22 +123,22 @@ public override void Write( private static string Wire(QueryComparison comparison) => comparison switch { - QueryComparison.Equal => "eq", - QueryComparison.NotEqual => "ne", - QueryComparison.LessThan => "lt", - QueryComparison.LessThanOrEqual => "le", - QueryComparison.GreaterThan => "gt", - QueryComparison.GreaterThanOrEqual => "ge", + QueryComparison.Equal => "equal", + QueryComparison.NotEqual => "notEqual", + QueryComparison.LessThan => "lessThan", + QueryComparison.LessThanOrEqual => "lessThanOrEqual", + QueryComparison.GreaterThan => "greaterThan", + QueryComparison.GreaterThanOrEqual => "greaterThanOrEqual", _ => throw new JsonException("Query document names an unknown comparison."), }; private static string Wire(QueryStringOperation operation) => operation switch { - QueryStringOperation.EqualsOrdinal => "equals", - QueryStringOperation.EqualsOrdinalIgnoreCase => "equalsIgnoreCase", - QueryStringOperation.StartsWithOrdinal => "startsWith", - QueryStringOperation.EndsWithOrdinal => "endsWith", - QueryStringOperation.ContainsOrdinal => "contains", + QueryStringOperation.EqualsOrdinal => "stringEqualOrdinal", + QueryStringOperation.EqualsOrdinalIgnoreCase => "stringEqualOrdinalIgnoreCase", + QueryStringOperation.StartsWithOrdinal => "startsWithOrdinal", + QueryStringOperation.EndsWithOrdinal => "endsWithOrdinal", + QueryStringOperation.ContainsOrdinal => "containsOrdinal", _ => throw new JsonException("Query document names an unknown string operation."), }; @@ -186,7 +186,7 @@ private void WriteNode(Utf8JsonWriter writer, QueryNode node, int depth) WritePair(writer, comparison.Left, comparison.Right, depth); break; case StringNode text: - writer.WriteString("kind", "string"); + writer.WriteString("kind", "comparison"); writer.WriteString("operator", Wire(text.Operator)); WritePair(writer, text.Left, text.Right, depth); break; @@ -206,10 +206,11 @@ private void WriteNode(Utf8JsonWriter writer, QueryNode node, int depth) case FieldNode field: writer.WriteString("kind", "field"); writer.WriteString("target", Wire(field.Target)); - WriteBoundedString(writer, "name", field.WireName, "Field wire name"); + WriteBoundedString(writer, "wireName", field.WireName, "Field wire name"); break; case ConstantNode constant: writer.WriteString("kind", "constant"); + writer.WritePropertyName("value"); WriteConstant(writer, constant.Value); break; default: @@ -224,11 +225,11 @@ private void WriteRegex(Utf8JsonWriter writer, RegexNode regex, int depth) QueryJsonWireRules.ValidateRegex(regex, _limits); writer.WriteString("kind", "regex"); + writer.WritePropertyName("input"); + WriteNode(writer, regex.Input, depth + 1); writer.WriteString("dialect", regex.Dialect); writer.WriteString("pattern", regex.Pattern); writer.WriteNumber("semanticOptions", (int)regex.SemanticOptions); - writer.WritePropertyName("input"); - WriteNode(writer, regex.Input, depth + 1); } private void WriteOperands( @@ -262,42 +263,44 @@ private void WriteConstant(Utf8JsonWriter writer, QueryConstant constant) throw new JsonException("Query document contains a null constant."); } + writer.WriteStartObject(); switch (constant) { case NullConstant: - writer.WriteString("type", "null"); - writer.WriteNull("value"); + writer.WriteString("kind", "null"); break; case BooleanConstant boolean: - writer.WriteString("type", "boolean"); + writer.WriteString("kind", "boolean"); writer.WriteBoolean("value", boolean.Value); break; case Int64Constant number: - writer.WriteString("type", "int64"); + writer.WriteString("kind", "int64"); writer.WriteNumber("value", number.Value); break; case StringConstant text: - writer.WriteString("type", "string"); + writer.WriteString("kind", "string"); WriteBoundedString(writer, "value", text.Value, "String value"); break; case InstantConstant instant: - writer.WriteString("type", "instant"); - writer.WriteNumber("value", instant.UnixSeconds); + writer.WriteString("kind", "instant"); + writer.WriteNumber("unixSeconds", instant.UnixSeconds); break; case EnumConstant member: - writer.WriteString("type", "enum"); - WriteBoundedString(writer, "enumType", member.Type, "Enum type"); - WriteBoundedString(writer, "value", member.Value, "Enum value"); + writer.WriteString("kind", "enum"); + WriteBoundedString(writer, "type", member.Type, "Enum type"); + WriteBoundedString(writer, "token", member.Value, "Enum value"); break; case TypedIdConstant id: - writer.WriteString("type", "typedId"); - writer.WriteString("target", Wire(id.Target)); + writer.WriteString("kind", "typedId"); + writer.WriteString("type", Wire(id.Target)); WriteBoundedString(writer, "value", id.Value, "Typed ID value"); break; default: throw new JsonException( $"Constant '{constant.GetType().Name}' has no v1 wire form."); } + + writer.WriteEndObject(); } private void WriteBoundedString( @@ -350,14 +353,7 @@ internal QueryNode ReadNode(JsonElement element, int depth) "and" => new AndNode([.. ReadOperands(element, depth)]), "or" => new OrNode([.. ReadOperands(element, depth)]), "not" => new NotNode(ReadNode(element.GetProperty("operand"), depth + 1)), - "comparison" => new ComparisonNode( - ReadComparison(element.GetProperty("operator")), - ReadNode(element.GetProperty("left"), depth + 1), - ReadNode(element.GetProperty("right"), depth + 1)), - "string" => new StringNode( - ReadStringOperation(element.GetProperty("operator")), - ReadNode(element.GetProperty("left"), depth + 1), - ReadNode(element.GetProperty("right"), depth + 1)), + "comparison" => ReadComparisonNode(element, depth), "regex" => new RegexNode( ReadNode(element.GetProperty("input"), depth + 1), ReadDialect(element.GetProperty("dialect")), @@ -369,8 +365,8 @@ internal QueryNode ReadNode(JsonElement element, int depth) ReadNode(element.GetProperty("predicate"), depth + 1)), "field" => new FieldNode( ReadTarget(element.GetProperty("target")), - ReadBoundedString(element.GetProperty("name"), "Field wire name")), - "constant" => new ConstantNode(ReadConstant(element)), + ReadBoundedString(element.GetProperty("wireName"), "Field wire name")), + "constant" => new ConstantNode(ReadConstant(element.GetProperty("value"))), _ => throw new JsonException("Query document names an unknown node kind."), }; } @@ -430,28 +426,34 @@ private string ReadBoundedString(JsonElement element, string description = "Stri : throw new JsonException("String value exceeds the maximum length."); } - private static QueryComparison ReadComparison(JsonElement element) => - element.GetString() switch + private QueryNode ReadComparisonNode(JsonElement element, int depth) + { + string? operation = element.GetProperty("operator").GetString(); + QueryNode left = ReadNode(element.GetProperty("left"), depth + 1); + QueryNode right = ReadNode(element.GetProperty("right"), depth + 1); + return operation switch { - "eq" => QueryComparison.Equal, - "ne" => QueryComparison.NotEqual, - "lt" => QueryComparison.LessThan, - "le" => QueryComparison.LessThanOrEqual, - "gt" => QueryComparison.GreaterThan, - "ge" => QueryComparison.GreaterThanOrEqual, + "equal" => new ComparisonNode(QueryComparison.Equal, left, right), + "notEqual" => new ComparisonNode(QueryComparison.NotEqual, left, right), + "lessThan" => new ComparisonNode(QueryComparison.LessThan, left, right), + "lessThanOrEqual" => + new ComparisonNode(QueryComparison.LessThanOrEqual, left, right), + "greaterThan" => new ComparisonNode(QueryComparison.GreaterThan, left, right), + "greaterThanOrEqual" => + new ComparisonNode(QueryComparison.GreaterThanOrEqual, left, right), + "stringEqualOrdinal" => + new StringNode(QueryStringOperation.EqualsOrdinal, left, right), + "stringEqualOrdinalIgnoreCase" => + new StringNode(QueryStringOperation.EqualsOrdinalIgnoreCase, left, right), + "startsWithOrdinal" => + new StringNode(QueryStringOperation.StartsWithOrdinal, left, right), + "endsWithOrdinal" => + new StringNode(QueryStringOperation.EndsWithOrdinal, left, right), + "containsOrdinal" => + new StringNode(QueryStringOperation.ContainsOrdinal, left, right), _ => throw new JsonException("Query document names an unknown comparison."), }; - - private static QueryStringOperation ReadStringOperation(JsonElement element) => - element.GetString() switch - { - "equals" => QueryStringOperation.EqualsOrdinal, - "equalsIgnoreCase" => QueryStringOperation.EqualsOrdinalIgnoreCase, - "startsWith" => QueryStringOperation.StartsWithOrdinal, - "endsWith" => QueryStringOperation.EndsWithOrdinal, - "contains" => QueryStringOperation.ContainsOrdinal, - _ => throw new JsonException("Query document names an unknown string operation."), - }; + } private static QueryQuantifier ReadQuantifier(JsonElement element) => element.GetString() switch @@ -462,18 +464,18 @@ private static QueryQuantifier ReadQuantifier(JsonElement element) => }; private QueryConstant ReadConstant(JsonElement element) => - element.GetProperty("type").GetString() switch + element.GetProperty("kind").GetString() switch { "null" => new NullConstant(), "boolean" => new BooleanConstant(element.GetProperty("value").GetBoolean()), "int64" => new Int64Constant(element.GetProperty("value").GetInt64()), "string" => new StringConstant(ReadBoundedString(element.GetProperty("value"))), - "instant" => new InstantConstant(element.GetProperty("value").GetInt64()), + "instant" => new InstantConstant(element.GetProperty("unixSeconds").GetInt64()), "enum" => new EnumConstant( - ReadBoundedString(element.GetProperty("enumType"), "Enum type"), - ReadBoundedString(element.GetProperty("value"), "Enum value")), + ReadBoundedString(element.GetProperty("type"), "Enum type"), + ReadBoundedString(element.GetProperty("token"), "Enum value")), "typedId" => new TypedIdConstant( - ReadTarget(element.GetProperty("target")), + ReadTarget(element.GetProperty("type")), ReadBoundedString(element.GetProperty("value"), "Typed ID value")), _ => throw new JsonException("Query document names an unknown constant type."), }; diff --git a/src/LibTmux/PublicAPI.Unshipped.txt b/src/LibTmux/PublicAPI.Unshipped.txt index cfec304..09a13e9 100644 --- a/src/LibTmux/PublicAPI.Unshipped.txt +++ b/src/LibTmux/PublicAPI.Unshipped.txt @@ -1320,7 +1320,7 @@ LibTmux.WindowRotationDirection.Up = 0 -> LibTmux.WindowRotationDirection abstract LibTmux.Query.QueryConstant.$() -> LibTmux.Query.QueryConstant! abstract LibTmux.Query.QueryNode.$() -> LibTmux.Query.QueryNode! abstract LibTmux.TmuxEvent.$() -> LibTmux.TmuxEvent! -const LibTmux.Query.QueryDocument.CurrentSchema = "libtmux.query" -> string! +const LibTmux.Query.QueryDocument.CurrentSchema = "libtmux-query" -> string! const LibTmux.Query.QueryDocument.CurrentVersion = 1 -> int override LibTmux.AttachSessionRequest.Equals(object? obj) -> bool override LibTmux.AttachSessionRequest.GetHashCode() -> int diff --git a/src/LibTmux/Query/QueryDocument.cs b/src/LibTmux/Query/QueryDocument.cs index 697075b..b9e80dd 100644 --- a/src/LibTmux/Query/QueryDocument.cs +++ b/src/LibTmux/Query/QueryDocument.cs @@ -17,7 +17,7 @@ public sealed record QueryDocument( QueryNode Predicate) { /// The current wire schema identifier. - public const string CurrentSchema = "libtmux.query"; + public const string CurrentSchema = "libtmux-query"; /// The current wire schema version. public const int CurrentVersion = 1; diff --git a/tests/LibTmux.UnitTests/Query/QueryJsonTests.cs b/tests/LibTmux.UnitTests/Query/QueryJsonTests.cs index 095399a..03721c3 100644 --- a/tests/LibTmux.UnitTests/Query/QueryJsonTests.cs +++ b/tests/LibTmux.UnitTests/Query/QueryJsonTests.cs @@ -53,6 +53,20 @@ public void Round_trips_every_version_one_golden_byte_for_byte( Assert.DoesNotContain("\n", json, StringComparison.Ordinal); } + [Fact] + public void The_wire_matches_the_accepted_version_one_golden() + { + const string expected = + """ + {"schema":"libtmux-query","version":1,"target":"session","predicate":{"kind":"comparison","operator":"containsOrdinal","left":{"kind":"field","target":"session","wireName":"session_name"},"right":{"kind":"constant","value":{"kind":"string","value":"dev"}}}} + """; + QueryDocument document = + QueryEdgeParser.ParseNameContains(QueryTarget.Session, "dev"); + + Assert.Equal(expected, QueryJson.Serialize(document)); + Assert.Equal(document, QueryJson.Deserialize(expected)); + } + [Fact] public void Limits_may_tighten_the_frozen_ceilings_but_never_widen_them() { @@ -84,7 +98,7 @@ public void An_oversized_or_too_deep_document_is_refused() public void An_unknown_node_kind_is_refused_rather_than_guessed() { const string json = - """{"schema":"libtmux.query","version":1,"target":"session","predicate":{"kind":"telepathy"}}"""; + """{"schema":"libtmux-query","version":1,"target":"session","predicate":{"kind":"telepathy"}}"""; Assert.Throws(() => QueryJson.Deserialize(json)); } diff --git a/tests/LibTmux.UnitTests/Query/QueryJsonTrustBoundaryTests.cs b/tests/LibTmux.UnitTests/Query/QueryJsonTrustBoundaryTests.cs index da068b3..df888f3 100644 --- a/tests/LibTmux.UnitTests/Query/QueryJsonTrustBoundaryTests.cs +++ b/tests/LibTmux.UnitTests/Query/QueryJsonTrustBoundaryTests.cs @@ -11,13 +11,16 @@ namespace LibTmux.UnitTests.Query; /// public sealed class QueryJsonTrustBoundaryTests { - private static string Document(string predicate, string schema = "libtmux.query", int version = 1) => + private static string Document( + string predicate, + string schema = QueryDocument.CurrentSchema, + int version = 1) => $$""" {"schema":"{{schema}}","version":{{version}},"target":"session","predicate":{{predicate}}} """; private const string TrivialPredicate = - """{"kind":"constant","type":"boolean","value":true}"""; + """{"kind":"constant","value":{"kind":"boolean","value":true}}"""; [Fact] public void A_document_naming_another_schema_is_refused() @@ -43,7 +46,8 @@ public void A_document_naming_a_future_version_is_refused() public void A_string_longer_than_the_limit_is_refused_on_the_way_in() { string oversized = new('a', QueryJsonLimits.V1.MaximumStringLength + 1); - string json = Document($$"""{"kind":"constant","type":"string","value":"{{oversized}}"}"""); + string json = Document( + $$"""{"kind":"constant","value":{"kind":"string","value":"{{oversized}}" } }"""); Assert.Throws(() => QueryJson.Deserialize(json)); } @@ -54,7 +58,7 @@ public void A_pattern_longer_than_the_limit_is_refused_on_the_way_in() string oversized = new('a', QueryJsonLimits.V1.MaximumPatternLength + 1); string json = Document( $$""" - {"kind":"regex","input":{"kind":"field","target":"session","name":"session_name"}, + {"kind":"regex","input":{"kind":"field","target":"session","wireName":"session_name"}, "dialect":"dotnet","pattern":"{{oversized}}","semanticOptions":0} """); @@ -66,7 +70,7 @@ public void A_regex_dialect_this_library_cannot_evaluate_is_refused() { string json = Document( """ - {"kind":"regex","input":{"kind":"field","target":"session","name":"session_name"}, + {"kind":"regex","input":{"kind":"field","target":"session","wireName":"session_name"}, "dialect":"pcre","pattern":"^a","semanticOptions":0} """); @@ -80,7 +84,7 @@ public void Regex_options_outside_the_supported_set_are_refused() // RegexOptions.NonBacktracking, which this library's translation never emits. string json = Document( """ - {"kind":"regex","input":{"kind":"field","target":"session","name":"session_name"}, + {"kind":"regex","input":{"kind":"field","target":"session","wireName":"session_name"}, "dialect":"dotnet","pattern":"^a","semanticOptions":1024} """); @@ -93,22 +97,22 @@ public void An_unknown_quantifier_is_refused_rather_than_treated_as_all() string json = Document( """ {"kind":"quantifier","quantifier":"sometimes", - "relation":{"kind":"field","target":"session","name":"session_windows"}, - "predicate":{"kind":"constant","type":"boolean","value":true}} + "relation":{"kind":"field","target":"session","wireName":"session_windows"}, + "predicate":{"kind":"constant","value":{"kind":"boolean","value":true}}} """); Assert.Throws(() => QueryJson.Deserialize(json)); } [Theory] - [InlineData("string", "\"value\":null")] - [InlineData("enum", "\"enumType\":null,\"value\":\"Ready\"")] - [InlineData("enum", "\"enumType\":\"State\",\"value\":null")] - [InlineData("typedId", "\"target\":\"session\",\"value\":null")] - public void Null_constant_text_is_refused(string type, string members) + [InlineData("\"kind\":\"string\",\"value\":null")] + [InlineData("\"kind\":\"enum\",\"type\":null,\"token\":\"Ready\"")] + [InlineData("\"kind\":\"enum\",\"type\":\"State\",\"token\":null")] + [InlineData("\"kind\":\"typedId\",\"type\":\"session\",\"value\":null")] + public void Null_constant_text_is_refused(string members) { string json = Document( - $$"""{"kind":"constant","type":"{{type}}",{{members}}}"""); + $$"""{"kind":"constant","value":{ {{members}} } }"""); Assert.Throws(() => QueryJson.Deserialize(json)); } @@ -118,7 +122,7 @@ public void A_null_regex_dialect_is_refused() { string json = Document( """ - {"kind":"regex","input":{"kind":"field","target":"session","name":"session_name"}, + {"kind":"regex","input":{"kind":"field","target":"session","wireName":"session_name"}, "dialect":null,"pattern":"^a","semanticOptions":0} """); From 2c434c693ef3fc4bdfabf3ccfba8f2a976a441f8 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 06:26:22 -0500 Subject: [PATCH 013/129] Query(fix[regex]): Freeze culture semantics why: Regex translation and JSON accepted culture-dependent and execution-only modes even though the v1 schema and retained proof require invariant, portable semantics. what: - Give query regex semantics one internal owner shared by core translation and JSON - Require CultureInvariant and reject unsupported option bits on both wire directions - Preserve canonical JSON bytes with the selected encoder and retained regex golden --- .../QueryDocumentJsonConverter.cs | 20 +++++++----------- .../QueryJsonSerializerContext.cs | 7 +++++-- src/LibTmux/LibTmux.csproj | 1 + src/LibTmux/Query/QueryRegexSemantics.cs | 21 +++++++++++++++++++ src/LibTmux/Query/QueryTranslator.cs | 7 ++++++- .../LibTmux.UnitTests/Query/QueryJsonTests.cs | 17 +++++++++++++++ .../Query/QueryJsonTrustBoundaryTests.cs | 12 +++++++++++ .../Query/QuerySemanticsTests.cs | 20 ++++++++++++++++++ 8 files changed, 89 insertions(+), 16 deletions(-) create mode 100644 src/LibTmux/Query/QueryRegexSemantics.cs diff --git a/src/LibTmux.Query.Json/QueryDocumentJsonConverter.cs b/src/LibTmux.Query.Json/QueryDocumentJsonConverter.cs index 29b4fcd..dbef960 100644 --- a/src/LibTmux.Query.Json/QueryDocumentJsonConverter.cs +++ b/src/LibTmux.Query.Json/QueryDocumentJsonConverter.cs @@ -6,15 +6,6 @@ namespace LibTmux.Query.Json; internal static class QueryJsonWireRules { - internal const string RegexDialect = "dotnet"; - - internal const RegexOptions AllowedRegexOptions = - RegexOptions.None - | RegexOptions.IgnoreCase - | RegexOptions.Multiline - | RegexOptions.Singleline - | RegexOptions.CultureInvariant; - internal static int ScalarLength(string? value, string description) { if (value is null) @@ -46,7 +37,10 @@ internal static int ScalarLength(string? value, string description) internal static void ValidateRegex(RegexNode regex, QueryJsonLimits limits) { - if (!string.Equals(regex.Dialect, RegexDialect, StringComparison.Ordinal)) + if (!string.Equals( + regex.Dialect, + QueryRegexSemantics.Dialect, + StringComparison.Ordinal)) { throw new JsonException($"Regex dialect '{regex.Dialect}' is not supported."); } @@ -56,7 +50,7 @@ internal static void ValidateRegex(RegexNode regex, QueryJsonLimits limits) throw new JsonException("Regex pattern exceeds the maximum length."); } - if ((regex.SemanticOptions & ~AllowedRegexOptions) != 0) + if (!QueryRegexSemantics.IsSupported(regex.SemanticOptions)) { throw new JsonException("Regex names options this writer does not support."); } @@ -381,7 +375,7 @@ private static string ReadDialect(JsonElement element) { string dialect = element.GetString() ?? throw new JsonException("Regex names no dialect."); - return string.Equals(dialect, QueryJsonWireRules.RegexDialect, StringComparison.Ordinal) + return string.Equals(dialect, QueryRegexSemantics.Dialect, StringComparison.Ordinal) ? dialect : throw new JsonException($"Regex dialect '{dialect}' is not supported."); } @@ -406,7 +400,7 @@ private static System.Text.RegularExpressions.RegexOptions ReadRegexOptions( JsonElement element) { var options = (RegexOptions)element.GetInt32(); - return (options & ~QueryJsonWireRules.AllowedRegexOptions) == 0 + return QueryRegexSemantics.IsSupported(options) ? options : throw new JsonException("Regex names options this reader does not support."); } diff --git a/src/LibTmux.Query.Json/QueryJsonSerializerContext.cs b/src/LibTmux.Query.Json/QueryJsonSerializerContext.cs index f3844ee..68b734c 100644 --- a/src/LibTmux.Query.Json/QueryJsonSerializerContext.cs +++ b/src/LibTmux.Query.Json/QueryJsonSerializerContext.cs @@ -1,4 +1,5 @@ using System.Text; +using System.Text.Encodings.Web; using System.Text.Json; namespace LibTmux.Query.Json; @@ -25,7 +26,7 @@ public sealed record QueryJsonLimits( MaximumDepth: 32, MaximumNodes: 512, MaximumStringLength: 4096, - MaximumPatternLength: 1024, + MaximumPatternLength: QueryRegexSemantics.MaximumPatternLength, MaximumUtf8Bytes: 262144); internal QueryJsonLimits Clamp() @@ -66,7 +67,9 @@ public static string Serialize(QueryDocument document) { ArgumentNullException.ThrowIfNull(document); var buffer = new MemoryStream(); - using (var writer = new Utf8JsonWriter(buffer)) + using (var writer = new Utf8JsonWriter( + buffer, + new JsonWriterOptions { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping })) { new QueryDocumentJsonConverter(QueryJsonLimits.V1) .Write(writer, document, new JsonSerializerOptions()); diff --git a/src/LibTmux/LibTmux.csproj b/src/LibTmux/LibTmux.csproj index 56b3907..0759113 100644 --- a/src/LibTmux/LibTmux.csproj +++ b/src/LibTmux/LibTmux.csproj @@ -55,6 +55,7 @@ + diff --git a/src/LibTmux/Query/QueryRegexSemantics.cs b/src/LibTmux/Query/QueryRegexSemantics.cs new file mode 100644 index 0000000..bf130e1 --- /dev/null +++ b/src/LibTmux/Query/QueryRegexSemantics.cs @@ -0,0 +1,21 @@ +using System.Text.RegularExpressions; + +namespace LibTmux.Query; + +internal static class QueryRegexSemantics +{ + internal const string Dialect = "dotnet"; + internal const int MaximumPatternLength = 1024; + + internal const RegexOptions AllowedOptions = + RegexOptions.IgnoreCase + | RegexOptions.Multiline + | RegexOptions.ExplicitCapture + | RegexOptions.Singleline + | RegexOptions.IgnorePatternWhitespace + | RegexOptions.CultureInvariant; + + internal static bool IsSupported(RegexOptions options) => + (options & ~AllowedOptions) == 0 + && (options & RegexOptions.CultureInvariant) != 0; +} diff --git a/src/LibTmux/Query/QueryTranslator.cs b/src/LibTmux/Query/QueryTranslator.cs index 7e16a1a..4731f55 100644 --- a/src/LibTmux/Query/QueryTranslator.cs +++ b/src/LibTmux/Query/QueryTranslator.cs @@ -166,9 +166,14 @@ private static RegexNode TranslateRegex( options = parsed; } + if (!QueryRegexSemantics.IsSupported(options)) + { + throw Unsupported(call); + } + return new RegexNode( TranslateOperand(call.Arguments[0], parameter), - "dotnet", + QueryRegexSemantics.Dialect, (string)pattern!, options); } diff --git a/tests/LibTmux.UnitTests/Query/QueryJsonTests.cs b/tests/LibTmux.UnitTests/Query/QueryJsonTests.cs index 03721c3..c1aa13d 100644 --- a/tests/LibTmux.UnitTests/Query/QueryJsonTests.cs +++ b/tests/LibTmux.UnitTests/Query/QueryJsonTests.cs @@ -67,6 +67,23 @@ public void The_wire_matches_the_accepted_version_one_golden() Assert.Equal(document, QueryJson.Deserialize(expected)); } + [Fact] + public void The_wire_matches_the_retained_regex_golden() + { + const string expected = + """ + {"schema":"libtmux-query","version":1,"target":"session","predicate":{"kind":"regex","input":{"kind":"field","target":"session","wireName":"session_name"},"dialect":"dotnet","pattern":"^prod-[0-9]+$","semanticOptions":512}} + """; + QueryDocument document = Document(new RegexNode( + SessionName, + "dotnet", + "^prod-[0-9]+$", + RegexOptions.CultureInvariant)); + + Assert.Equal(expected, QueryJson.Serialize(document)); + Assert.Equal(document, QueryJson.Deserialize(expected)); + } + [Fact] public void Limits_may_tighten_the_frozen_ceilings_but_never_widen_them() { diff --git a/tests/LibTmux.UnitTests/Query/QueryJsonTrustBoundaryTests.cs b/tests/LibTmux.UnitTests/Query/QueryJsonTrustBoundaryTests.cs index df888f3..72bbdfa 100644 --- a/tests/LibTmux.UnitTests/Query/QueryJsonTrustBoundaryTests.cs +++ b/tests/LibTmux.UnitTests/Query/QueryJsonTrustBoundaryTests.cs @@ -91,6 +91,18 @@ public void Regex_options_outside_the_supported_set_are_refused() Assert.Throws(() => QueryJson.Deserialize(json)); } + [Fact] + public void Regex_options_without_culture_invariance_are_refused() + { + string json = Document( + """ + {"kind":"regex","input":{"kind":"field","target":"session","wireName":"session_name"}, + "dialect":"dotnet","pattern":"^a","semanticOptions":0} + """); + + Assert.Throws(() => QueryJson.Deserialize(json)); + } + [Fact] public void An_unknown_quantifier_is_refused_rather_than_treated_as_all() { diff --git a/tests/LibTmux.UnitTests/Query/QuerySemanticsTests.cs b/tests/LibTmux.UnitTests/Query/QuerySemanticsTests.cs index 669c896..96f6a72 100644 --- a/tests/LibTmux.UnitTests/Query/QuerySemanticsTests.cs +++ b/tests/LibTmux.UnitTests/Query/QuerySemanticsTests.cs @@ -1,3 +1,4 @@ +using System.Text.RegularExpressions; using LibTmux.Query; namespace LibTmux.UnitTests.Query; @@ -123,6 +124,25 @@ public void Translation_refuses_an_unsupported_node_rather_than_evaluating_it() () => QueryExtensions.Translate(row => row.SessionName.Trim() == "X")); } + [Fact] + public void Regex_translation_requires_explicit_culture_invariance() + { + Assert.Throws( + () => QueryExtensions.Translate( + row => Regex.IsMatch(row.SessionName, "^build", RegexOptions.IgnoreCase))); + + QueryDocument document = QueryExtensions.Translate( + row => Regex.IsMatch( + row.SessionName, + "^build", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)); + RegexNode regex = Assert.IsType(document.Predicate); + + Assert.Equal( + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant, + regex.SemanticOptions); + } + [Fact] public void The_legacy_name_lookup_is_ordinal_and_target_scoped() { From bea27778753816cb84282f05cedf08d50c6fa088 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 06:29:24 -0500 Subject: [PATCH 014/129] QueryJson(fix[grammar]): Enforce the closed wire shape why: Deserialization ignored unknown and duplicate members despite the v1 schema forbidding them, and its parser depth rejected documents the writer allowed. what: - Validate exact envelope, node, and constant member sets - Reject duplicate properties instead of accepting last-value ambiguity - Separate parser nesting from the declared logical query depth --- .../QueryDocumentJsonConverter.cs | 89 ++++++++++++++++++- .../QueryJsonSerializerContext.cs | 5 +- .../LibTmux.UnitTests/Query/QueryJsonTests.cs | 16 ++++ .../Query/QueryJsonTrustBoundaryTests.cs | 21 +++++ 4 files changed, 127 insertions(+), 4 deletions(-) diff --git a/src/LibTmux.Query.Json/QueryDocumentJsonConverter.cs b/src/LibTmux.Query.Json/QueryDocumentJsonConverter.cs index dbef960..0316031 100644 --- a/src/LibTmux.Query.Json/QueryDocumentJsonConverter.cs +++ b/src/LibTmux.Query.Json/QueryDocumentJsonConverter.cs @@ -6,6 +6,83 @@ namespace LibTmux.Query.Json; internal static class QueryJsonWireRules { + private static readonly string[] EnvelopeProperties = + ["schema", "version", "target", "predicate"]; + private static readonly string[] FieldProperties = ["kind", "target", "wireName"]; + private static readonly string[] ConstantNodeProperties = ["kind", "value"]; + private static readonly string[] OperandsProperties = ["kind", "operands"]; + private static readonly string[] NotProperties = ["kind", "operand"]; + private static readonly string[] ComparisonProperties = + ["kind", "operator", "left", "right"]; + private static readonly string[] QuantifierProperties = + ["kind", "quantifier", "relation", "predicate"]; + private static readonly string[] RegexProperties = + ["kind", "input", "dialect", "pattern", "semanticOptions"]; + private static readonly string[] KindProperties = ["kind"]; + private static readonly string[] ValueProperties = ["kind", "value"]; + private static readonly string[] TypedIdProperties = ["kind", "type", "value"]; + private static readonly string[] EnumProperties = ["kind", "type", "token"]; + private static readonly string[] InstantProperties = ["kind", "unixSeconds"]; + + internal static void ValidateEnvelope(JsonElement element) => + ValidateProperties(element, EnvelopeProperties, "query envelope"); + + internal static void ValidateNode(JsonElement element, string? kind) + { + string[]? allowed = kind switch + { + "field" => FieldProperties, + "constant" => ConstantNodeProperties, + "and" or "or" => OperandsProperties, + "not" => NotProperties, + "comparison" => ComparisonProperties, + "quantifier" => QuantifierProperties, + "regex" => RegexProperties, + _ => null, + }; + if (allowed is not null) + { + ValidateProperties(element, allowed, $"{kind} node"); + } + } + + internal static void ValidateConstant(JsonElement element, string? kind) + { + string[]? allowed = kind switch + { + "null" => KindProperties, + "boolean" or "int64" or "string" => ValueProperties, + "typedId" => TypedIdProperties, + "enum" => EnumProperties, + "instant" => InstantProperties, + _ => null, + }; + if (allowed is not null) + { + ValidateProperties(element, allowed, $"{kind} constant"); + } + } + + private static void ValidateProperties( + JsonElement element, + IReadOnlyList allowed, + string description) + { + var seen = new HashSet(StringComparer.Ordinal); + foreach (JsonProperty property in element.EnumerateObject()) + { + if (!allowed.Contains(property.Name, StringComparer.Ordinal)) + { + throw new JsonException($"Unknown member in {description}."); + } + + if (!seen.Add(property.Name)) + { + throw new JsonException($"Duplicate member in {description}."); + } + } + } + internal static int ScalarLength(string? value, string description) { if (value is null) @@ -342,7 +419,9 @@ internal QueryNode ReadNode(JsonElement element, int depth) throw new JsonException("Query document exceeds the maximum node count."); } - return element.GetProperty("kind").GetString() switch + string? kind = element.GetProperty("kind").GetString(); + QueryJsonWireRules.ValidateNode(element, kind); + return kind switch { "and" => new AndNode([.. ReadOperands(element, depth)]), "or" => new OrNode([.. ReadOperands(element, depth)]), @@ -457,8 +536,11 @@ private static QueryQuantifier ReadQuantifier(JsonElement element) => _ => throw new JsonException("Query document names an unknown quantifier."), }; - private QueryConstant ReadConstant(JsonElement element) => - element.GetProperty("kind").GetString() switch + private QueryConstant ReadConstant(JsonElement element) + { + string? kind = element.GetProperty("kind").GetString(); + QueryJsonWireRules.ValidateConstant(element, kind); + return kind switch { "null" => new NullConstant(), "boolean" => new BooleanConstant(element.GetProperty("value").GetBoolean()), @@ -473,6 +555,7 @@ private QueryConstant ReadConstant(JsonElement element) => ReadBoundedString(element.GetProperty("value"), "Typed ID value")), _ => throw new JsonException("Query document names an unknown constant type."), }; + } private IEnumerable ReadOperands(JsonElement element, int depth) { diff --git a/src/LibTmux.Query.Json/QueryJsonSerializerContext.cs b/src/LibTmux.Query.Json/QueryJsonSerializerContext.cs index 68b734c..ac2a145 100644 --- a/src/LibTmux.Query.Json/QueryJsonSerializerContext.cs +++ b/src/LibTmux.Query.Json/QueryJsonSerializerContext.cs @@ -100,10 +100,11 @@ public static QueryDocument Deserialize(string json, QueryJsonLimits? limits = n using JsonDocument parsed = JsonDocument.Parse( json, - new JsonDocumentOptions { MaxDepth = bounds.MaximumDepth }); + new JsonDocumentOptions { MaxDepth = ParserDepth(bounds.MaximumDepth) }); try { JsonElement root = parsed.RootElement; + QueryJsonWireRules.ValidateEnvelope(root); // Schema and version must be checked before anything else is read, or // a v2 payload gets silently parsed under v1 rules. @@ -139,4 +140,6 @@ or InvalidOperationException throw new JsonException("Query document does not match the v1 wire form.", exception); } } + + private static int ParserDepth(int queryDepth) => (queryDepth * 2) + 4; } diff --git a/tests/LibTmux.UnitTests/Query/QueryJsonTests.cs b/tests/LibTmux.UnitTests/Query/QueryJsonTests.cs index c1aa13d..34e5090 100644 --- a/tests/LibTmux.UnitTests/Query/QueryJsonTests.cs +++ b/tests/LibTmux.UnitTests/Query/QueryJsonTests.cs @@ -111,6 +111,22 @@ public void An_oversized_or_too_deep_document_is_refused() () => QueryJson.Deserialize(json, QueryJsonLimits.V1 with { MaximumNodes = 1 })); } + [Fact] + public void A_document_at_the_maximum_logical_depth_round_trips() + { + QueryNode predicate = True; + for (int depth = 1; depth < QueryJsonLimits.V1.MaximumDepth; depth++) + { + predicate = new NotNode(predicate); + } + + QueryDocument document = Document(predicate); + + Assert.Equal(document, QueryJson.Deserialize(QueryJson.Serialize(document))); + Assert.Throws( + () => QueryJson.Serialize(Document(new NotNode(predicate)))); + } + [Fact] public void An_unknown_node_kind_is_refused_rather_than_guessed() { diff --git a/tests/LibTmux.UnitTests/Query/QueryJsonTrustBoundaryTests.cs b/tests/LibTmux.UnitTests/Query/QueryJsonTrustBoundaryTests.cs index 72bbdfa..6c57292 100644 --- a/tests/LibTmux.UnitTests/Query/QueryJsonTrustBoundaryTests.cs +++ b/tests/LibTmux.UnitTests/Query/QueryJsonTrustBoundaryTests.cs @@ -149,6 +149,27 @@ public void A_structurally_malformed_document_reports_a_json_error() Assert.Throws(() => QueryJson.Deserialize(json)); } + [Theory] + [InlineData( + "{\"kind\":\"constant\",\"value\":{\"kind\":\"boolean\",\"value\":true},\"extra\":false}")] + [InlineData( + "{\"kind\":\"constant\",\"kind\":\"constant\",\"value\":{\"kind\":\"boolean\",\"value\":true}}")] + [InlineData( + "{\"kind\":\"constant\",\"value\":{\"kind\":\"boolean\",\"value\":true,\"extra\":false}}")] + public void Unknown_or_duplicate_node_members_are_refused(string predicate) => + Assert.Throws(() => QueryJson.Deserialize(Document(predicate))); + + [Fact] + public void An_unknown_envelope_member_is_refused() + { + const string json = + """ + {"schema":"libtmux-query","version":1,"target":"session","predicate":{"kind":"constant","value":{"kind":"boolean","value":true}},"extra":false} + """; + + Assert.Throws(() => QueryJson.Deserialize(json)); + } + [Fact] public void A_field_outside_the_catalog_cannot_be_read_from_an_element() { From c83d3cccfec3d790d323fb53572227541ff6a657 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 06:38:13 -0500 Subject: [PATCH 015/129] Query(fix[semantics]): Validate the closed AST why: The production catalog lost field value kinds, allowing documents whose fields, targets, constants, and predicates contradicted the shipped schema and changed meaning across consumers. what: - Restore value kinds to the generated field catalog - Validate every translated, compiled, serialized, and deserialized document in the core - Interpret schema-valid Boolean field predicates and reject type-changing projections --- .../FieldCatalogGenerator.cs | 48 ++-- .../QueryDocumentJsonConverter.cs | 32 +-- .../QueryJsonSerializerContext.cs | 7 +- src/LibTmux/Query/QueryDocumentValidator.cs | 209 ++++++++++++++++++ src/LibTmux/Query/QueryInterpreter.cs | 9 + src/LibTmux/Query/QueryTextSemantics.cs | 35 +++ src/LibTmux/Query/QueryTranslator.cs | 4 +- src/LibTmux/Query/QueryValueKind.cs | 12 + .../LibTmux.UnitTests/Query/QueryJsonTests.cs | 6 +- .../Query/QueryJsonTrustBoundaryTests.cs | 13 ++ .../Query/QuerySemanticsTests.cs | 46 +++- 11 files changed, 369 insertions(+), 52 deletions(-) create mode 100644 src/LibTmux/Query/QueryDocumentValidator.cs create mode 100644 src/LibTmux/Query/QueryTextSemantics.cs create mode 100644 src/LibTmux/Query/QueryValueKind.cs diff --git a/src/LibTmux.Generators/FieldCatalogGenerator.cs b/src/LibTmux.Generators/FieldCatalogGenerator.cs index 1c14625..b252910 100644 --- a/src/LibTmux.Generators/FieldCatalogGenerator.cs +++ b/src/LibTmux.Generators/FieldCatalogGenerator.cs @@ -19,21 +19,21 @@ public sealed class FieldCatalogGenerator : IIncrementalGenerator /// not systematic (client_controlIsControlClient, and two /// fields have no property at all). /// - private static readonly (string WireName, string Target, bool Relation, string? Property)[] + private static readonly (string WireName, string Target, string Kind, string? Property)[] Fields = { - ("client_control", "Client", false, "IsControlClient"), - ("client_id", "Client", false, null), - ("client_name", "Client", false, "Name"), - ("pane_command", "Pane", false, null), - ("pane_id", "Pane", false, "Id"), - ("session_attached", "Session", false, "Attached"), - ("session_id", "Session", false, "Id"), - ("session_name", "Session", false, "Name"), - ("session_windows", "Session", true, "Windows"), - ("window_id", "Window", false, "Id"), - ("window_name", "Window", false, "Name"), - ("window_panes", "Window", true, "Panes"), + ("client_control", "Client", "Boolean", "IsControlClient"), + ("client_id", "Client", "TypedId", null), + ("client_name", "Client", "String", "Name"), + ("pane_command", "Pane", "String", null), + ("pane_id", "Pane", "TypedId", "Id"), + ("session_attached", "Session", "Boolean", "Attached"), + ("session_id", "Session", "TypedId", "Id"), + ("session_name", "Session", "String", "Name"), + ("session_windows", "Session", "Relation", "Windows"), + ("window_id", "Window", "TypedId", "Id"), + ("window_name", "Window", "String", "Name"), + ("window_panes", "Window", "Relation", "Panes"), }; /// @@ -56,9 +56,9 @@ private static string Render() source.AppendLine( " internal static bool IsRelation(string wireName) => wireName switch"); source.AppendLine(" {"); - foreach ((string wireName, _, bool relation, _) in Fields) + foreach ((string wireName, _, string kind, _) in Fields) { - if (relation) + if (kind == "Relation") { source.AppendLine($" \"{wireName}\" => true,"); } @@ -85,6 +85,24 @@ private static string Render() source.AppendLine(" }"); source.AppendLine(" }"); source.AppendLine(); + source.AppendLine( + " internal static bool TryGetKind(string wireName, out QueryValueKind kind)"); + source.AppendLine(" {"); + source.AppendLine(" switch (wireName)"); + source.AppendLine(" {"); + foreach ((string wireName, _, string kind, _) in Fields) + { + source.AppendLine($" case \"{wireName}\":"); + source.AppendLine($" kind = QueryValueKind.{kind};"); + source.AppendLine(" return true;"); + } + + source.AppendLine(" default:"); + source.AppendLine(" kind = default;"); + source.AppendLine(" return false;"); + source.AppendLine(" }"); + source.AppendLine(" }"); + source.AppendLine(); source.AppendLine( " internal static bool TryGetWireName(string owner, string property, " + "out string wireName)"); diff --git a/src/LibTmux.Query.Json/QueryDocumentJsonConverter.cs b/src/LibTmux.Query.Json/QueryDocumentJsonConverter.cs index 0316031..19cc5cf 100644 --- a/src/LibTmux.Query.Json/QueryDocumentJsonConverter.cs +++ b/src/LibTmux.Query.Json/QueryDocumentJsonConverter.cs @@ -85,28 +85,9 @@ private static void ValidateProperties( internal static int ScalarLength(string? value, string description) { - if (value is null) + if (!QueryTextSemantics.TryCountScalars(value, out int scalars)) { - throw new JsonException($"{description} is null."); - } - - int scalars = 0; - for (int index = 0; index < value.Length; index++, scalars++) - { - char character = value[index]; - if (char.IsHighSurrogate(character)) - { - if (index + 1 >= value.Length || !char.IsLowSurrogate(value[index + 1])) - { - throw new JsonException($"{description} contains an unpaired surrogate."); - } - - index++; - } - else if (char.IsLowSurrogate(character)) - { - throw new JsonException($"{description} contains an unpaired surrogate."); - } + throw new JsonException($"{description} is null or contains invalid Unicode."); } return scalars; @@ -160,6 +141,15 @@ public override void Write( { ArgumentNullException.ThrowIfNull(writer); ArgumentNullException.ThrowIfNull(value); + try + { + QueryDocumentValidator.Validate(value); + } + catch (UnsupportedQueryExpressionException exception) + { + throw new JsonException(exception.Message, exception); + } + if (!string.Equals(value.Schema, QueryDocument.CurrentSchema, StringComparison.Ordinal)) { throw new JsonException( diff --git a/src/LibTmux.Query.Json/QueryJsonSerializerContext.cs b/src/LibTmux.Query.Json/QueryJsonSerializerContext.cs index ac2a145..31b53f0 100644 --- a/src/LibTmux.Query.Json/QueryJsonSerializerContext.cs +++ b/src/LibTmux.Query.Json/QueryJsonSerializerContext.cs @@ -125,17 +125,20 @@ public static QueryDocument Deserialize(string json, QueryJsonLimits? limits = n } var reader = new QueryDocumentJsonReader(bounds); - return new QueryDocument( + QueryDocument document = new( schema, version, QueryDocumentJsonReader.ReadTarget(root.GetProperty("target")), reader.ReadNode(root.GetProperty("predicate"), depth: 1)); + QueryDocumentValidator.Validate(document); + return document; } catch (Exception exception) when ( exception is KeyNotFoundException or InvalidCastException or InvalidOperationException - or FormatException) + or FormatException + or UnsupportedQueryExpressionException) { throw new JsonException("Query document does not match the v1 wire form.", exception); } diff --git a/src/LibTmux/Query/QueryDocumentValidator.cs b/src/LibTmux/Query/QueryDocumentValidator.cs new file mode 100644 index 0000000..1668f55 --- /dev/null +++ b/src/LibTmux/Query/QueryDocumentValidator.cs @@ -0,0 +1,209 @@ +namespace LibTmux.Query; + +internal static class QueryDocumentValidator +{ + internal static void Validate(QueryDocument document) + { + ArgumentNullException.ThrowIfNull(document); + if (!string.Equals( + document.Schema, + QueryDocument.CurrentSchema, + StringComparison.Ordinal) + || document.Version != QueryDocument.CurrentVersion) + { + throw Unsupported("Query document names an unknown schema or version."); + } + + _ = Target(document.Target); + ValidatePredicate(document.Predicate, document.Target); + } + + private static void ValidatePredicate(QueryNode? node, QueryTarget expectedTarget) + { + switch (node) + { + case FieldNode field when ResolveField(field, expectedTarget) == QueryValueKind.Boolean: + case ConstantNode { Value: BooleanConstant }: + return; + case AndNode and: + ValidateOperands(and.Operands, expectedTarget); + return; + case OrNode or: + ValidateOperands(or.Operands, expectedTarget); + return; + case NotNode not: + ValidatePredicate(not.Operand, expectedTarget); + return; + case ComparisonNode comparison: + ValidateComparison(comparison, expectedTarget); + return; + case StringNode text: + ValidateString(text, expectedTarget); + return; + case RegexNode regex: + ValidateRegex(regex, expectedTarget); + return; + case QuantifierNode quantifier: + ValidateQuantifier(quantifier, expectedTarget); + return; + case null: + throw Unsupported("Query predicate is null."); + default: + throw Unsupported($"Node '{node.GetType().Name}' is not a Boolean predicate."); + } + } + + private static void ValidateOperands( + IReadOnlyList operands, + QueryTarget expectedTarget) + { + foreach (QueryNode operand in operands) + { + ValidatePredicate(operand, expectedTarget); + } + } + + private static void ValidateComparison( + ComparisonNode comparison, + QueryTarget expectedTarget) + { + if (comparison.Left is not FieldNode field + || comparison.Right is not ConstantNode constant) + { + throw Unsupported("Comparison operands must be a field and a constant."); + } + + QueryValueKind kind = ResolveField(field, expectedTarget); + ValidateConstant(kind, field.Target, constant.Value); + switch (comparison.Operator) + { + case QueryComparison.Equal: + case QueryComparison.NotEqual: + return; + case QueryComparison.LessThan: + case QueryComparison.LessThanOrEqual: + case QueryComparison.GreaterThan: + case QueryComparison.GreaterThanOrEqual: + if ((kind == QueryValueKind.Int64 && constant.Value is Int64Constant) + || (kind == QueryValueKind.Instant + && constant.Value is InstantConstant)) + { + return; + } + + throw Unsupported("Ordered comparison requires an integer or instant field."); + default: + throw Unsupported("Query document names an unknown comparison."); + } + } + + private static void ValidateString(StringNode text, QueryTarget expectedTarget) + { + if (text.Left is not FieldNode field + || text.Right is not ConstantNode { Value: StringConstant constant } + || ResolveField(field, expectedTarget) != QueryValueKind.String + || !QueryTextSemantics.TryCountScalars(constant.Value, out _)) + { + throw Unsupported("String comparison requires a string field and constant."); + } + + _ = text.Operator switch + { + QueryStringOperation.EqualsOrdinal => true, + QueryStringOperation.EqualsOrdinalIgnoreCase => true, + QueryStringOperation.StartsWithOrdinal => true, + QueryStringOperation.EndsWithOrdinal => true, + QueryStringOperation.ContainsOrdinal => true, + _ => throw Unsupported("Query document names an unknown string operation."), + }; + } + + private static void ValidateRegex(RegexNode regex, QueryTarget expectedTarget) + { + if (regex.Input is not FieldNode field + || ResolveField(field, expectedTarget) != QueryValueKind.String + || !string.Equals( + regex.Dialect, + QueryRegexSemantics.Dialect, + StringComparison.Ordinal) + || !QueryTextSemantics.TryCountScalars(regex.Pattern, out int length) + || length > QueryRegexSemantics.MaximumPatternLength + || !QueryRegexSemantics.IsSupported(regex.SemanticOptions)) + { + throw Unsupported("Regex does not match the query wire semantics."); + } + } + + private static void ValidateQuantifier( + QuantifierNode quantifier, + QueryTarget expectedTarget) + { + if (quantifier.Quantifier is not QueryQuantifier.Any and not QueryQuantifier.All + || ResolveField(quantifier.Relation, expectedTarget) != QueryValueKind.Relation) + { + throw Unsupported("Quantifier does not name a supported relation."); + } + + QueryTarget childTarget = quantifier.Relation.WireName switch + { + "session_windows" => QueryTarget.Window, + "window_panes" => QueryTarget.Pane, + _ => throw Unsupported("Quantifier does not name a supported relation."), + }; + ValidatePredicate(quantifier.Predicate, childTarget); + } + + private static QueryValueKind ResolveField(FieldNode field, QueryTarget expectedTarget) + { + if (field.Target != expectedTarget + || !QueryFieldCatalog.TryGetTarget(field.WireName, out QueryTarget target) + || target != field.Target + || !QueryFieldCatalog.TryGetKind(field.WireName, out QueryValueKind kind)) + { + throw Unsupported($"Field '{field.WireName}' is outside the query catalog."); + } + + return kind; + } + + private static void ValidateConstant( + QueryValueKind kind, + QueryTarget target, + QueryConstant? constant) + { + bool compatible = constant switch + { + NullConstant => kind != QueryValueKind.Relation, + BooleanConstant => kind == QueryValueKind.Boolean, + Int64Constant => kind == QueryValueKind.Int64, + StringConstant text => + kind == QueryValueKind.String + && QueryTextSemantics.TryCountScalars(text.Value, out _), + TypedIdConstant id => + kind == QueryValueKind.TypedId + && id.Target == target + && QueryTextSemantics.TryCountScalars(id.Value, out _), + EnumConstant member => + kind == QueryValueKind.Enum + && QueryTextSemantics.TryCountScalars(member.Type, out _) + && QueryTextSemantics.TryCountScalars(member.Value, out _), + InstantConstant => kind == QueryValueKind.Instant, + _ => false, + }; + if (!compatible) + { + throw Unsupported("Constant does not match its field."); + } + } + + private static QueryTarget Target(QueryTarget target) => target switch + { + QueryTarget.Session => target, + QueryTarget.Window => target, + QueryTarget.Pane => target, + QueryTarget.Client => target, + _ => throw Unsupported("Query document names an unknown target."), + }; + + private static UnsupportedQueryExpressionException Unsupported(string message) => new(message); +} diff --git a/src/LibTmux/Query/QueryInterpreter.cs b/src/LibTmux/Query/QueryInterpreter.cs index d8c5b3b..a534b6a 100644 --- a/src/LibTmux/Query/QueryInterpreter.cs +++ b/src/LibTmux/Query/QueryInterpreter.cs @@ -25,6 +25,7 @@ internal static class QueryInterpreter internal static Func Compile(QueryDocument document) { ArgumentNullException.ThrowIfNull(document); + QueryDocumentValidator.Validate(document); return element => Evaluate(document.Predicate, element!); } @@ -41,6 +42,8 @@ internal static Func Compile(QueryDocument document) regex.SemanticOptions, RegexBudget), QuantifierNode quantifier => Quantify(quantifier, element), + FieldNode field => ReadBoolean(field, element), + ConstantNode { Value: BooleanConstant boolean } => boolean.Value, _ => throw new UnsupportedQueryExpressionException( $"Node '{node.GetType().Name}' has no interpretation."), }; @@ -122,6 +125,12 @@ private static bool CompareText(StringNode text, object element) ? Convert.ToString(value, CultureInfo.InvariantCulture) : null; + private static bool ReadBoolean(FieldNode field, object element) => + Read(field, element) is bool value + ? value + : throw new UnsupportedQueryExpressionException( + $"Field '{field.WireName}' did not produce a Boolean value."); + private static object? Read(QueryNode node, object element) => node switch { ConstantNode constant => Literal(constant.Value), diff --git a/src/LibTmux/Query/QueryTextSemantics.cs b/src/LibTmux/Query/QueryTextSemantics.cs new file mode 100644 index 0000000..16ce614 --- /dev/null +++ b/src/LibTmux/Query/QueryTextSemantics.cs @@ -0,0 +1,35 @@ +using System.Buffers; +using System.Text; + +namespace LibTmux.Query; + +internal static class QueryTextSemantics +{ + internal static bool TryCountScalars(string? value, out int count) + { + count = 0; + if (value is null) + { + return false; + } + + ReadOnlySpan remaining = value; + while (!remaining.IsEmpty) + { + OperationStatus status = Rune.DecodeFromUtf16( + remaining, + out _, + out int consumed); + if (status != OperationStatus.Done) + { + count = 0; + return false; + } + + remaining = remaining[consumed..]; + count++; + } + + return true; + } +} diff --git a/src/LibTmux/Query/QueryTranslator.cs b/src/LibTmux/Query/QueryTranslator.cs index 4731f55..497bbf9 100644 --- a/src/LibTmux/Query/QueryTranslator.cs +++ b/src/LibTmux/Query/QueryTranslator.cs @@ -24,11 +24,13 @@ internal static QueryDocument Translate(Expression> predicate) ArgumentNullException.ThrowIfNull(predicate); ParameterExpression parameter = predicate.Parameters[0]; QueryNode node = TranslateNode(predicate.Body, parameter); - return new QueryDocument( + QueryDocument document = new( QueryDocument.CurrentSchema, QueryDocument.CurrentVersion, TargetOf(node), node); + QueryDocumentValidator.Validate(document); + return document; } private static QueryNode TranslateNode(Expression body, ParameterExpression parameter) => diff --git a/src/LibTmux/Query/QueryValueKind.cs b/src/LibTmux/Query/QueryValueKind.cs new file mode 100644 index 0000000..d4a6d7c --- /dev/null +++ b/src/LibTmux/Query/QueryValueKind.cs @@ -0,0 +1,12 @@ +namespace LibTmux.Query; + +internal enum QueryValueKind +{ + Boolean, + Int64, + String, + TypedId, + Enum, + Instant, + Relation, +} diff --git a/tests/LibTmux.UnitTests/Query/QueryJsonTests.cs b/tests/LibTmux.UnitTests/Query/QueryJsonTests.cs index 34e5090..62131b5 100644 --- a/tests/LibTmux.UnitTests/Query/QueryJsonTests.cs +++ b/tests/LibTmux.UnitTests/Query/QueryJsonTests.cs @@ -7,7 +7,7 @@ namespace LibTmux.UnitTests.Query; public sealed class QueryJsonTests { - private sealed record Row(string SessionName, long SessionWindows); + private sealed record Row(string SessionName, bool SessionAttached); private static readonly FieldNode SessionName = new(QueryTarget.Session, "session_name"); @@ -21,7 +21,7 @@ private sealed record Row(string SessionName, long SessionWindows); { "string-and-comparison", QueryExtensions.Translate( - row => row.SessionName.StartsWith("dev") && row.SessionWindows > 1) + row => row.SessionName.StartsWith("dev") && row.SessionAttached) }, { "negated-contains", @@ -30,7 +30,7 @@ private sealed record Row(string SessionName, long SessionWindows); { "disjunction", QueryExtensions.Translate( - row => row.SessionName == "a" || row.SessionWindows <= 3) + row => row.SessionName == "a" || row.SessionAttached) }, { "legacy-name-contains", QueryEdgeParser.ParseNameContains(QueryTarget.Window, "log") }, }; diff --git a/tests/LibTmux.UnitTests/Query/QueryJsonTrustBoundaryTests.cs b/tests/LibTmux.UnitTests/Query/QueryJsonTrustBoundaryTests.cs index 6c57292..edb314f 100644 --- a/tests/LibTmux.UnitTests/Query/QueryJsonTrustBoundaryTests.cs +++ b/tests/LibTmux.UnitTests/Query/QueryJsonTrustBoundaryTests.cs @@ -191,5 +191,18 @@ public void A_field_outside_the_catalog_cannot_be_read_from_an_element() Assert.Contains("connection", failure.Message, StringComparison.Ordinal); } + [Fact] + public void A_field_outside_the_catalog_is_refused_while_deserializing() + { + string json = Document( + """ + {"kind":"comparison","operator":"stringEqualOrdinal", + "left":{"kind":"field","target":"session","wireName":"connection"}, + "right":{"kind":"constant","value":{"kind":"string","value":"anything"}}} + """); + + Assert.Throws(() => QueryJson.Deserialize(json)); + } + private sealed record SessionRow(string SessionName, bool SessionAttached); } diff --git a/tests/LibTmux.UnitTests/Query/QuerySemanticsTests.cs b/tests/LibTmux.UnitTests/Query/QuerySemanticsTests.cs index 96f6a72..de47e26 100644 --- a/tests/LibTmux.UnitTests/Query/QuerySemanticsTests.cs +++ b/tests/LibTmux.UnitTests/Query/QuerySemanticsTests.cs @@ -5,7 +5,9 @@ namespace LibTmux.UnitTests.Query; public sealed class QuerySemanticsTests { - private sealed record Row(string SessionName, long SessionWindows); + private sealed record Row(string SessionName, bool SessionAttached); + + private sealed record MismatchedRow(string SessionName, long SessionWindows); [Fact] public void An_entity_translates_through_the_name_tmux_uses_for_the_field() @@ -72,7 +74,7 @@ public void A_property_outside_the_catalog_still_refuses_to_translate() public void Matching_translates_and_interprets_the_canonical_AST() { QueryDocument document = QueryExtensions.Translate( - row => row.SessionName.StartsWith("dev") && row.SessionWindows > 1); + row => row.SessionName.StartsWith("dev") && row.SessionAttached); Assert.Equal(QueryDocument.CurrentSchema, document.Schema); Assert.Equal(QueryDocument.CurrentVersion, document.Version); @@ -88,20 +90,23 @@ public void Matching_translates_and_interprets_the_canonical_AST() Assert.Equal( new ConstantNode(new StringConstant("dev")), Assert.IsType(prefix.Right)); - ComparisonNode greater = Assert.IsType(conjunction.Operands[1]); - Assert.Equal(QueryComparison.GreaterThan, greater.Operator); + ComparisonNode attached = Assert.IsType(conjunction.Operands[1]); + Assert.Equal(QueryComparison.Equal, attached.Operator); + Assert.Equal( + new ConstantNode(new BooleanConstant(true)), + Assert.IsType(attached.Right)); // The same predicate must mean the same thing in memory as on the wire. Func compiled = document.Compile(); - Assert.True(compiled(new Row("devbox", 2))); - Assert.False(compiled(new Row("devbox", 1))); - Assert.False(compiled(new Row("prod", 4))); + Assert.True(compiled(new Row("devbox", true))); + Assert.False(compiled(new Row("devbox", false))); + Assert.False(compiled(new Row("prod", true))); IReadOnlyList matched = new[] { - new Row("devbox", 2), - new Row("prod", 9), - }.Matching(row => row.SessionName.StartsWith("dev") && row.SessionWindows > 1); + new Row("devbox", true), + new Row("prod", true), + }.Matching(row => row.SessionName.StartsWith("dev") && row.SessionAttached); Assert.Single(matched); Assert.Equal("devbox", matched[0].SessionName); } @@ -117,6 +122,27 @@ public void Translation_refuses_a_field_outside_the_closed_catalog() Assert.Contains("pane_title", error.Message, StringComparison.Ordinal); } + [Fact] + public void Translation_refuses_a_projection_that_changes_a_field_kind() + { + Assert.Throws( + () => QueryExtensions.Translate(row => row.SessionWindows > 1)); + } + + [Fact] + public void A_boolean_field_is_a_complete_predicate() + { + QueryDocument document = new( + QueryDocument.CurrentSchema, + QueryDocument.CurrentVersion, + QueryTarget.Session, + new FieldNode(QueryTarget.Session, "session_attached")); + Func predicate = document.Compile(); + + Assert.True(predicate(new Row("build", true))); + Assert.False(predicate(new Row("build", false))); + } + [Fact] public void Translation_refuses_an_unsupported_node_rather_than_evaluating_it() { From d362ef5945ed1ee9fa143375e4708a020012c705 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 06:38:58 -0500 Subject: [PATCH 016/129] QueryJson(test[goldens]): Bind wire tests to evidence why: Generated self-roundtrips stayed green when the accepted wire format changed, so they could not defend the versioned contract. what: - Embed the retained version-one goldens in the unit test assembly - Round-trip every accepted artifact byte for byte - Keep translated-document round trips as separate coverage --- .../LibTmux.UnitTests.csproj | 1 + .../LibTmux.UnitTests/Query/QueryJsonTests.cs | 25 ++++++++++++++++--- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/tests/LibTmux.UnitTests/LibTmux.UnitTests.csproj b/tests/LibTmux.UnitTests/LibTmux.UnitTests.csproj index 03f6b55..218fe3f 100644 --- a/tests/LibTmux.UnitTests/LibTmux.UnitTests.csproj +++ b/tests/LibTmux.UnitTests/LibTmux.UnitTests.csproj @@ -28,5 +28,6 @@ + diff --git a/tests/LibTmux.UnitTests/Query/QueryJsonTests.cs b/tests/LibTmux.UnitTests/Query/QueryJsonTests.cs index 62131b5..5490f73 100644 --- a/tests/LibTmux.UnitTests/Query/QueryJsonTests.cs +++ b/tests/LibTmux.UnitTests/Query/QueryJsonTests.cs @@ -15,7 +15,7 @@ private sealed record Row(string SessionName, bool SessionAttached); private static readonly ConstantNode True = new(new BooleanConstant(true)); - public static TheoryData Goldens => + public static TheoryData TranslatedDocuments => new() { { @@ -36,8 +36,8 @@ private sealed record Row(string SessionName, bool SessionAttached); }; [Theory] - [MemberData(nameof(Goldens))] - public void Round_trips_every_version_one_golden_byte_for_byte( + [MemberData(nameof(TranslatedDocuments))] + public void Translated_documents_round_trip_byte_for_byte( string name, QueryDocument document) { @@ -53,6 +53,25 @@ public void Round_trips_every_version_one_golden_byte_for_byte( Assert.DoesNotContain("\n", json, StringComparison.Ordinal); } + [Theory] + [InlineData("attached-nvim.json")] + [InlineData("regex-invariant.json")] + [InlineData("turkish-ignore-case.json")] + [InlineData("typed-id.json")] + public void Round_trips_every_version_one_golden_byte_for_byte(string fileName) + { + string resourceName = $"LibTmux.UnitTests.QueryGoldens.{fileName}"; + using Stream stream = typeof(QueryJsonTests).Assembly + .GetManifestResourceStream(resourceName) + ?? throw new InvalidOperationException($"Missing embedded resource '{resourceName}'."); + using StreamReader reader = new(stream); + string json = reader.ReadToEnd().TrimEnd('\r', '\n'); + + QueryDocument document = QueryJson.Deserialize(json); + + Assert.Equal(json, QueryJson.Serialize(document)); + } + [Fact] public void The_wire_matches_the_accepted_version_one_golden() { From 933f884732afc49dcb97565c141c6a3e45f2e47f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 06:51:34 -0500 Subject: [PATCH 017/129] QueryJson(refactor[wire]): Split reader and rules why: One file mixed closed-shape validation, wire writing, and hostile-input reading, which obscured the package's trust boundaries. what: - Move the v1 reader into its own internal file - Move closed-wire validation rules into their own internal file - Keep the writer API and canonical bytes unchanged --- .../QueryDocumentJsonConverter.cs | 291 +----------------- .../QueryDocumentJsonReader.cs | 163 ++++++++++ src/LibTmux.Query.Json/QueryJsonWireRules.cs | 114 +++++++ 3 files changed, 278 insertions(+), 290 deletions(-) create mode 100644 src/LibTmux.Query.Json/QueryDocumentJsonReader.cs create mode 100644 src/LibTmux.Query.Json/QueryJsonWireRules.cs diff --git a/src/LibTmux.Query.Json/QueryDocumentJsonConverter.cs b/src/LibTmux.Query.Json/QueryDocumentJsonConverter.cs index 19cc5cf..90e6a5e 100644 --- a/src/LibTmux.Query.Json/QueryDocumentJsonConverter.cs +++ b/src/LibTmux.Query.Json/QueryDocumentJsonConverter.cs @@ -1,121 +1,9 @@ using System.Text.Json; using System.Text.Json.Serialization; -using System.Text.RegularExpressions; namespace LibTmux.Query.Json; -internal static class QueryJsonWireRules -{ - private static readonly string[] EnvelopeProperties = - ["schema", "version", "target", "predicate"]; - private static readonly string[] FieldProperties = ["kind", "target", "wireName"]; - private static readonly string[] ConstantNodeProperties = ["kind", "value"]; - private static readonly string[] OperandsProperties = ["kind", "operands"]; - private static readonly string[] NotProperties = ["kind", "operand"]; - private static readonly string[] ComparisonProperties = - ["kind", "operator", "left", "right"]; - private static readonly string[] QuantifierProperties = - ["kind", "quantifier", "relation", "predicate"]; - private static readonly string[] RegexProperties = - ["kind", "input", "dialect", "pattern", "semanticOptions"]; - private static readonly string[] KindProperties = ["kind"]; - private static readonly string[] ValueProperties = ["kind", "value"]; - private static readonly string[] TypedIdProperties = ["kind", "type", "value"]; - private static readonly string[] EnumProperties = ["kind", "type", "token"]; - private static readonly string[] InstantProperties = ["kind", "unixSeconds"]; - - internal static void ValidateEnvelope(JsonElement element) => - ValidateProperties(element, EnvelopeProperties, "query envelope"); - - internal static void ValidateNode(JsonElement element, string? kind) - { - string[]? allowed = kind switch - { - "field" => FieldProperties, - "constant" => ConstantNodeProperties, - "and" or "or" => OperandsProperties, - "not" => NotProperties, - "comparison" => ComparisonProperties, - "quantifier" => QuantifierProperties, - "regex" => RegexProperties, - _ => null, - }; - if (allowed is not null) - { - ValidateProperties(element, allowed, $"{kind} node"); - } - } - - internal static void ValidateConstant(JsonElement element, string? kind) - { - string[]? allowed = kind switch - { - "null" => KindProperties, - "boolean" or "int64" or "string" => ValueProperties, - "typedId" => TypedIdProperties, - "enum" => EnumProperties, - "instant" => InstantProperties, - _ => null, - }; - if (allowed is not null) - { - ValidateProperties(element, allowed, $"{kind} constant"); - } - } - - private static void ValidateProperties( - JsonElement element, - IReadOnlyList allowed, - string description) - { - var seen = new HashSet(StringComparer.Ordinal); - foreach (JsonProperty property in element.EnumerateObject()) - { - if (!allowed.Contains(property.Name, StringComparer.Ordinal)) - { - throw new JsonException($"Unknown member in {description}."); - } - - if (!seen.Add(property.Name)) - { - throw new JsonException($"Duplicate member in {description}."); - } - } - } - - internal static int ScalarLength(string? value, string description) - { - if (!QueryTextSemantics.TryCountScalars(value, out int scalars)) - { - throw new JsonException($"{description} is null or contains invalid Unicode."); - } - - return scalars; - } - - internal static void ValidateRegex(RegexNode regex, QueryJsonLimits limits) - { - if (!string.Equals( - regex.Dialect, - QueryRegexSemantics.Dialect, - StringComparison.Ordinal)) - { - throw new JsonException($"Regex dialect '{regex.Dialect}' is not supported."); - } - - if (ScalarLength(regex.Pattern, "Regex pattern") > limits.MaximumPatternLength) - { - throw new JsonException("Regex pattern exceeds the maximum length."); - } - - if (!QueryRegexSemantics.IsSupported(regex.SemanticOptions)) - { - throw new JsonException("Regex names options this writer does not support."); - } - } -} - -/// Reads and writes the stable v1 wire form of a query document. +/// Writes the stable v1 wire form of a query document. /// /// The wire form is hand-written rather than reflection-derived so the schema /// is decoupled from the CLR shape: renaming a record property must not change @@ -378,180 +266,3 @@ private void WriteBoundedString( writer.WriteString(propertyName, value); } } - -/// Reads the stable v1 wire form back into a query document. -internal sealed class QueryDocumentJsonReader -{ - private readonly QueryJsonLimits _limits; - private int _nodes; - - internal QueryDocumentJsonReader(QueryJsonLimits limits) => _limits = limits; - - internal static QueryTarget ReadTarget(JsonElement element) => - element.GetString() switch - { - "session" => QueryTarget.Session, - "window" => QueryTarget.Window, - "pane" => QueryTarget.Pane, - "client" => QueryTarget.Client, - _ => throw new JsonException("Query document names an unknown target."), - }; - - internal QueryNode ReadNode(JsonElement element, int depth) - { - if (depth > _limits.MaximumDepth) - { - throw new JsonException("Query document exceeds the maximum nesting depth."); - } - - if (++_nodes > _limits.MaximumNodes) - { - throw new JsonException("Query document exceeds the maximum node count."); - } - - string? kind = element.GetProperty("kind").GetString(); - QueryJsonWireRules.ValidateNode(element, kind); - return kind switch - { - "and" => new AndNode([.. ReadOperands(element, depth)]), - "or" => new OrNode([.. ReadOperands(element, depth)]), - "not" => new NotNode(ReadNode(element.GetProperty("operand"), depth + 1)), - "comparison" => ReadComparisonNode(element, depth), - "regex" => new RegexNode( - ReadNode(element.GetProperty("input"), depth + 1), - ReadDialect(element.GetProperty("dialect")), - ReadPattern(element.GetProperty("pattern")), - ReadRegexOptions(element.GetProperty("semanticOptions"))), - "quantifier" => new QuantifierNode( - ReadQuantifier(element.GetProperty("quantifier")), - (FieldNode)ReadNode(element.GetProperty("relation"), depth + 1), - ReadNode(element.GetProperty("predicate"), depth + 1)), - "field" => new FieldNode( - ReadTarget(element.GetProperty("target")), - ReadBoundedString(element.GetProperty("wireName"), "Field wire name")), - "constant" => new ConstantNode(ReadConstant(element.GetProperty("value"))), - _ => throw new JsonException("Query document names an unknown node kind."), - }; - } - - /// Reads a regex dialect, refusing one this library cannot evaluate. - /// - /// The wire form names a dialect so a future reader can tell .NET patterns - /// from someone else's. Accepting an unknown name would mean evaluating a - /// pattern under rules it was not written for. - /// - private static string ReadDialect(JsonElement element) - { - string dialect = element.GetString() - ?? throw new JsonException("Regex names no dialect."); - return string.Equals(dialect, QueryRegexSemantics.Dialect, StringComparison.Ordinal) - ? dialect - : throw new JsonException($"Regex dialect '{dialect}' is not supported."); - } - - /// Reads a pattern, bounded by the declared limit. - private string ReadPattern(JsonElement element) - { - string pattern = element.GetString() - ?? throw new JsonException("Regex names no pattern."); - return QueryJsonWireRules.ScalarLength(pattern, "Regex pattern") - <= _limits.MaximumPatternLength - ? pattern - : throw new JsonException("Regex pattern exceeds the maximum length."); - } - - /// Reads regex options, refusing bits this library does not define. - /// - /// Arrives as a raw integer, so only the bit combinations the writer can - /// produce are accepted; anything else describes behaviour this library never writes. - /// - private static System.Text.RegularExpressions.RegexOptions ReadRegexOptions( - JsonElement element) - { - var options = (RegexOptions)element.GetInt32(); - return QueryRegexSemantics.IsSupported(options) - ? options - : throw new JsonException("Regex names options this reader does not support."); - } - - /// Reads a string constant, bounded by the declared limit. - /// - /// Re-checked here because the writer's own limit does not bound documents - /// produced elsewhere, which are exactly the ones this limit exists for. - /// - private string ReadBoundedString(JsonElement element, string description = "String value") - { - string value = element.GetString() - ?? throw new JsonException($"{description} is null."); - return QueryJsonWireRules.ScalarLength(value, description) - <= _limits.MaximumStringLength - ? value - : throw new JsonException("String value exceeds the maximum length."); - } - - private QueryNode ReadComparisonNode(JsonElement element, int depth) - { - string? operation = element.GetProperty("operator").GetString(); - QueryNode left = ReadNode(element.GetProperty("left"), depth + 1); - QueryNode right = ReadNode(element.GetProperty("right"), depth + 1); - return operation switch - { - "equal" => new ComparisonNode(QueryComparison.Equal, left, right), - "notEqual" => new ComparisonNode(QueryComparison.NotEqual, left, right), - "lessThan" => new ComparisonNode(QueryComparison.LessThan, left, right), - "lessThanOrEqual" => - new ComparisonNode(QueryComparison.LessThanOrEqual, left, right), - "greaterThan" => new ComparisonNode(QueryComparison.GreaterThan, left, right), - "greaterThanOrEqual" => - new ComparisonNode(QueryComparison.GreaterThanOrEqual, left, right), - "stringEqualOrdinal" => - new StringNode(QueryStringOperation.EqualsOrdinal, left, right), - "stringEqualOrdinalIgnoreCase" => - new StringNode(QueryStringOperation.EqualsOrdinalIgnoreCase, left, right), - "startsWithOrdinal" => - new StringNode(QueryStringOperation.StartsWithOrdinal, left, right), - "endsWithOrdinal" => - new StringNode(QueryStringOperation.EndsWithOrdinal, left, right), - "containsOrdinal" => - new StringNode(QueryStringOperation.ContainsOrdinal, left, right), - _ => throw new JsonException("Query document names an unknown comparison."), - }; - } - - private static QueryQuantifier ReadQuantifier(JsonElement element) => - element.GetString() switch - { - "any" => QueryQuantifier.Any, - "all" => QueryQuantifier.All, - _ => throw new JsonException("Query document names an unknown quantifier."), - }; - - private QueryConstant ReadConstant(JsonElement element) - { - string? kind = element.GetProperty("kind").GetString(); - QueryJsonWireRules.ValidateConstant(element, kind); - return kind switch - { - "null" => new NullConstant(), - "boolean" => new BooleanConstant(element.GetProperty("value").GetBoolean()), - "int64" => new Int64Constant(element.GetProperty("value").GetInt64()), - "string" => new StringConstant(ReadBoundedString(element.GetProperty("value"))), - "instant" => new InstantConstant(element.GetProperty("unixSeconds").GetInt64()), - "enum" => new EnumConstant( - ReadBoundedString(element.GetProperty("type"), "Enum type"), - ReadBoundedString(element.GetProperty("token"), "Enum value")), - "typedId" => new TypedIdConstant( - ReadTarget(element.GetProperty("type")), - ReadBoundedString(element.GetProperty("value"), "Typed ID value")), - _ => throw new JsonException("Query document names an unknown constant type."), - }; - } - - private IEnumerable ReadOperands(JsonElement element, int depth) - { - foreach (JsonElement operand in element.GetProperty("operands").EnumerateArray()) - { - yield return ReadNode(operand, depth + 1); - } - } -} diff --git a/src/LibTmux.Query.Json/QueryDocumentJsonReader.cs b/src/LibTmux.Query.Json/QueryDocumentJsonReader.cs new file mode 100644 index 0000000..5efdcec --- /dev/null +++ b/src/LibTmux.Query.Json/QueryDocumentJsonReader.cs @@ -0,0 +1,163 @@ +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace LibTmux.Query.Json; + +/// Reads the stable v1 wire form back into a query document. +internal sealed class QueryDocumentJsonReader +{ + private readonly QueryJsonLimits _limits; + private int _nodes; + + internal QueryDocumentJsonReader(QueryJsonLimits limits) => _limits = limits; + + internal static QueryTarget ReadTarget(JsonElement element) => + element.GetString() switch + { + "session" => QueryTarget.Session, + "window" => QueryTarget.Window, + "pane" => QueryTarget.Pane, + "client" => QueryTarget.Client, + _ => throw new JsonException("Query document names an unknown target."), + }; + + internal QueryNode ReadNode(JsonElement element, int depth) + { + if (depth > _limits.MaximumDepth) + { + throw new JsonException("Query document exceeds the maximum nesting depth."); + } + + if (++_nodes > _limits.MaximumNodes) + { + throw new JsonException("Query document exceeds the maximum node count."); + } + + string? kind = element.GetProperty("kind").GetString(); + QueryJsonWireRules.ValidateNode(element, kind); + return kind switch + { + "and" => new AndNode([.. ReadOperands(element, depth)]), + "or" => new OrNode([.. ReadOperands(element, depth)]), + "not" => new NotNode(ReadNode(element.GetProperty("operand"), depth + 1)), + "comparison" => ReadComparisonNode(element, depth), + "regex" => new RegexNode( + ReadNode(element.GetProperty("input"), depth + 1), + ReadDialect(element.GetProperty("dialect")), + ReadPattern(element.GetProperty("pattern")), + ReadRegexOptions(element.GetProperty("semanticOptions"))), + "quantifier" => new QuantifierNode( + ReadQuantifier(element.GetProperty("quantifier")), + (FieldNode)ReadNode(element.GetProperty("relation"), depth + 1), + ReadNode(element.GetProperty("predicate"), depth + 1)), + "field" => new FieldNode( + ReadTarget(element.GetProperty("target")), + ReadBoundedString(element.GetProperty("wireName"), "Field wire name")), + "constant" => new ConstantNode(ReadConstant(element.GetProperty("value"))), + _ => throw new JsonException("Query document names an unknown node kind."), + }; + } + + private static string ReadDialect(JsonElement element) + { + string dialect = element.GetString() + ?? throw new JsonException("Regex names no dialect."); + return string.Equals(dialect, QueryRegexSemantics.Dialect, StringComparison.Ordinal) + ? dialect + : throw new JsonException($"Regex dialect '{dialect}' is not supported."); + } + + private string ReadPattern(JsonElement element) + { + string pattern = element.GetString() + ?? throw new JsonException("Regex names no pattern."); + return QueryJsonWireRules.ScalarLength(pattern, "Regex pattern") + <= _limits.MaximumPatternLength + ? pattern + : throw new JsonException("Regex pattern exceeds the maximum length."); + } + + private static RegexOptions ReadRegexOptions(JsonElement element) + { + var options = (RegexOptions)element.GetInt32(); + return QueryRegexSemantics.IsSupported(options) + ? options + : throw new JsonException("Regex names options this reader does not support."); + } + + private string ReadBoundedString(JsonElement element, string description = "String value") + { + string value = element.GetString() + ?? throw new JsonException($"{description} is null."); + return QueryJsonWireRules.ScalarLength(value, description) + <= _limits.MaximumStringLength + ? value + : throw new JsonException("String value exceeds the maximum length."); + } + + private QueryNode ReadComparisonNode(JsonElement element, int depth) + { + string? operation = element.GetProperty("operator").GetString(); + QueryNode left = ReadNode(element.GetProperty("left"), depth + 1); + QueryNode right = ReadNode(element.GetProperty("right"), depth + 1); + return operation switch + { + "equal" => new ComparisonNode(QueryComparison.Equal, left, right), + "notEqual" => new ComparisonNode(QueryComparison.NotEqual, left, right), + "lessThan" => new ComparisonNode(QueryComparison.LessThan, left, right), + "lessThanOrEqual" => + new ComparisonNode(QueryComparison.LessThanOrEqual, left, right), + "greaterThan" => new ComparisonNode(QueryComparison.GreaterThan, left, right), + "greaterThanOrEqual" => + new ComparisonNode(QueryComparison.GreaterThanOrEqual, left, right), + "stringEqualOrdinal" => + new StringNode(QueryStringOperation.EqualsOrdinal, left, right), + "stringEqualOrdinalIgnoreCase" => + new StringNode(QueryStringOperation.EqualsOrdinalIgnoreCase, left, right), + "startsWithOrdinal" => + new StringNode(QueryStringOperation.StartsWithOrdinal, left, right), + "endsWithOrdinal" => + new StringNode(QueryStringOperation.EndsWithOrdinal, left, right), + "containsOrdinal" => + new StringNode(QueryStringOperation.ContainsOrdinal, left, right), + _ => throw new JsonException("Query document names an unknown comparison."), + }; + } + + private static QueryQuantifier ReadQuantifier(JsonElement element) => + element.GetString() switch + { + "any" => QueryQuantifier.Any, + "all" => QueryQuantifier.All, + _ => throw new JsonException("Query document names an unknown quantifier."), + }; + + private QueryConstant ReadConstant(JsonElement element) + { + string? kind = element.GetProperty("kind").GetString(); + QueryJsonWireRules.ValidateConstant(element, kind); + return kind switch + { + "null" => new NullConstant(), + "boolean" => new BooleanConstant(element.GetProperty("value").GetBoolean()), + "int64" => new Int64Constant(element.GetProperty("value").GetInt64()), + "string" => new StringConstant(ReadBoundedString(element.GetProperty("value"))), + "instant" => new InstantConstant(element.GetProperty("unixSeconds").GetInt64()), + "enum" => new EnumConstant( + ReadBoundedString(element.GetProperty("type"), "Enum type"), + ReadBoundedString(element.GetProperty("token"), "Enum value")), + "typedId" => new TypedIdConstant( + ReadTarget(element.GetProperty("type")), + ReadBoundedString(element.GetProperty("value"), "Typed ID value")), + _ => throw new JsonException("Query document names an unknown constant type."), + }; + } + + private IEnumerable ReadOperands(JsonElement element, int depth) + { + foreach (JsonElement operand in element.GetProperty("operands").EnumerateArray()) + { + yield return ReadNode(operand, depth + 1); + } + } +} diff --git a/src/LibTmux.Query.Json/QueryJsonWireRules.cs b/src/LibTmux.Query.Json/QueryJsonWireRules.cs new file mode 100644 index 0000000..740b610 --- /dev/null +++ b/src/LibTmux.Query.Json/QueryJsonWireRules.cs @@ -0,0 +1,114 @@ +using System.Text.Json; + +namespace LibTmux.Query.Json; + +internal static class QueryJsonWireRules +{ + private static readonly string[] EnvelopeProperties = + ["schema", "version", "target", "predicate"]; + private static readonly string[] FieldProperties = ["kind", "target", "wireName"]; + private static readonly string[] ConstantNodeProperties = ["kind", "value"]; + private static readonly string[] OperandsProperties = ["kind", "operands"]; + private static readonly string[] NotProperties = ["kind", "operand"]; + private static readonly string[] ComparisonProperties = + ["kind", "operator", "left", "right"]; + private static readonly string[] QuantifierProperties = + ["kind", "quantifier", "relation", "predicate"]; + private static readonly string[] RegexProperties = + ["kind", "input", "dialect", "pattern", "semanticOptions"]; + private static readonly string[] KindProperties = ["kind"]; + private static readonly string[] ValueProperties = ["kind", "value"]; + private static readonly string[] TypedIdProperties = ["kind", "type", "value"]; + private static readonly string[] EnumProperties = ["kind", "type", "token"]; + private static readonly string[] InstantProperties = ["kind", "unixSeconds"]; + + internal static void ValidateEnvelope(JsonElement element) => + ValidateProperties(element, EnvelopeProperties, "query envelope"); + + internal static void ValidateNode(JsonElement element, string? kind) + { + string[]? allowed = kind switch + { + "field" => FieldProperties, + "constant" => ConstantNodeProperties, + "and" or "or" => OperandsProperties, + "not" => NotProperties, + "comparison" => ComparisonProperties, + "quantifier" => QuantifierProperties, + "regex" => RegexProperties, + _ => null, + }; + if (allowed is not null) + { + ValidateProperties(element, allowed, $"{kind} node"); + } + } + + internal static void ValidateConstant(JsonElement element, string? kind) + { + string[]? allowed = kind switch + { + "null" => KindProperties, + "boolean" or "int64" or "string" => ValueProperties, + "typedId" => TypedIdProperties, + "enum" => EnumProperties, + "instant" => InstantProperties, + _ => null, + }; + if (allowed is not null) + { + ValidateProperties(element, allowed, $"{kind} constant"); + } + } + + private static void ValidateProperties( + JsonElement element, + IReadOnlyList allowed, + string description) + { + var seen = new HashSet(StringComparer.Ordinal); + foreach (JsonProperty property in element.EnumerateObject()) + { + if (!allowed.Contains(property.Name, StringComparer.Ordinal)) + { + throw new JsonException($"Unknown member in {description}."); + } + + if (!seen.Add(property.Name)) + { + throw new JsonException($"Duplicate member in {description}."); + } + } + } + + internal static int ScalarLength(string? value, string description) + { + if (!QueryTextSemantics.TryCountScalars(value, out int scalars)) + { + throw new JsonException($"{description} is null or contains invalid Unicode."); + } + + return scalars; + } + + internal static void ValidateRegex(RegexNode regex, QueryJsonLimits limits) + { + if (!string.Equals( + regex.Dialect, + QueryRegexSemantics.Dialect, + StringComparison.Ordinal)) + { + throw new JsonException($"Regex dialect '{regex.Dialect}' is not supported."); + } + + if (ScalarLength(regex.Pattern, "Regex pattern") > limits.MaximumPatternLength) + { + throw new JsonException("Regex pattern exceeds the maximum length."); + } + + if (!QueryRegexSemantics.IsSupported(regex.SemanticOptions)) + { + throw new JsonException("Regex names options this writer does not support."); + } + } +} From eb07dd9ab3500b383a021adda15e3c822b843bd0 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 06:52:15 -0500 Subject: [PATCH 018/129] QueryJson(docs[wire]): Show canonical v1 document why: The package README showed an unsupported schema name and flattened AST, so copying it produced a document the reader rejects. what: - Replace the example with the canonical nested v1 wire shape - Name every resource ceiling enforced while reading --- src/LibTmux.Query.Json/README.md | 30 +++++++++++++++++++++++------- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/src/LibTmux.Query.Json/README.md b/src/LibTmux.Query.Json/README.md index f6ab916..69f8918 100644 --- a/src/LibTmux.Query.Json/README.md +++ b/src/LibTmux.Query.Json/README.md @@ -41,14 +41,30 @@ Console.WriteLine(parsed == document); ```json { - "schema": "libtmux.query", + "schema": "libtmux-query", "version": 1, "target": "session", "predicate": { "kind": "and", "operands": [ - { "kind": "string", "operator": "startsWith", "field": "session_name", "value": "build" }, - { "kind": "comparison", "operator": "equal", "field": "session_attached", "value": true } + { + "kind": "comparison", + "operator": "startsWithOrdinal", + "left": { + "kind": "field", + "target": "session", + "wireName": "session_name" + }, + "right": { + "kind": "constant", + "value": { "kind": "string", "value": "build" } + } + }, + { + "kind": "field", + "target": "session", + "wireName": "session_attached" + } ] } } @@ -69,10 +85,10 @@ Console.WriteLine(matched.Count); ## What reading a document costs -Deserializing applies the limits in `QueryJsonLimits.V1` — depth, node count, -string length — so a document that arrived from somewhere else cannot cost more -than a document is allowed to. The schema those limits describe ships in the -package as `libtmux-query-v1.schema.json`. +Deserializing applies the limits in `QueryJsonLimits.V1`: document size, +nesting depth, node count, string length, and regex pattern length. A caller +may tighten those ceilings but cannot widen the v1 contract. The schema ships +in the package as `libtmux-query-v1.schema.json`. ```csharp run Console.WriteLine($"depth {QueryJsonLimits.V1.MaximumDepth}, nodes {QueryJsonLimits.V1.MaximumNodes}"); From 8cd72637fa172f9ce1c885499c617047690ffae0 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 06:53:13 -0500 Subject: [PATCH 019/129] Packaging(fix[restore]): Isolate consumer cache why: The packed consumer restored only after the solution had warmed its external dependencies, so CI could hide an incomplete downstream graph. what: - Map local LibTmux packages separately from public dependencies - Restore the CI consumer through a fresh NuGet cache - Make workflow validation reject a shared consumer cache --- .github/workflows/dotnet.yml | 2 ++ eng/parity/tests/test_workflows.py | 16 +++++++++++++++- eng/parity/verify_workflows.py | 1 + tests/LibTmux.PackageConsumer/NuGet.config | 15 ++++++++++++--- 4 files changed, 30 insertions(+), 4 deletions(-) diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index f7e0369..f356c89 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -84,6 +84,8 @@ jobs: # Reaching the library through the package is the point: a missing # assembly or a wrong target framework is invisible from inside the # repository. + env: + NUGET_PACKAGES: ${{ runner.temp }}/libtmux-package-consumer run: | dotnet restore tests/LibTmux.PackageConsumer/LibTmux.PackageConsumer.csproj for framework in net8.0 net10.0; do diff --git a/eng/parity/tests/test_workflows.py b/eng/parity/tests/test_workflows.py index 9c58dd4..ef52212 100644 --- a/eng/parity/tests/test_workflows.py +++ b/eng/parity/tests/test_workflows.py @@ -39,7 +39,9 @@ def verify(root: pathlib.Path) -> list[str]: - run: dotnet build --warnaserror - run: dotnet pack src/LibTmux/LibTmux.csproj - run: dotnet publish tests/LibTmux.AotSmoke/LibTmux.AotSmoke.csproj - - run: dotnet run --project tests/LibTmux.PackageConsumer + - env: + NUGET_PACKAGES: ${{ runner.temp }}/libtmux-package-consumer + run: dotnet run --project tests/LibTmux.PackageConsumer - run: dotnet run --project examples/LibTmux.Examples - run: dotnet test --project tests/LibTmux.ExampleTests - run: uv run python eng/docs/render_api_reference.py --check @@ -171,6 +173,18 @@ def test_a_dropped_build_step_is_reported(tmp_path: pathlib.Path, step: str) -> assert f"dotnet.yml omits {step}" in verify(root) +def test_a_shared_package_consumer_cache_is_reported(tmp_path: pathlib.Path) -> None: + """A warm solution cache can hide an incomplete package restore graph.""" + isolation = "NUGET_PACKAGES: ${{ runner.temp }}/libtmux-package-consumer" + root = write( + tmp_path, + BUILD.replace(isolation, "NUGET_PACKAGES: shared"), + MATRIX.format(versions=every_version()), + ) + + assert f"dotnet.yml omits {isolation}" in verify(root) + + def test_a_missing_workflow_is_reported(tmp_path: pathlib.Path) -> None: """Deleting a workflow is the loudest way to stop testing.""" root = write(tmp_path, BUILD, MATRIX.format(versions=every_version())) diff --git a/eng/parity/verify_workflows.py b/eng/parity/verify_workflows.py index 3819844..e0e3340 100644 --- a/eng/parity/verify_workflows.py +++ b/eng/parity/verify_workflows.py @@ -24,6 +24,7 @@ "dotnet pack", "LibTmux.AotSmoke", "LibTmux.PackageConsumer", + "NUGET_PACKAGES: ${{ runner.temp }}/libtmux-package-consumer", "LibTmux.Examples", "LibTmux.ExampleTests", "render_api_reference.py --check", diff --git a/tests/LibTmux.PackageConsumer/NuGet.config b/tests/LibTmux.PackageConsumer/NuGet.config index bc79c58..94261a3 100644 --- a/tests/LibTmux.PackageConsumer/NuGet.config +++ b/tests/LibTmux.PackageConsumer/NuGet.config @@ -1,10 +1,19 @@ - + + + + + + + + + + + From 3614b55dcb022789ae59d1aa48035bc81865553b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 06:55:15 -0500 Subject: [PATCH 020/129] QueryJson(test[closure]): Reach packed and native paths why: The optional package declared NativeAOT compatibility but no packed or native consumer referenced it, so that claim could regress invisibly. what: - Round-trip Query JSON through both downstream smoke executables - Reference the optional package from packed and NativeAOT graphs - Keep runtime-specific lock restores stable and assert their output --- src/LibTmux.Query.Json/LibTmux.Query.Json.csproj | 3 +++ src/LibTmux.Query.Json/packages.lock.json | 4 +++- tests/LibTmux.AotSmoke/LibTmux.AotSmoke.csproj | 1 + tests/LibTmux.AotSmoke/Program.cs | 9 ++++++++- tests/LibTmux.AotSmoke/packages.lock.json | 12 ++++++++++++ .../Packaging/PackageClosureTests.cs | 2 ++ .../LibTmux.PackageConsumer.csproj | 16 ++++++---------- tests/LibTmux.PackageConsumer/Program.cs | 11 +++++++++++ 8 files changed, 46 insertions(+), 12 deletions(-) diff --git a/src/LibTmux.Query.Json/LibTmux.Query.Json.csproj b/src/LibTmux.Query.Json/LibTmux.Query.Json.csproj index 5b31068..15646c9 100644 --- a/src/LibTmux.Query.Json/LibTmux.Query.Json.csproj +++ b/src/LibTmux.Query.Json/LibTmux.Query.Json.csproj @@ -7,6 +7,9 @@ true true + + linux-x64 + LibTmux.Query.Json System.Text.Json support for LibTmux query documents. The core library does not reference it, so a caller who does not want a JSON dependency does not get one. tmux;json;query;serialization diff --git a/src/LibTmux.Query.Json/packages.lock.json b/src/LibTmux.Query.Json/packages.lock.json index c4a371b..40daea9 100644 --- a/src/LibTmux.Query.Json/packages.lock.json +++ b/src/LibTmux.Query.Json/packages.lock.json @@ -35,6 +35,7 @@ } } }, + "net10.0/linux-x64": {}, "net8.0": { "Microsoft.CodeAnalysis.PublicApiAnalyzers": { "type": "Direct", @@ -68,6 +69,7 @@ "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0" } } - } + }, + "net8.0/linux-x64": {} } } \ No newline at end of file diff --git a/tests/LibTmux.AotSmoke/LibTmux.AotSmoke.csproj b/tests/LibTmux.AotSmoke/LibTmux.AotSmoke.csproj index d0717d3..6005769 100644 --- a/tests/LibTmux.AotSmoke/LibTmux.AotSmoke.csproj +++ b/tests/LibTmux.AotSmoke/LibTmux.AotSmoke.csproj @@ -16,6 +16,7 @@ + Main() await using TemporaryHierarchyScope scope = await factory.CreateHierarchyAsync(options); { + QueryDocument query = + QueryEdgeParser.ParseNameContains(QueryTarget.Session, "aot"); + bool queryRoundTrips = + QueryJson.Deserialize(QueryJson.Serialize(query)) == query; Server server = scope.Server; Session session = scope.Session; Window window = scope.Window; @@ -45,7 +51,8 @@ private static async Task Main() Console.WriteLine($"pane {pane.Width}x{pane.Height}"); Console.WriteLine($"option {option.Value.Raw}"); Console.WriteLine($"buffer {buffer}"); - return option.Value.Boolean == false && buffer == "aot" ? 0 : 1; + Console.WriteLine($"query-json {queryRoundTrips}"); + return option.Value.Boolean == false && buffer == "aot" && queryRoundTrips ? 0 : 1; } } } diff --git a/tests/LibTmux.AotSmoke/packages.lock.json b/tests/LibTmux.AotSmoke/packages.lock.json index f49a8cc..b72e3f7 100644 --- a/tests/LibTmux.AotSmoke/packages.lock.json +++ b/tests/LibTmux.AotSmoke/packages.lock.json @@ -25,6 +25,12 @@ "Microsoft.Extensions.Logging.Abstractions": "[10.0.10, )" } }, + "libtmux.query.json": { + "type": "Project", + "dependencies": { + "LibTmux": "[0.0.0-alpha.9, )" + } + }, "Microsoft.Extensions.Logging.Abstractions": { "type": "CentralTransitive", "requested": "[10.0.10, )", @@ -75,6 +81,12 @@ "Microsoft.Extensions.Logging.Abstractions": "[8.0.0, )" } }, + "libtmux.query.json": { + "type": "Project", + "dependencies": { + "LibTmux": "[0.0.0-alpha.9, )" + } + }, "Microsoft.Extensions.Logging.Abstractions": { "type": "CentralTransitive", "requested": "[8.0.0, )", diff --git a/tests/LibTmux.IntegrationTests/Packaging/PackageClosureTests.cs b/tests/LibTmux.IntegrationTests/Packaging/PackageClosureTests.cs index 0614aee..4a569cc 100644 --- a/tests/LibTmux.IntegrationTests/Packaging/PackageClosureTests.cs +++ b/tests/LibTmux.IntegrationTests/Packaging/PackageClosureTests.cs @@ -100,6 +100,7 @@ public async Task Packed_consumers_execute_on_both_frameworks() [ManagedEntryPoint("tests/LibTmux.PackageConsumer", framework)]); Assert.Contains("captured True", output, StringComparison.Ordinal); + Assert.Contains("query-json True", output, StringComparison.Ordinal); } } @@ -126,6 +127,7 @@ public async Task Trimmed_native_aot_executes_on_both_frameworks() string output = await RunAsync(NativeEntryPoint(framework), []); Assert.Contains("buffer aot", output, StringComparison.Ordinal); + Assert.Contains("query-json True", output, StringComparison.Ordinal); } } diff --git a/tests/LibTmux.PackageConsumer/LibTmux.PackageConsumer.csproj b/tests/LibTmux.PackageConsumer/LibTmux.PackageConsumer.csproj index 66b88cd..dc1c2f5 100644 --- a/tests/LibTmux.PackageConsumer/LibTmux.PackageConsumer.csproj +++ b/tests/LibTmux.PackageConsumer/LibTmux.PackageConsumer.csproj @@ -18,18 +18,14 @@ lock file, which is where it belongs. --> false false + + $(VersionPrefix) + $(VersionPrefix)-$(VersionSuffix) - - - + + + diff --git a/tests/LibTmux.PackageConsumer/Program.cs b/tests/LibTmux.PackageConsumer/Program.cs index 07fdeee..d8242c6 100644 --- a/tests/LibTmux.PackageConsumer/Program.cs +++ b/tests/LibTmux.PackageConsumer/Program.cs @@ -1,5 +1,7 @@ using System.Runtime.Versioning; using System.Text; +using LibTmux.Query; +using LibTmux.Query.Json; using LibTmux.Testing; namespace LibTmux.PackageConsumer; @@ -14,6 +16,15 @@ internal static class Program { private static async Task Main(string[] args) { + QueryDocument query = + QueryEdgeParser.ParseNameContains(QueryTarget.Session, "package"); + bool queryRoundTrips = QueryJson.Deserialize(QueryJson.Serialize(query)) == query; + Console.WriteLine($"query-json {queryRoundTrips}"); + if (!queryRoundTrips) + { + return 1; + } + if (args is ["--psmux"]) { Console.OutputEncoding = new UTF8Encoding(false, true); From 29088e050040afd65debb39ce6c9a3d8f8805232 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 07:12:25 -0500 Subject: [PATCH 021/129] Workspace(fix[parser]): Enforce the supported YAML shape why: Raw-line rewriting corrupted valid quoted pane commands, while reflection binding silently discarded unsupported configuration. what: - Parse one bounded YAML tree into defensively copied workspace values - Support scalar and ordered pane commands without changing their text - Send each command with one Enter and prove the regression against real tmux --- src/LibTmux.Workspace/WorkspaceBuilder.cs | 25 +- src/LibTmux.Workspace/WorkspaceFile.cs | 260 +++++++------- src/LibTmux.Workspace/WorkspaceYamlParser.cs | 318 ++++++++++++++++++ .../Workspace/WorkspaceBuilderTests.cs | 46 ++- .../Workspace/WorkspaceFileTests.cs | 109 ++++++ 5 files changed, 622 insertions(+), 136 deletions(-) create mode 100644 src/LibTmux.Workspace/WorkspaceYamlParser.cs create mode 100644 tests/LibTmux.IntegrationTests/Workspace/WorkspaceFileTests.cs diff --git a/src/LibTmux.Workspace/WorkspaceBuilder.cs b/src/LibTmux.Workspace/WorkspaceBuilder.cs index 967facb..c9cd637 100644 --- a/src/LibTmux.Workspace/WorkspaceBuilder.cs +++ b/src/LibTmux.Workspace/WorkspaceBuilder.cs @@ -2,22 +2,16 @@ namespace LibTmux.Workspace; -/// What building a workspace produced, and what it could not honour. +/// Describes a built workspace and any layout tmux rejected. /// The session that was built. /// The windows, in the order the file listed them. -/// What the file asked for that tmux alone cannot do. +/// The layouts tmux rejected after creating their windows. public sealed record WorkspaceResult( Session Session, IReadOnlyList Windows, IReadOnlyList Unsupported); /// Builds a tmux session from a tmuxp workspace file. -/// -/// tmuxp plugins and before-script hooks run through Python tooling this -/// library does not have, so an unsupported key is silently dropped by the -/// YAML reader (WorkspaceFile.Parse ignores unmatched properties) rather than -/// surfaced in WorkspaceResult.Unsupported. -/// [UnsupportedOSPlatform("windows")] public sealed class WorkspaceBuilder { @@ -94,7 +88,7 @@ await FillAsync(window, described, workspace, unsupported, cancellationToken) private static async Task ApplyOptionsAsync( TmuxOptions options, - Dictionary described, + IReadOnlyDictionary described, CancellationToken cancellationToken) { foreach ((string name, string value) in described) @@ -147,11 +141,10 @@ private static async Task FillAsync( cancellationToken) .ConfigureAwait(false); - if (!string.IsNullOrWhiteSpace(pane.ShellCommand)) + foreach (string command in pane.ShellCommands) { - await target.SendTextAsync(pane.ShellCommand, cancellationToken: cancellationToken) + await target.SendTextAsync(command, cancellationToken: cancellationToken) .ConfigureAwait(false); - await target.EnterAsync(cancellationToken).ConfigureAwait(false); } current = target; @@ -179,9 +172,13 @@ await target.SendTextAsync(pane.ShellCommand, cancellationToken: cancellationTok await ApplyOptionsAsync(window.Options, described.Options, cancellationToken) .ConfigureAwait(false); - foreach (WorkspacePane focused in described.Panes.Where(pane => pane.Focus)) + for (int index = 0; index < described.Panes.Count; index++) { - int index = described.Panes.IndexOf(focused); + if (!described.Panes[index].Focus) + { + continue; + } + IReadOnlyList made = await window.GetPanesAsync(cancellationToken) .ConfigureAwait(false); if (index < made.Count) diff --git a/src/LibTmux.Workspace/WorkspaceFile.cs b/src/LibTmux.Workspace/WorkspaceFile.cs index 8e1c9cc..21b438f 100644 --- a/src/LibTmux.Workspace/WorkspaceFile.cs +++ b/src/LibTmux.Workspace/WorkspaceFile.cs @@ -1,161 +1,185 @@ -using YamlDotNet.Serialization; -using YamlDotNet.Serialization.NamingConventions; +using System.Collections.ObjectModel; namespace LibTmux.Workspace; -/// One pane in a tmuxp workspace file. -/// -/// tmuxp lets a pane be written as a bare string, which means the command to -/// run, or as a mapping when it needs more than that. Both arrive here as this. -/// +/// Describes one pane in a supported tmuxp workspace. public sealed class WorkspacePane { - /// Gets or sets the shell command the pane starts with. - [YamlMember(Alias = "shell_command")] - public string? ShellCommand { get; set; } + private readonly ReadOnlyCollection _shellCommands; + + /// Initializes a pane description. + /// The commands to run, in order. + /// The directory the pane starts in. + /// Whether the pane is left selected. + public WorkspacePane( + IReadOnlyList? shellCommands = null, + string? startDirectory = null, + bool focus = false) + { + _shellCommands = WorkspaceCollections.Copy(shellCommands, nameof(shellCommands)); + StartDirectory = startDirectory; + Focus = focus; + } + + /// Gets the commands to run, in order. + public IReadOnlyList ShellCommands => _shellCommands; - /// Gets or sets the directory the pane starts in. - [YamlMember(Alias = "start_directory")] - public string? StartDirectory { get; set; } + /// Gets the directory the pane starts in. + public string? StartDirectory { get; } - /// Gets or sets whether this pane is the one left selected. - [YamlMember(Alias = "focus")] - public bool Focus { get; set; } + /// Gets whether the pane is left selected. + public bool Focus { get; } } -/// One window in a tmuxp workspace file. +/// Describes one window in a supported tmuxp workspace. public sealed class WorkspaceWindow { - /// Gets or sets the window name. - [YamlMember(Alias = "window_name")] - public string? WindowName { get; set; } + private readonly ReadOnlyDictionary _options; + private readonly ReadOnlyCollection _panes; + + /// Initializes a window description. + /// The window name. + /// The directory its panes start in. + /// The layout to apply after creating its panes. + /// Whether the window is left selected. + /// The window options to set. + /// The panes to create, in order. + public WorkspaceWindow( + string? windowName = null, + string? startDirectory = null, + string? layout = null, + bool focus = false, + IReadOnlyDictionary? options = null, + IReadOnlyList? panes = null) + { + WindowName = windowName; + StartDirectory = startDirectory; + Layout = layout; + Focus = focus; + _options = WorkspaceCollections.Copy(options, nameof(options)); + _panes = WorkspaceCollections.Copy(panes, nameof(panes)); + } + + /// Gets the window name. + public string? WindowName { get; } - /// Gets or sets the directory the window's panes start in. - [YamlMember(Alias = "start_directory")] - public string? StartDirectory { get; set; } + /// Gets the directory its panes start in. + public string? StartDirectory { get; } - /// Gets or sets the layout tmux arranges the panes with. - [YamlMember(Alias = "layout")] - public string? Layout { get; set; } + /// Gets the layout to apply after creating its panes. + public string? Layout { get; } - /// Gets or sets whether this window is the one left selected. - [YamlMember(Alias = "focus")] - public bool Focus { get; set; } + /// Gets whether the window is left selected. + public bool Focus { get; } - /// Gets or sets the window options set once the panes exist. - [YamlMember(Alias = "options")] - public Dictionary Options { get; set; } = []; + /// Gets the window options to set. + public IReadOnlyDictionary Options => _options; - /// Gets or sets the panes, in the order they are created. - [YamlMember(Alias = "panes")] - public List Panes { get; set; } = []; + /// Gets the panes to create, in order. + public IReadOnlyList Panes => _panes; } -/// A tmuxp workspace file. +/// Describes the supported subset of one tmuxp workspace. /// -/// Only what shapes a session is read: the name, where things start, the -/// windows, and the options. tmuxp's plugin and hook machinery runs Python and -/// has no meaning here, so a file using it still builds and what cannot be -/// honoured is reported rather than ignored. +/// Parsing rejects keys that require tmuxp's Python hooks or plugins. It does +/// not execute or silently discard configuration outside this model. /// public sealed class WorkspaceFile { - /// Gets or sets the session name. - [YamlMember(Alias = "session_name")] - public string? SessionName { get; set; } + private readonly ReadOnlyDictionary _options; + private readonly ReadOnlyCollection _windows; + + /// Initializes a workspace description. + /// The session name. + /// The directory its windows start in. + /// The session options to set. + /// The windows to create, in order. + public WorkspaceFile( + string? sessionName = null, + string? startDirectory = null, + IReadOnlyDictionary? options = null, + IReadOnlyList? windows = null) + { + SessionName = sessionName; + StartDirectory = startDirectory; + _options = WorkspaceCollections.Copy(options, nameof(options)); + _windows = WorkspaceCollections.Copy(windows, nameof(windows)); + } + + /// Gets the session name. + public string? SessionName { get; } - /// Gets or sets the directory every window starts in. - [YamlMember(Alias = "start_directory")] - public string? StartDirectory { get; set; } + /// Gets the directory its windows start in. + public string? StartDirectory { get; } - /// Gets or sets the session options set once the session exists. - [YamlMember(Alias = "options")] - public Dictionary Options { get; set; } = []; + /// Gets the session options to set. + public IReadOnlyDictionary Options => _options; - /// Gets or sets the windows, in the order they are created. - [YamlMember(Alias = "windows")] - public List Windows { get; set; } = []; + /// Gets the windows to create, in order. + public IReadOnlyList Windows => _windows; /// Reads a workspace from tmuxp YAML. - /// The file's contents. - /// The workspace. - /// The text is not a workspace. + /// The file contents. + /// The parsed workspace. + /// + /// The input is too large, malformed, contains more than one document, or + /// uses a key or value shape outside the supported subset. + /// public static WorkspaceFile Parse(string yaml) { ArgumentNullException.ThrowIfNull(yaml); - IDeserializer reader = new DeserializerBuilder() - .WithNamingConvention(UnderscoredNamingConvention.Instance) - .IgnoreUnmatchedProperties() - .Build(); + return WorkspaceYamlParser.Parse(yaml); + } +} - try - { - // tmuxp writes a pane as a bare string when the command is all it - // needs, so the shape is normalised before it is bound. - return reader.Deserialize(Normalize(yaml)) - ?? throw new WorkspaceFormatException("The workspace file is empty."); - } - catch (YamlDotNet.Core.YamlException failure) - { - throw new WorkspaceFormatException( - $"The workspace file could not be read: {failure.Message}", - failure); - } +/// Thrown when a workspace file cannot be read. +public sealed class WorkspaceFormatException : LibTmuxException +{ + /// Initializes the exception. + /// The invalid part of the workspace. + /// The underlying YAML failure, when present. + public WorkspaceFormatException(string message, Exception? innerException = null) + : base(message, innerException) + { } +} - private static string Normalize(string yaml) +internal static class WorkspaceCollections +{ + public static ReadOnlyCollection Copy( + IReadOnlyList? values, + string parameterName) + where T : class { - // A pane written as "- vim" means a pane running vim. Rewriting it to - // the mapping form is what lets one reader handle both spellings. - string[] lines = yaml.Replace("\r\n", "\n", StringComparison.Ordinal).Split('\n'); - List rewritten = new(lines.Length); - bool inPanes = false; - int panesIndent = 0; - - foreach (string line in lines) + T[] copy = values is null ? [] : [.. values]; + if (copy.Any(static value => value is null)) { - string trimmed = line.TrimStart(); - int indent = line.Length - trimmed.Length; - - if (trimmed.StartsWith("panes:", StringComparison.Ordinal)) - { - inPanes = true; - panesIndent = indent; - rewritten.Add(line); - continue; - } + throw new ArgumentException("The collection cannot contain null.", parameterName); + } - if (inPanes && trimmed.Length > 0 && indent <= panesIndent - && !trimmed.StartsWith("- ", StringComparison.Ordinal)) - { - inPanes = false; - } + return Array.AsReadOnly(copy); + } - if (inPanes - && trimmed.StartsWith("- ", StringComparison.Ordinal) - && !trimmed.Contains(": ", StringComparison.Ordinal) - && !trimmed.EndsWith(':')) + public static ReadOnlyDictionary Copy( + IReadOnlyDictionary? values, + string parameterName) + { + Dictionary copy = new(StringComparer.Ordinal); + if (values is not null) + { + foreach ((string key, string value) in values) { - string command = trimmed[2..].Trim(); - rewritten.Add($"{line[..indent]}- shell_command: {command}"); - continue; + if (key is null || value is null) + { + throw new ArgumentException( + "Option names and values cannot be null.", + parameterName); + } + + copy.Add(key, value); } - - rewritten.Add(line); } - return string.Join('\n', rewritten); - } -} - -/// Thrown when a workspace file cannot be read. -public sealed class WorkspaceFormatException : LibTmuxException -{ - /// Initializes the exception. - /// What is wrong with the file. - /// The underlying failure, when any. - public WorkspaceFormatException(string message, Exception? innerException = null) - : base(message, innerException) - { + return new ReadOnlyDictionary(copy); } } diff --git a/src/LibTmux.Workspace/WorkspaceYamlParser.cs b/src/LibTmux.Workspace/WorkspaceYamlParser.cs new file mode 100644 index 0000000..3298c0c --- /dev/null +++ b/src/LibTmux.Workspace/WorkspaceYamlParser.cs @@ -0,0 +1,318 @@ +using System.Globalization; +using YamlDotNet.Core; +using YamlDotNet.RepresentationModel; + +namespace LibTmux.Workspace; + +internal static class WorkspaceYamlParser +{ + internal const int MaximumCharacters = 1_048_576; + + private static readonly string[] RootKeys = + ["session_name", "start_directory", "options", "windows"]; + + private static readonly string[] WindowKeys = + ["window_name", "start_directory", "layout", "focus", "options", "panes"]; + + private static readonly string[] PaneKeys = + ["shell_command", "start_directory", "focus"]; + + public static WorkspaceFile Parse(string yaml) + { + if (yaml.Length > MaximumCharacters) + { + string limit = MaximumCharacters.ToString(CultureInfo.InvariantCulture); + throw new WorkspaceFormatException( + $"The workspace file exceeds the {limit}-character limit."); + } + + try + { + YamlStream stream = new(); + stream.Load(new StringReader(yaml)); + if (stream.Documents.Count == 0) + { + throw new WorkspaceFormatException("The workspace file is empty."); + } + + if (stream.Documents.Count != 1) + { + throw new WorkspaceFormatException( + "The workspace file must contain exactly one YAML document."); + } + + Dictionary root = ReadMapping( + stream.Documents[0].RootNode, + "$", + RootKeys); + + return new WorkspaceFile( + sessionName: ReadOptionalScalar(root, "session_name", "session_name"), + startDirectory: ReadOptionalScalar( + root, + "start_directory", + "start_directory"), + options: ReadOptions(root, "options", "options"), + windows: ReadWindows(root)); + } + catch (WorkspaceFormatException) + { + throw; + } + catch (YamlException failure) + { + throw new WorkspaceFormatException( + $"The workspace file could not be read: {AsSentence(failure.Message)}", + failure); + } + } + + private static WorkspaceWindow[] ReadWindows(Dictionary root) + { + if (!root.TryGetValue("windows", out YamlNode? node)) + { + return []; + } + + YamlSequenceNode sequence = RequireSequence(node, "windows"); + WorkspaceWindow[] windows = new WorkspaceWindow[sequence.Children.Count]; + for (int index = 0; index < windows.Length; index++) + { + string path = $"windows[{index}]"; + Dictionary values = ReadMapping( + sequence.Children[index], + path, + WindowKeys); + + windows[index] = new WorkspaceWindow( + windowName: ReadOptionalScalar(values, "window_name", $"{path}.window_name"), + startDirectory: ReadOptionalScalar( + values, + "start_directory", + $"{path}.start_directory"), + layout: ReadOptionalScalar(values, "layout", $"{path}.layout"), + focus: ReadOptionalBoolean(values, "focus", $"{path}.focus"), + options: ReadOptions(values, "options", $"{path}.options"), + panes: ReadPanes(values, path)); + } + + return windows; + } + + private static WorkspacePane[] ReadPanes( + Dictionary window, + string windowPath) + { + if (!window.TryGetValue("panes", out YamlNode? node)) + { + return []; + } + + string path = $"{windowPath}.panes"; + YamlSequenceNode sequence = RequireSequence(node, path); + WorkspacePane[] panes = new WorkspacePane[sequence.Children.Count]; + for (int index = 0; index < panes.Length; index++) + { + string panePath = $"{path}[{index}]"; + YamlNode pane = sequence.Children[index]; + if (pane is YamlScalarNode scalar) + { + string? command = ReadNullableScalar(scalar); + panes[index] = new WorkspacePane( + shellCommands: command is null ? [] : [command]); + continue; + } + + Dictionary values = ReadMapping(pane, panePath, PaneKeys); + panes[index] = new WorkspacePane( + shellCommands: ReadCommands(values, panePath), + startDirectory: ReadOptionalScalar( + values, + "start_directory", + $"{panePath}.start_directory"), + focus: ReadOptionalBoolean(values, "focus", $"{panePath}.focus")); + } + + return panes; + } + + private static string[] ReadCommands( + Dictionary pane, + string panePath) + { + if (!pane.TryGetValue("shell_command", out YamlNode? node)) + { + return []; + } + + string path = $"{panePath}.shell_command"; + if (node is YamlScalarNode scalar) + { + string? command = ReadNullableScalar(scalar); + return command is null ? [] : [command]; + } + + YamlSequenceNode sequence = RequireSequence(node, path); + List commands = new(sequence.Children.Count); + for (int index = 0; index < sequence.Children.Count; index++) + { + YamlNode command = sequence.Children[index]; + if (command is not YamlScalarNode commandScalar) + { + throw WrongShape($"{path}[{index}]", "a scalar"); + } + + string? commandText = ReadNullableScalar(commandScalar); + if (commandText is not null) + { + commands.Add(commandText); + } + } + + return commands.ToArray(); + } + + private static Dictionary ReadOptions( + Dictionary parent, + string key, + string path) + { + if (!parent.TryGetValue(key, out YamlNode? node)) + { + return new Dictionary(StringComparer.Ordinal); + } + + if (node is not YamlMappingNode mapping) + { + throw WrongShape(path, "a mapping"); + } + + Dictionary options = new(StringComparer.Ordinal); + foreach ((YamlNode optionKey, YamlNode optionValue) in mapping.Children) + { + string name = ReadScalar(optionKey, $"a key in {path}"); + if (!options.TryAdd(name, ReadScalar(optionValue, $"{path}.{name}"))) + { + throw DuplicateKey(path, name); + } + } + + return options; + } + + private static Dictionary ReadMapping( + YamlNode node, + string path, + string[] allowedKeys) + { + if (node is not YamlMappingNode mapping) + { + throw WrongShape(path, "a mapping"); + } + + Dictionary values = new(StringComparer.Ordinal); + foreach ((YamlNode keyNode, YamlNode value) in mapping.Children) + { + string key = ReadScalar(keyNode, $"a key in {path}"); + if (!allowedKeys.Contains(key, StringComparer.Ordinal)) + { + throw new WorkspaceFormatException( + $"Workspace path '{path}' contains unsupported key '{key}'."); + } + + if (!values.TryAdd(key, value)) + { + throw DuplicateKey(path, key); + } + } + + return values; + } + + private static string? ReadOptionalScalar( + Dictionary parent, + string key, + string path) + { + if (!parent.TryGetValue(key, out YamlNode? node)) + { + return null; + } + + if (node is not YamlScalarNode scalar) + { + throw WrongShape(path, "a scalar"); + } + + return ReadNullableScalar(scalar); + } + + private static bool ReadOptionalBoolean( + Dictionary parent, + string key, + string path) + { + if (!parent.TryGetValue(key, out YamlNode? node)) + { + return false; + } + + string value = ReadScalar(node, path); + if (value.Equals("true", StringComparison.OrdinalIgnoreCase) + || value.Equals("yes", StringComparison.OrdinalIgnoreCase) + || value.Equals("on", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + if (value.Equals("false", StringComparison.OrdinalIgnoreCase) + || value.Equals("no", StringComparison.OrdinalIgnoreCase) + || value.Equals("off", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + throw WrongShape(path, "a Boolean"); + } + + private static YamlSequenceNode RequireSequence(YamlNode node, string path) => + node as YamlSequenceNode ?? throw WrongShape(path, "a sequence"); + + private static string ReadScalar(YamlNode node, string path) + { + if (node is not YamlScalarNode scalar + || ReadNullableScalar(scalar) is not string value) + { + throw WrongShape(path, "a non-null scalar"); + } + + return value; + } + + private static string? ReadNullableScalar(YamlScalarNode scalar) + { + if (scalar.Style is ScalarStyle.SingleQuoted + or ScalarStyle.DoubleQuoted + or ScalarStyle.Literal + or ScalarStyle.Folded) + { + return scalar.Value ?? string.Empty; + } + + return scalar.Value switch + { + null or "" or "~" => null, + string value when value.Equals("null", StringComparison.OrdinalIgnoreCase) => null, + string value => value, + }; + } + + private static WorkspaceFormatException WrongShape(string path, string expected) => + new($"Workspace path '{path}' must be {expected}."); + + private static WorkspaceFormatException DuplicateKey(string path, string key) => + new($"Workspace path '{path}' contains duplicate key '{key}'."); + + private static string AsSentence(string message) => + message.EndsWith('.') ? message : $"{message}."; +} diff --git a/tests/LibTmux.IntegrationTests/Workspace/WorkspaceBuilderTests.cs b/tests/LibTmux.IntegrationTests/Workspace/WorkspaceBuilderTests.cs index ad2c2e9..17b972a 100644 --- a/tests/LibTmux.IntegrationTests/Workspace/WorkspaceBuilderTests.cs +++ b/tests/LibTmux.IntegrationTests/Workspace/WorkspaceBuilderTests.cs @@ -27,7 +27,9 @@ public sealed class WorkspaceBuilderTests focus: true - window_name: shell panes: - - echo bare-pane + - shell_command: + - echo command-one + - echo command-two """; [UnixFact] @@ -55,7 +57,7 @@ public async Task A_workspace_file_becomes_a_session() token)) .Value.Raw); - // A pane written as a bare string is a pane running that command. + // Command lists run in order in the same pane. IReadOnlyList editor = await result.Windows[0].GetPanesAsync(token); IReadOnlyList shell = await result.Windows[1].GetPanesAsync(token); Assert.Equal(2, editor.Count); @@ -65,11 +67,15 @@ public async Task A_workspace_file_becomes_a_session() async cancellation => string.Join( '\n', await shell[0].CaptureAsync(cancellationToken: cancellation)), - captured => captured.Contains("bare-pane", StringComparison.Ordinal), + captured => captured.Contains("command-two", StringComparison.Ordinal), TimeSpan.FromSeconds(10), TimeSpan.FromMilliseconds(20), token); - Assert.Contains("bare-pane", text, StringComparison.Ordinal); + Assert.Contains("command-one", text, StringComparison.Ordinal); + Assert.Contains("command-two", text, StringComparison.Ordinal); + Assert.True( + text.IndexOf("command-one", StringComparison.Ordinal) + < text.IndexOf("command-two", StringComparison.Ordinal)); // The file asks for nothing tmux alone cannot do, so nothing is // reported as unsupported. @@ -105,6 +111,38 @@ public async Task What_tmux_cannot_do_is_reported_rather_than_dropped() message => message.Contains("not-a-layout", StringComparison.Ordinal)); } + [UnixFact] + public async Task Each_workspace_command_receives_one_enter() + { + CancellationToken token = TestContext.Current.CancellationToken; + TmuxTestFactory factory = new(); + await using TemporaryServerScope scope = await factory.CreateServerAsync( + HarnessOptions(), + token); + + WorkspaceFile workspace = WorkspaceFile.Parse(""" + session_name: libtmux-single-enter + windows: + - panes: + - shell_command: 'printf "ready\n"; read value; printf "got=<%s>\n" "$value"' + """); + WorkspaceResult result = await new WorkspaceBuilder(scope.Server) + .BuildAsync(workspace, token); + Pane pane = Assert.Single(await Assert.Single(result.Windows).GetPanesAsync(token)); + + bool receivedBlankLine = await TmuxWait.UntilAsync( + async cancellation => string.Join( + '\n', + await pane.CaptureAsync(cancellationToken: cancellation)) + .Contains("got=<>", StringComparison.Ordinal), + TimeSpan.FromSeconds(1), + TimeSpan.FromMilliseconds(20), + throwOnTimeout: false, + token); + + Assert.False(receivedBlankLine); + } + [UnixFact] public void A_file_that_is_not_a_workspace_is_refused() { diff --git a/tests/LibTmux.IntegrationTests/Workspace/WorkspaceFileTests.cs b/tests/LibTmux.IntegrationTests/Workspace/WorkspaceFileTests.cs new file mode 100644 index 0000000..273cdda --- /dev/null +++ b/tests/LibTmux.IntegrationTests/Workspace/WorkspaceFileTests.cs @@ -0,0 +1,109 @@ +using LibTmux.Workspace; + +namespace LibTmux.IntegrationTests; + +public sealed class WorkspaceFileTests +{ + public static TheoryData InvalidShapes => + new() + { + "- not\n- a\n- mapping\n", + "session_name: wrong-windows\nwindows: one\n", + "session_name: null-windows\nwindows: null\n", + "session_name: wrong-window\nwindows:\n - one\n", + "session_name: wrong-panes\nwindows:\n - panes: one\n", + "session_name: null-panes\nwindows:\n - panes: null\n", + "session_name: wrong-options\noptions:\n - one\n", + "session_name: wrong-focus\nwindows:\n - focus: perhaps\n", + "session_name: wrong-command\nwindows:\n - panes:\n - shell_command:\n command: one\n", + }; + + [Fact] + public void Pane_spellings_preserve_command_text_and_order() + { + WorkspaceFile workspace = WorkspaceFile.Parse(""" + session_name: quoted-commands + windows: + - window_name: shell + panes: + - 'printf "scalar: value # literal"' + - shell_command: 'printf "mapping: value # literal"' + - shell_command: + - 'printf "first: value # literal"' + - 'printf "second: value # literal"' + - shell_command: + - shell_command: + - + - '' + """); + + IReadOnlyList panes = Assert.Single(workspace.Windows).Panes; + Assert.Equal(["printf \"scalar: value # literal\""], panes[0].ShellCommands); + Assert.Equal(["printf \"mapping: value # literal\""], panes[1].ShellCommands); + Assert.Equal( + [ + "printf \"first: value # literal\"", + "printf \"second: value # literal\"", + ], + panes[2].ShellCommands); + Assert.Empty(panes[3].ShellCommands); + Assert.Equal([string.Empty], panes[4].ShellCommands); + } + + [Theory] + [MemberData(nameof(InvalidShapes))] + public void Wrong_value_shapes_are_refused(string yaml) => + Assert.Throws(() => WorkspaceFile.Parse(yaml)); + + [Theory] + [InlineData("before_script: echo no\nwindows: []\n", "$", "before_script")] + [InlineData("windows:\n - panes:\n - plugin: no\n", "windows[0].panes[0]", "plugin")] + public void Unsupported_keys_report_their_path( + string yaml, + string path, + string key) + { + WorkspaceFormatException failure = Assert.Throws( + () => WorkspaceFile.Parse(yaml)); + + Assert.Contains(path, failure.Message, StringComparison.Ordinal); + Assert.Contains(key, failure.Message, StringComparison.Ordinal); + } + + [Fact] + public void Duplicate_keys_are_refused() + { + WorkspaceFormatException failure = Assert.Throws( + () => WorkspaceFile.Parse("windows: []\nwindows: []\n")); + + Assert.Contains("duplicate", failure.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void One_bounded_document_is_required() + { + Assert.Throws(() => WorkspaceFile.Parse(string.Empty)); + Assert.Throws(() => WorkspaceFile.Parse("--- {}\n--- {}\n")); + Assert.Throws( + () => WorkspaceFile.Parse(new string(' ', WorkspaceYamlParser.MaximumCharacters + 1))); + } + + [Fact] + public void Workspace_values_copy_input_collections() + { + List commands = ["echo one"]; + Dictionary options = new() { ["base-index"] = "1" }; + List panes = [new WorkspacePane(commands)]; + List windows = [new WorkspaceWindow(options: options, panes: panes)]; + WorkspaceFile workspace = new(options: options, windows: windows); + + commands[0] = "echo changed"; + options["base-index"] = "2"; + panes.Clear(); + windows.Clear(); + + Assert.Equal(["echo one"], workspace.Windows[0].Panes[0].ShellCommands); + Assert.Equal("1", workspace.Options["base-index"]); + Assert.Equal("1", workspace.Windows[0].Options["base-index"]); + } +} From 66e1781a1236f1de193d53f80a046aa21dbaf8fe Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 07:14:14 -0500 Subject: [PATCH 022/129] Workspace(fix[builder]): Match tmuxp selection semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Window creation ignored the first pane directory, and duplicate focus flags selected the first entry instead of tmuxp’s last entry. what: - Create each window in its first pane directory when one is specified - Let the last focused window and pane win - Cover both behaviors against current tmux and tmux 3.2a --- src/LibTmux.Workspace/WorkspaceBuilder.cs | 15 ++-- .../Workspace/WorkspaceBuilderTests.cs | 72 +++++++++++++++++++ 2 files changed, 83 insertions(+), 4 deletions(-) diff --git a/src/LibTmux.Workspace/WorkspaceBuilder.cs b/src/LibTmux.Workspace/WorkspaceBuilder.cs index c9cd637..1fe9f8c 100644 --- a/src/LibTmux.Workspace/WorkspaceBuilder.cs +++ b/src/LibTmux.Workspace/WorkspaceBuilder.cs @@ -54,7 +54,7 @@ public async Task BuildAsync( new NewSessionRequest( name: workspace.SessionName, windowName: first.WindowName, - startDirectory: first.StartDirectory ?? workspace.StartDirectory), + startDirectory: StartDirectoryFor(first, workspace)), cancellationToken) .ConfigureAwait(false); @@ -72,7 +72,7 @@ await ApplyOptionsAsync(session.Options, workspace.Options, cancellationToken) Window window = await session.CreateWindowAsync( new NewWindowRequest( name: described.WindowName, - startDirectory: described.StartDirectory ?? workspace.StartDirectory), + startDirectory: StartDirectoryFor(described, workspace)), cancellationToken) .ConfigureAwait(false); windows.Add( @@ -86,6 +86,13 @@ await FillAsync(window, described, workspace, unsupported, cancellationToken) return new WorkspaceResult(session, windows, unsupported); } + private static string? StartDirectoryFor( + WorkspaceWindow window, + WorkspaceFile workspace) => + (window.Panes.Count == 0 ? null : window.Panes[0].StartDirectory) + ?? window.StartDirectory + ?? workspace.StartDirectory; + private static async Task ApplyOptionsAsync( TmuxOptions options, IReadOnlyDictionary described, @@ -103,7 +110,7 @@ private static async Task SelectFocusedAsync( List windows, CancellationToken cancellationToken) { - for (int index = 0; index < workspace.Windows.Count; index++) + for (int index = workspace.Windows.Count - 1; index >= 0; index--) { if (!workspace.Windows[index].Focus) { @@ -172,7 +179,7 @@ await target.SendTextAsync(command, cancellationToken: cancellationToken) await ApplyOptionsAsync(window.Options, described.Options, cancellationToken) .ConfigureAwait(false); - for (int index = 0; index < described.Panes.Count; index++) + for (int index = described.Panes.Count - 1; index >= 0; index--) { if (!described.Panes[index].Focus) { diff --git a/tests/LibTmux.IntegrationTests/Workspace/WorkspaceBuilderTests.cs b/tests/LibTmux.IntegrationTests/Workspace/WorkspaceBuilderTests.cs index 17b972a..508c029 100644 --- a/tests/LibTmux.IntegrationTests/Workspace/WorkspaceBuilderTests.cs +++ b/tests/LibTmux.IntegrationTests/Workspace/WorkspaceBuilderTests.cs @@ -143,6 +143,78 @@ await pane.CaptureAsync(cancellationToken: cancellation)) Assert.False(receivedBlankLine); } + [UnixFact] + public async Task First_pane_directory_controls_window_creation() + { + CancellationToken token = TestContext.Current.CancellationToken; + TmuxTestFactory factory = new(); + await using TemporaryServerScope scope = await factory.CreateServerAsync( + HarnessOptions(), + token); + + WorkspaceFile workspace = WorkspaceFile.Parse(""" + session_name: libtmux-pane-directory + start_directory: /tmp + windows: + - panes: + - start_directory: /usr + shell_command: pwd + - start_directory: /etc + shell_command: pwd + """); + WorkspaceResult result = await new WorkspaceBuilder(scope.Server) + .BuildAsync(workspace, token); + IReadOnlyList panes = await Assert.Single(result.Windows).GetPanesAsync(token); + + IReadOnlyList first = await TmuxWait.UntilAsync( + cancellation => panes[0].CaptureAsync(cancellationToken: cancellation), + lines => lines.Contains("/usr", StringComparer.Ordinal), + TimeSpan.FromSeconds(10), + TimeSpan.FromMilliseconds(20), + token); + IReadOnlyList second = await TmuxWait.UntilAsync( + cancellation => panes[1].CaptureAsync(cancellationToken: cancellation), + lines => lines.Contains("/etc", StringComparer.Ordinal), + TimeSpan.FromSeconds(10), + TimeSpan.FromMilliseconds(20), + token); + + Assert.Contains("/usr", first); + Assert.Contains("/etc", second); + } + + [UnixFact] + public async Task Last_focused_window_and_pane_win() + { + CancellationToken token = TestContext.Current.CancellationToken; + TmuxTestFactory factory = new(); + await using TemporaryServerScope scope = await factory.CreateServerAsync( + HarnessOptions(), + token); + + WorkspaceFile workspace = WorkspaceFile.Parse(""" + session_name: libtmux-last-focus + windows: + - window_name: first + focus: true + panes: + - + - window_name: second + focus: true + panes: + - focus: true + - focus: true + """); + WorkspaceResult result = await new WorkspaceBuilder(scope.Server) + .BuildAsync(workspace, token); + Session session = await result.Session.RefreshAsync(token); + Window window = await result.Windows[1].RefreshAsync(token); + IReadOnlyList panes = await window.GetPanesAsync(token); + + Assert.Equal(result.Windows[1].Id, session.ActiveWindow.Id); + Assert.Equal(panes[1].Id, window.ActivePane.Id); + } + [UnixFact] public void A_file_that_is_not_a_workspace_is_refused() { From 1464459810ba88a236b57b2569e8231ace34ff8c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 07:16:59 -0500 Subject: [PATCH 023/129] Query(fix[catalog]): Separate scalar and relation roles why: session_windows and window_panes are numeric tmux values in row projections but relations on materialized entities. what: - track scalar value kind independently from relation eligibility - restore numeric count comparisons without opening quantifiers - align the v1 JSON schema and regression coverage --- .../FieldCatalogGenerator.cs | 46 ++++++++++-------- .../libtmux-query-v1.schema.json | 48 +++++++++++++++++++ src/LibTmux/Query/QueryDocumentValidator.cs | 5 +- src/LibTmux/Query/QueryValueKind.cs | 1 - .../LibTmux.UnitTests/Query/QueryJsonTests.cs | 6 +++ .../Query/QuerySemanticsTests.cs | 15 ++++-- 6 files changed, 93 insertions(+), 28 deletions(-) diff --git a/src/LibTmux.Generators/FieldCatalogGenerator.cs b/src/LibTmux.Generators/FieldCatalogGenerator.cs index b252910..01e6c31 100644 --- a/src/LibTmux.Generators/FieldCatalogGenerator.cs +++ b/src/LibTmux.Generators/FieldCatalogGenerator.cs @@ -19,21 +19,25 @@ public sealed class FieldCatalogGenerator : IIncrementalGenerator /// not systematic (client_controlIsControlClient, and two /// fields have no property at all). /// - private static readonly (string WireName, string Target, string Kind, string? Property)[] - Fields = + private static readonly ( + string WireName, + string Target, + string Kind, + bool Relation, + string? Property)[] Fields = { - ("client_control", "Client", "Boolean", "IsControlClient"), - ("client_id", "Client", "TypedId", null), - ("client_name", "Client", "String", "Name"), - ("pane_command", "Pane", "String", null), - ("pane_id", "Pane", "TypedId", "Id"), - ("session_attached", "Session", "Boolean", "Attached"), - ("session_id", "Session", "TypedId", "Id"), - ("session_name", "Session", "String", "Name"), - ("session_windows", "Session", "Relation", "Windows"), - ("window_id", "Window", "TypedId", "Id"), - ("window_name", "Window", "String", "Name"), - ("window_panes", "Window", "Relation", "Panes"), + ("client_control", "Client", "Boolean", false, "IsControlClient"), + ("client_id", "Client", "TypedId", false, null), + ("client_name", "Client", "String", false, "Name"), + ("pane_command", "Pane", "String", false, null), + ("pane_id", "Pane", "TypedId", false, "Id"), + ("session_attached", "Session", "Boolean", false, "Attached"), + ("session_id", "Session", "TypedId", false, "Id"), + ("session_name", "Session", "String", false, "Name"), + ("session_windows", "Session", "Int64", true, "Windows"), + ("window_id", "Window", "TypedId", false, "Id"), + ("window_name", "Window", "String", false, "Name"), + ("window_panes", "Window", "Int64", true, "Panes"), }; /// @@ -56,9 +60,9 @@ private static string Render() source.AppendLine( " internal static bool IsRelation(string wireName) => wireName switch"); source.AppendLine(" {"); - foreach ((string wireName, _, string kind, _) in Fields) + foreach ((string wireName, _, _, bool relation, _) in Fields) { - if (kind == "Relation") + if (relation) { source.AppendLine($" \"{wireName}\" => true,"); } @@ -72,7 +76,7 @@ private static string Render() source.AppendLine(" {"); source.AppendLine(" switch (wireName)"); source.AppendLine(" {"); - foreach ((string wireName, string target, _, _) in Fields) + foreach ((string wireName, string target, _, _, _) in Fields) { source.AppendLine($" case \"{wireName}\":"); source.AppendLine($" target = QueryTarget.{target};"); @@ -90,7 +94,7 @@ private static string Render() source.AppendLine(" {"); source.AppendLine(" switch (wireName)"); source.AppendLine(" {"); - foreach ((string wireName, _, string kind, _) in Fields) + foreach ((string wireName, _, string kind, _, _) in Fields) { source.AppendLine($" case \"{wireName}\":"); source.AppendLine($" kind = QueryValueKind.{kind};"); @@ -109,7 +113,7 @@ private static string Render() source.AppendLine(" {"); source.AppendLine(" switch (owner + \".\" + property)"); source.AppendLine(" {"); - foreach ((string wireName, string target, _, string? property) in Fields) + foreach ((string wireName, string target, _, _, string? property) in Fields) { if (property is null) { @@ -133,7 +137,7 @@ private static string Render() source.AppendLine(" {"); source.AppendLine(" switch (owner + \".\" + wireName)"); source.AppendLine(" {"); - foreach ((string wireName, string target, _, string? property) in Fields) + foreach ((string wireName, string target, _, _, string? property) in Fields) { if (property is null) { @@ -153,7 +157,7 @@ private static string Render() source.AppendLine(); source.AppendLine(" internal static IReadOnlyList WireNames { get; } ="); source.AppendLine(" ["); - foreach ((string wireName, _, _, _) in Fields) + foreach ((string wireName, _, _, _, _) in Fields) { source.AppendLine($" \"{wireName}\","); } diff --git a/src/LibTmux.Query.Json/libtmux-query-v1.schema.json b/src/LibTmux.Query.Json/libtmux-query-v1.schema.json index adf694e..ea0f66d 100644 --- a/src/LibTmux.Query.Json/libtmux-query-v1.schema.json +++ b/src/LibTmux.Query.Json/libtmux-query-v1.schema.json @@ -134,6 +134,16 @@ } ] }, + "int64Field": { + "allOf": [ + { "$ref": "#/$defs/field" }, + { + "properties": { + "wireName": { "enum": ["session_windows", "window_panes"] } + } + } + ] + }, "relationField": { "allOf": [ { "$ref": "#/$defs/field" }, @@ -340,6 +350,15 @@ "value": { "$ref": "#/$defs/booleanConstant" } } }, + "int64ConstantNode": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "value"], + "properties": { + "kind": { "const": "constant" }, + "value": { "$ref": "#/$defs/int64Constant" } + } + }, "stringConstantNode": { "type": "object", "additionalProperties": false, @@ -355,6 +374,12 @@ { "$ref": "#/$defs/nullConstantNode" } ] }, + "int64OrNullConstantNode": { + "oneOf": [ + { "$ref": "#/$defs/int64ConstantNode" }, + { "$ref": "#/$defs/nullConstantNode" } + ] + }, "stringOrNullConstantNode": { "oneOf": [ { "$ref": "#/$defs/stringConstantNode" }, @@ -537,6 +562,29 @@ "right": { "$ref": "#/$defs/stringOrNullConstantNode" } } }, + { + "title": "int64 equality", + "properties": { + "operator": { "enum": ["equal", "notEqual"] }, + "left": { "$ref": "#/$defs/int64Field" }, + "right": { "$ref": "#/$defs/int64OrNullConstantNode" } + } + }, + { + "title": "int64 ordering", + "properties": { + "operator": { + "enum": [ + "lessThan", + "lessThanOrEqual", + "greaterThan", + "greaterThanOrEqual" + ] + }, + "left": { "$ref": "#/$defs/int64Field" }, + "right": { "$ref": "#/$defs/int64ConstantNode" } + } + }, { "title": "ordinal string operation", "properties": { diff --git a/src/LibTmux/Query/QueryDocumentValidator.cs b/src/LibTmux/Query/QueryDocumentValidator.cs index 1668f55..451903a 100644 --- a/src/LibTmux/Query/QueryDocumentValidator.cs +++ b/src/LibTmux/Query/QueryDocumentValidator.cs @@ -138,8 +138,9 @@ private static void ValidateQuantifier( QuantifierNode quantifier, QueryTarget expectedTarget) { + _ = ResolveField(quantifier.Relation, expectedTarget); if (quantifier.Quantifier is not QueryQuantifier.Any and not QueryQuantifier.All - || ResolveField(quantifier.Relation, expectedTarget) != QueryValueKind.Relation) + || !QueryFieldCatalog.IsRelation(quantifier.Relation.WireName)) { throw Unsupported("Quantifier does not name a supported relation."); } @@ -173,7 +174,7 @@ private static void ValidateConstant( { bool compatible = constant switch { - NullConstant => kind != QueryValueKind.Relation, + NullConstant => true, BooleanConstant => kind == QueryValueKind.Boolean, Int64Constant => kind == QueryValueKind.Int64, StringConstant text => diff --git a/src/LibTmux/Query/QueryValueKind.cs b/src/LibTmux/Query/QueryValueKind.cs index d4a6d7c..6b0194b 100644 --- a/src/LibTmux/Query/QueryValueKind.cs +++ b/src/LibTmux/Query/QueryValueKind.cs @@ -8,5 +8,4 @@ internal enum QueryValueKind TypedId, Enum, Instant, - Relation, } diff --git a/tests/LibTmux.UnitTests/Query/QueryJsonTests.cs b/tests/LibTmux.UnitTests/Query/QueryJsonTests.cs index 5490f73..60be49d 100644 --- a/tests/LibTmux.UnitTests/Query/QueryJsonTests.cs +++ b/tests/LibTmux.UnitTests/Query/QueryJsonTests.cs @@ -9,6 +9,8 @@ public sealed class QueryJsonTests { private sealed record Row(string SessionName, bool SessionAttached); + private sealed record SessionCountRow(string SessionName, long SessionWindows); + private static readonly FieldNode SessionName = new(QueryTarget.Session, "session_name"); @@ -32,6 +34,10 @@ private sealed record Row(string SessionName, bool SessionAttached); QueryExtensions.Translate( row => row.SessionName == "a" || row.SessionAttached) }, + { + "numeric-comparison", + QueryExtensions.Translate(row => row.SessionWindows > 1) + }, { "legacy-name-contains", QueryEdgeParser.ParseNameContains(QueryTarget.Window, "log") }, }; diff --git a/tests/LibTmux.UnitTests/Query/QuerySemanticsTests.cs b/tests/LibTmux.UnitTests/Query/QuerySemanticsTests.cs index de47e26..2ebd5c5 100644 --- a/tests/LibTmux.UnitTests/Query/QuerySemanticsTests.cs +++ b/tests/LibTmux.UnitTests/Query/QuerySemanticsTests.cs @@ -7,7 +7,9 @@ public sealed class QuerySemanticsTests { private sealed record Row(string SessionName, bool SessionAttached); - private sealed record MismatchedRow(string SessionName, long SessionWindows); + private sealed record SessionCountRow(string SessionName, long SessionWindows); + + private sealed record WindowCountRow(string WindowName, long WindowPanes); [Fact] public void An_entity_translates_through_the_name_tmux_uses_for_the_field() @@ -123,10 +125,15 @@ public void Translation_refuses_a_field_outside_the_closed_catalog() } [Fact] - public void Translation_refuses_a_projection_that_changes_a_field_kind() + public void Relation_fields_keep_their_scalar_tmux_value_in_row_projections() { - Assert.Throws( - () => QueryExtensions.Translate(row => row.SessionWindows > 1)); + QueryDocument sessions = QueryExtensions.Translate( + row => row.SessionWindows > 1); + QueryDocument panes = QueryExtensions.Translate( + row => row.WindowPanes == 2); + + Assert.True(sessions.Compile()(new SessionCountRow("dev", 2))); + Assert.True(panes.Compile()(new WindowCountRow("main", 2))); } [Fact] From 3565550e920c5609f4fb0f22779af4593eb9be21 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 07:17:11 -0500 Subject: [PATCH 024/129] QueryJson(fix[schema]): Remove phantom string operators why: the v1 schema advertised three operators that every reader and writer rejects. what: - remove unsupported ignore-case prefix, suffix, and contains tokens - keep the schema aligned with the closed v1 wire vocabulary --- src/LibTmux.Query.Json/libtmux-query-v1.schema.json | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/LibTmux.Query.Json/libtmux-query-v1.schema.json b/src/LibTmux.Query.Json/libtmux-query-v1.schema.json index ea0f66d..f050dfb 100644 --- a/src/LibTmux.Query.Json/libtmux-query-v1.schema.json +++ b/src/LibTmux.Query.Json/libtmux-query-v1.schema.json @@ -593,11 +593,8 @@ "stringEqualOrdinal", "stringEqualOrdinalIgnoreCase", "startsWithOrdinal", - "startsWithOrdinalIgnoreCase", "endsWithOrdinal", - "endsWithOrdinalIgnoreCase", - "containsOrdinal", - "containsOrdinalIgnoreCase" + "containsOrdinal" ] }, "left": { "$ref": "#/$defs/stringField" }, From 9026ac12321fe5b9e3c3b960a9a15135a0ac9ea6 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 07:19:42 -0500 Subject: [PATCH 025/129] Query(fix[translation]): Preserve overload semantics why: culture-sensitive string overloads and regex timeouts were translated into narrower predicates without warning. what: - require explicit ordinal semantics for StartsWith and EndsWith - retain the ordinal one-argument Contains overload - reject Regex.IsMatch overloads whose timeout cannot cross the wire - update executable examples and parity coverage --- README.md | 5 +-- src/LibTmux.Query.Json/README.md | 5 +-- src/LibTmux/Query/QueryTranslator.cs | 28 +++++++++------ src/LibTmux/README.md | 9 +++-- .../Parity/Component08ParityTests.cs | 6 ++-- .../Query/EntityFilterTests.cs | 7 ++-- .../LibTmux.UnitTests/Query/QueryJsonTests.cs | 3 +- .../Query/QuerySemanticsTests.cs | 35 ++++++++++++++++--- 8 files changed, 70 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index d684cfd..270a4e7 100644 --- a/README.md +++ b/README.md @@ -170,7 +170,7 @@ it over the objects you already hold: ```csharp run IReadOnlyList sessions = await server.GetSessionsAsync(ct); IReadOnlyList building = sessions.Matching( - session => session.Name.StartsWith("build")); + session => session.Name.StartsWith("build", StringComparison.Ordinal)); ``` The same expression is also a document, which can be written here and answered @@ -178,7 +178,8 @@ somewhere else: ```csharp run QueryDocument document = QueryExtensions.Translate( - session => session.Name.StartsWith("build") && session.Attached); + session => session.Name.StartsWith("build", StringComparison.Ordinal) + && session.Attached); Console.WriteLine(document.Target); // Session ``` diff --git a/src/LibTmux.Query.Json/README.md b/src/LibTmux.Query.Json/README.md index 69f8918..b7e8cc2 100644 --- a/src/LibTmux.Query.Json/README.md +++ b/src/LibTmux.Query.Json/README.md @@ -28,7 +28,8 @@ document that travels: ```csharp run QueryDocument document = QueryExtensions.Translate( - session => session.Name.StartsWith("build") && session.Attached); + session => session.Name.StartsWith("build", StringComparison.Ordinal) + && session.Attached); string wire = QueryJson.Serialize(document); QueryDocument parsed = QueryJson.Deserialize(wire); @@ -75,7 +76,7 @@ The same document filters what you already hold, wherever it was written: ```csharp run // However this arrived — an argument, a request body, a stored filter. string received = QueryJson.Serialize(QueryExtensions.Translate( - session => session.Name.StartsWith("build"))); + session => session.Name.StartsWith("build", StringComparison.Ordinal))); IReadOnlyList sessions = await server.GetSessionsAsync(ct); IReadOnlyList matched = sessions.Matching(QueryJson.Deserialize(received)); diff --git a/src/LibTmux/Query/QueryTranslator.cs b/src/LibTmux/Query/QueryTranslator.cs index 497bbf9..b9af19f 100644 --- a/src/LibTmux/Query/QueryTranslator.cs +++ b/src/LibTmux/Query/QueryTranslator.cs @@ -124,18 +124,23 @@ private static QueryNode TranslateCall( "Contains" => QueryStringOperation.ContainsOrdinal, _ => throw Unsupported(call), }; - // The wire form is ordinal, so only the overload naming - // StringComparison.Ordinal is accepted -- the same one CA1310 asks - // callers to write. A culture-sensitive overload has no wire form and - // throws instead of silently meaning something else. - if (call.Object is null || call.Arguments.Count is not (1 or 2)) + if (call.Method.DeclaringType != typeof(string) || call.Object is null) { throw Unsupported(call); } - if (call.Arguments.Count == 2 - && !(TryConstant(call.Arguments[1], out object? comparison) - && comparison is StringComparison.Ordinal)) + if (call.Arguments.Count == 1) + { + // Contains(string) is ordinal. The one-argument StartsWith and + // EndsWith overloads use the current culture and have no v1 wire form. + if (operation != QueryStringOperation.ContainsOrdinal) + { + throw Unsupported(call); + } + } + else if (call.Arguments.Count != 2 + || !TryConstant(call.Arguments[1], out object? comparison) + || comparison is not StringComparison.Ordinal) { throw Unsupported(call); } @@ -150,10 +155,11 @@ private static RegexNode TranslateRegex( MethodCallExpression call, ParameterExpression parameter) { - if (call.Arguments.Count < 2 || !TryConstant(call.Arguments[1], out object? pattern)) + if (call.Arguments.Count is not (2 or 3) + || !TryConstant(call.Arguments[1], out object? pattern)) { - // A non-constant pattern cannot be carried on the wire, and - // compiling it locally would diverge from the document. + // A non-constant pattern or explicit timeout cannot be carried on + // the wire, and dropping either would change the predicate. throw Unsupported(call); } diff --git a/src/LibTmux/README.md b/src/LibTmux/README.md index d1d1dc5..c1e1071 100644 --- a/src/LibTmux/README.md +++ b/src/LibTmux/README.md @@ -194,7 +194,8 @@ objects you already hold: ```csharp run IReadOnlyList sessions = await server.GetSessionsAsync(ct); IReadOnlyList building = sessions.Matching( - session => session.Name.StartsWith("build") && session.Attached); + session => session.Name.StartsWith("build", StringComparison.Ordinal) + && session.Attached); ``` Relations quantify, and the element type carries its own fields: @@ -202,7 +203,8 @@ Relations quantify, and the element type carries its own fields: ```csharp run Server captured = await server.CaptureSnapshotAsync(SnapshotDepth.Windows, ct); IReadOnlyList withBuild = captured.Sessions.Matching( - session => session.Windows.Any(each => each.Name.StartsWith("build"))); + session => session.Windows.Any( + each => each.Name.StartsWith("build", StringComparison.Ordinal))); ``` The same expression is also a document, which can be written here and answered @@ -210,7 +212,8 @@ somewhere else: ```csharp run QueryDocument document = QueryExtensions.Translate( - session => session.Name.StartsWith("build") && session.Attached); + session => session.Name.StartsWith("build", StringComparison.Ordinal) + && session.Attached); ``` You write C# and tmux receives tmux. The catalog carries the pair for all diff --git a/tests/LibTmux.IntegrationTests/Parity/Component08ParityTests.cs b/tests/LibTmux.IntegrationTests/Parity/Component08ParityTests.cs index 61cbdb0..349eee7 100644 --- a/tests/LibTmux.IntegrationTests/Parity/Component08ParityTests.cs +++ b/tests/LibTmux.IntegrationTests/Parity/Component08ParityTests.cs @@ -125,8 +125,10 @@ private static bool ProvesNameContains() private static bool ProvesExpressionVocabulary() { IReadOnlyList rows = [new SessionRow("devbox", 2)]; - return rows.Matching(row => row.SessionName.StartsWith("dev")).Count == 1 - && rows.Matching(row => row.SessionName.EndsWith("box")).Count == 1 + return rows.Matching( + row => row.SessionName.StartsWith("dev", StringComparison.Ordinal)).Count == 1 + && rows.Matching( + row => row.SessionName.EndsWith("box", StringComparison.Ordinal)).Count == 1 && rows.Matching(row => row.SessionWindows >= 2).Count == 1 && rows.Matching(row => !row.SessionName.Contains("prod")).Count == 1; } diff --git a/tests/LibTmux.IntegrationTests/Query/EntityFilterTests.cs b/tests/LibTmux.IntegrationTests/Query/EntityFilterTests.cs index f1daaf0..0bd357f 100644 --- a/tests/LibTmux.IntegrationTests/Query/EntityFilterTests.cs +++ b/tests/LibTmux.IntegrationTests/Query/EntityFilterTests.cs @@ -28,7 +28,7 @@ public async Task A_predicate_over_sessions_matches_what_tmux_reports() IReadOnlyList sessions = await scope.Server.GetSessionsAsync(token); IReadOnlyList building = sessions.Matching( - session => session.Name.StartsWith("build")); + session => session.Name.StartsWith("build", StringComparison.Ordinal)); Assert.Equal(2, building.Count); Assert.All(building, session => Assert.StartsWith("build", session.Name, StringComparison.Ordinal)); @@ -46,11 +46,12 @@ public async Task The_document_a_predicate_became_filters_the_same_way() // The point of a document is that it can be written somewhere else and // still mean this, so the two paths have to agree. QueryDocument document = QueryExtensions.Translate( - window => window.Name.StartsWith("build")); + window => window.Name.StartsWith("build", StringComparison.Ordinal)); IReadOnlyList windows = await scope.Session.GetWindowsAsync(token); Assert.Equal( - windows.Matching(window => window.Name.StartsWith("build")).Count, + windows.Matching( + window => window.Name.StartsWith("build", StringComparison.Ordinal)).Count, windows.Matching(document).Count); Assert.Single(windows.Matching(document)); } diff --git a/tests/LibTmux.UnitTests/Query/QueryJsonTests.cs b/tests/LibTmux.UnitTests/Query/QueryJsonTests.cs index 60be49d..c65c2b4 100644 --- a/tests/LibTmux.UnitTests/Query/QueryJsonTests.cs +++ b/tests/LibTmux.UnitTests/Query/QueryJsonTests.cs @@ -23,7 +23,8 @@ private sealed record SessionCountRow(string SessionName, long SessionWindows); { "string-and-comparison", QueryExtensions.Translate( - row => row.SessionName.StartsWith("dev") && row.SessionAttached) + row => row.SessionName.StartsWith("dev", StringComparison.Ordinal) + && row.SessionAttached) }, { "negated-contains", diff --git a/tests/LibTmux.UnitTests/Query/QuerySemanticsTests.cs b/tests/LibTmux.UnitTests/Query/QuerySemanticsTests.cs index 2ebd5c5..3678e22 100644 --- a/tests/LibTmux.UnitTests/Query/QuerySemanticsTests.cs +++ b/tests/LibTmux.UnitTests/Query/QuerySemanticsTests.cs @@ -19,7 +19,7 @@ public void An_entity_translates_through_the_name_tmux_uses_for_the_field() Assert.Equal( "session_name", Field(QueryExtensions.Translate( - session => session.Name.StartsWith("build")))); + session => session.Name.StartsWith("build", StringComparison.Ordinal)))); Assert.Equal( "session_attached", Field(QueryExtensions.Translate(session => session.Attached))); @@ -51,7 +51,8 @@ public void A_row_a_caller_declares_still_names_the_wire_fields_itself() // the wire names already. Both spellings reach the same document. Assert.Equal( "session_name", - Field(QueryExtensions.Translate(row => row.SessionName.StartsWith("dev")))); + Field(QueryExtensions.Translate( + row => row.SessionName.StartsWith("dev", StringComparison.Ordinal)))); } [Fact] @@ -76,7 +77,8 @@ public void A_property_outside_the_catalog_still_refuses_to_translate() public void Matching_translates_and_interprets_the_canonical_AST() { QueryDocument document = QueryExtensions.Translate( - row => row.SessionName.StartsWith("dev") && row.SessionAttached); + row => row.SessionName.StartsWith("dev", StringComparison.Ordinal) + && row.SessionAttached); Assert.Equal(QueryDocument.CurrentSchema, document.Schema); Assert.Equal(QueryDocument.CurrentVersion, document.Version); @@ -108,7 +110,9 @@ public void Matching_translates_and_interprets_the_canonical_AST() { new Row("devbox", true), new Row("prod", true), - }.Matching(row => row.SessionName.StartsWith("dev") && row.SessionAttached); + }.Matching( + row => row.SessionName.StartsWith("dev", StringComparison.Ordinal) + && row.SessionAttached); Assert.Single(matched); Assert.Equal("devbox", matched[0].SessionName); } @@ -157,12 +161,35 @@ public void Translation_refuses_an_unsupported_node_rather_than_evaluating_it() () => QueryExtensions.Translate(row => row.SessionName.Trim() == "X")); } + [Fact] + public void String_translation_preserves_the_selected_comparison_semantics() + { + Assert.Throws( + () => QueryExtensions.Translate(row => row.SessionName.StartsWith("dev"))); + Assert.Throws( + () => QueryExtensions.Translate(row => row.SessionName.EndsWith("box"))); + + QueryDocument contains = QueryExtensions.Translate( + row => row.SessionName.Contains("dev")); + + Assert.Equal( + QueryStringOperation.ContainsOrdinal, + Assert.IsType(contains.Predicate).Operator); + } + [Fact] public void Regex_translation_requires_explicit_culture_invariance() { Assert.Throws( () => QueryExtensions.Translate( row => Regex.IsMatch(row.SessionName, "^build", RegexOptions.IgnoreCase))); + Assert.Throws( + () => QueryExtensions.Translate( + row => Regex.IsMatch( + row.SessionName, + "^build", + RegexOptions.CultureInvariant, + TimeSpan.FromSeconds(1)))); QueryDocument document = QueryExtensions.Translate( row => Regex.IsMatch( From d7d2b3768b7da7afdd8f41e363974db09844548d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 07:21:48 -0500 Subject: [PATCH 026/129] Query(fix[trimming]): Expose reflective metadata contract why: NativeAOT removed a query-only property while the public API suppressed the warning and the smoke test never evaluated a document. what: - annotate every reflective evaluation entry point - preserve an AOT-only row explicitly and execute a compiled query - bind the warning contract and native output with regression tests - document the trimming requirement --- README.md | 2 +- src/LibTmux.Query.Json/README.md | 3 +++ src/LibTmux/LibTmux.csproj | 4 +-- src/LibTmux/Query/QueryExtensions.cs | 3 +++ src/LibTmux/Query/QueryInterpreter.cs | 10 +++---- src/LibTmux/README.md | 1 + tests/LibTmux.AotSmoke/Program.cs | 27 ++++++++++++++++++- .../Packaging/PackageClosureTests.cs | 1 + .../Query/QuerySemanticsTests.cs | 18 +++++++++++++ 9 files changed, 60 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 270a4e7..c881bd0 100644 --- a/README.md +++ b/README.md @@ -300,7 +300,7 @@ never reaches the model's list. | tmux | Stable 3.2a and newer. CI builds 3.2a, 3.3a, 3.4, 3.5, 3.6, 3.7a, and 3.7b; development, release-candidate, and `next-*` versions have unknown capability state | | .NET | net8.0, net10.0 | | OS | Linux, macOS. The bounded [`Psmux*` native-Windows and WSL query preview](docs/psmux.md) is experimental; its release gate runs both paths on net8.0 and net10.0 | -| Trimming / NativeAOT | `LibTmux` core is analyzer-gated and its smoke app is published and run for `linux-x64` on net8.0 and net10.0. That proof does not cover the other packages, macOS, or native Windows/psmux | +| Trimming / NativeAOT | `LibTmux` core is analyzer-gated and its smoke app is published and run for `linux-x64` on net8.0 and net10.0. `Compile` and `Matching` resolve properties by name, so they warn trimmed callers to preserve the filtered types' public properties. The proof does not cover the other packages, macOS, or native Windows/psmux | ## License diff --git a/src/LibTmux.Query.Json/README.md b/src/LibTmux.Query.Json/README.md index b7e8cc2..b737b7a 100644 --- a/src/LibTmux.Query.Json/README.md +++ b/src/LibTmux.Query.Json/README.md @@ -91,6 +91,9 @@ nesting depth, node count, string length, and regex pattern length. A caller may tighten those ceilings but cannot widen the v1 contract. The schema ships in the package as `libtmux-query-v1.schema.json`. +Evaluating the result with `Compile` or `Matching` resolves public properties +by name. Those methods warn trimmed callers to preserve that metadata. + ```csharp run Console.WriteLine($"depth {QueryJsonLimits.V1.MaximumDepth}, nodes {QueryJsonLimits.V1.MaximumNodes}"); ``` diff --git a/src/LibTmux/LibTmux.csproj b/src/LibTmux/LibTmux.csproj index 0759113..a487c2e 100644 --- a/src/LibTmux/LibTmux.csproj +++ b/src/LibTmux/LibTmux.csproj @@ -25,8 +25,8 @@ tmux;terminal;multiplexer;automation;pty README.md - + true true true diff --git a/src/LibTmux/Query/QueryExtensions.cs b/src/LibTmux/Query/QueryExtensions.cs index 55aa12a..6659640 100644 --- a/src/LibTmux/Query/QueryExtensions.cs +++ b/src/LibTmux/Query/QueryExtensions.cs @@ -27,6 +27,7 @@ public static QueryDocument Translate(Expression> predicate) => /// The filtered element type. /// The translated document. /// The compiled predicate. + [RequiresUnreferencedCode(QueryInterpreter.TrimmingMessage)] public static Func Compile(this QueryDocument document) => QueryInterpreter.Compile(document); @@ -37,6 +38,7 @@ public static Func Compile(this QueryDocument document) => /// The matching elements. [RequiresDynamicCode( "Translating an expression evaluates its captured values, which needs runtime code generation.")] + [RequiresUnreferencedCode(QueryInterpreter.TrimmingMessage)] public static IReadOnlyList Matching( this IEnumerable source, Expression> predicate) => @@ -47,6 +49,7 @@ public static IReadOnlyList Matching( /// The captured elements. /// The translated document. /// The matching elements. + [RequiresUnreferencedCode(QueryInterpreter.TrimmingMessage)] public static IReadOnlyList Matching( this IEnumerable source, QueryDocument document) diff --git a/src/LibTmux/Query/QueryInterpreter.cs b/src/LibTmux/Query/QueryInterpreter.cs index a534b6a..dd89062 100644 --- a/src/LibTmux/Query/QueryInterpreter.cs +++ b/src/LibTmux/Query/QueryInterpreter.cs @@ -13,6 +13,9 @@ namespace LibTmux.Query; /// internal static class QueryInterpreter { + internal const string TrimmingMessage = + "Compiling a query reads public properties by name. Trimmed applications must preserve the filtered types' public properties."; + /// How long one regex may run before it is treated as hostile. /// /// A query document can come from outside this process, and a pattern @@ -22,6 +25,7 @@ internal static class QueryInterpreter /// private static readonly TimeSpan RegexBudget = TimeSpan.FromSeconds(1); + [RequiresUnreferencedCode(TrimmingMessage)] internal static Func Compile(QueryDocument document) { ArgumentNullException.ThrowIfNull(document); @@ -139,14 +143,10 @@ private static bool ReadBoolean(FieldNode field, object element) => $"Node '{node.GetType().Name}' is not an operand."), }; - // Matching in memory reads the element's own properties by name, which a - // trimmer cannot see and may therefore remove. The whole interpreter is - // marked so that a caller trimming their app is told, rather than finding - // out when a filter silently stops matching. [UnconditionalSuppressMessage( "Trimming", "IL2075:Members might be removed", - Justification = "Marked on the query surface a caller reaches this through.")] + Justification = "Every public compilation entry point declares the metadata requirement.")] private static object? ReadMember(FieldNode field, object element) { Type type = element.GetType(); diff --git a/src/LibTmux/README.md b/src/LibTmux/README.md index c1e1071..f0f35bf 100644 --- a/src/LibTmux/README.md +++ b/src/LibTmux/README.md @@ -313,6 +313,7 @@ broke, and `Unknown` is the default for exactly that reason. A | tmux | 3.2a to 3.7b | | .NET | net8.0, net10.0 | | OS | Linux and macOS. `Server`, `Session`, `Window` and `Pane` are annotated unsupported on Windows, because their lifecycle, mutation and control-mode contracts need a real tmux | +| Trimming / NativeAOT | Core APIs are analyzer-gated. Query `Compile` and `Matching` resolve properties by name, so they warn trimmed callers to preserve the filtered types' public properties | | Windows preview | `PsmuxServer`, `PsmuxSession`, `PsmuxWindow` and `PsmuxPane` read one [psmux](https://github.com/psmux/psmux) session — its windows, its panes, and pane text — natively or across WSL. They cannot express lifecycle, mutation, chaining, control mode, or raw commands, so a caller gets a compile error where a suppression would have given a silent gap. [The preview contract](https://github.com/libtmux/libtmux-dotnet/blob/master/docs/psmux.md) names the build it accepts and how to provision it | ## Related packages diff --git a/tests/LibTmux.AotSmoke/Program.cs b/tests/LibTmux.AotSmoke/Program.cs index d90fd1c..282dfec 100644 --- a/tests/LibTmux.AotSmoke/Program.cs +++ b/tests/LibTmux.AotSmoke/Program.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using System.Runtime.Versioning; using LibTmux.Query; using LibTmux.Query.Json; @@ -13,6 +14,15 @@ namespace LibTmux.AotSmoke; [UnsupportedOSPlatform("windows")] internal static class Program { + private sealed class QueryRow + { + private readonly string _sessionName; + + internal QueryRow(string sessionName) => _sessionName = sessionName; + + public string SessionName => _sessionName; + } + private static async Task Main() { if (OperatingSystem.IsWindows()) @@ -35,6 +45,7 @@ private static async Task Main() QueryEdgeParser.ParseNameContains(QueryTarget.Session, "aot"); bool queryRoundTrips = QueryJson.Deserialize(QueryJson.Serialize(query)) == query; + bool queryMatches = CompileQuery(query); Server server = scope.Server; Session session = scope.Session; Window window = scope.Window; @@ -52,7 +63,21 @@ private static async Task Main() Console.WriteLine($"option {option.Value.Raw}"); Console.WriteLine($"buffer {buffer}"); Console.WriteLine($"query-json {queryRoundTrips}"); - return option.Value.Boolean == false && buffer == "aot" && queryRoundTrips ? 0 : 1; + Console.WriteLine($"query-compile {queryMatches}"); + return option.Value.Boolean == false + && buffer == "aot" + && queryRoundTrips + && queryMatches + ? 0 + : 1; } } + + [DynamicDependency(DynamicallyAccessedMemberTypes.PublicProperties, typeof(QueryRow))] + [UnconditionalSuppressMessage( + "Trimming", + "IL2026:RequiresUnreferencedCode", + Justification = "The dynamic dependency preserves the reflected query row.")] + private static bool CompileQuery(QueryDocument query) => + query.Compile()(new QueryRow("package-aot")); } diff --git a/tests/LibTmux.IntegrationTests/Packaging/PackageClosureTests.cs b/tests/LibTmux.IntegrationTests/Packaging/PackageClosureTests.cs index 4a569cc..ac6968b 100644 --- a/tests/LibTmux.IntegrationTests/Packaging/PackageClosureTests.cs +++ b/tests/LibTmux.IntegrationTests/Packaging/PackageClosureTests.cs @@ -128,6 +128,7 @@ public async Task Trimmed_native_aot_executes_on_both_frameworks() Assert.Contains("buffer aot", output, StringComparison.Ordinal); Assert.Contains("query-json True", output, StringComparison.Ordinal); + Assert.Contains("query-compile True", output, StringComparison.Ordinal); } } diff --git a/tests/LibTmux.UnitTests/Query/QuerySemanticsTests.cs b/tests/LibTmux.UnitTests/Query/QuerySemanticsTests.cs index 3678e22..f0a72dd 100644 --- a/tests/LibTmux.UnitTests/Query/QuerySemanticsTests.cs +++ b/tests/LibTmux.UnitTests/Query/QuerySemanticsTests.cs @@ -1,3 +1,5 @@ +using System.Diagnostics.CodeAnalysis; +using System.Reflection; using System.Text.RegularExpressions; using LibTmux.Query; @@ -154,6 +156,22 @@ public void A_boolean_field_is_a_complete_predicate() Assert.False(predicate(new Row("build", false))); } + [Fact] + public void Reflection_based_evaluation_declares_its_trimming_contract() + { + MethodInfo[] evaluationMethods = + [ + .. typeof(QueryExtensions).GetMethods(BindingFlags.Public | BindingFlags.Static) + .Where(method => method.Name is "Compile" or "Matching"), + ]; + + Assert.Equal(3, evaluationMethods.Length); + Assert.All( + evaluationMethods, + method => Assert.NotNull( + method.GetCustomAttribute())); + } + [Fact] public void Translation_refuses_an_unsupported_node_rather_than_evaluating_it() { From 27091e81e2b46797f5c9710b641545666c39ab7a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 07:24:57 -0500 Subject: [PATCH 027/129] Workspace(build[api]): Gate the public surface why: the Workspace package exposed public types without the analyzer and baseline that protect the other packable libraries. what: - baseline all Workspace declarations on both target frameworks - enable the Roslyn public API analyzer - fail if any packable library lacks analyzer-backed baselines - replace the stale reflection-based AOT explanation --- .../LibTmux.Workspace.csproj | 15 ++++-- src/LibTmux.Workspace/PublicAPI.Shipped.txt | 1 + src/LibTmux.Workspace/PublicAPI.Unshipped.txt | 42 ++++++++++++++++ src/LibTmux.Workspace/packages.lock.json | 12 +++++ .../Packaging/PublicApiContractTests.cs | 50 +++++++++++++++++++ 5 files changed, 117 insertions(+), 3 deletions(-) create mode 100644 src/LibTmux.Workspace/PublicAPI.Shipped.txt create mode 100644 src/LibTmux.Workspace/PublicAPI.Unshipped.txt diff --git a/src/LibTmux.Workspace/LibTmux.Workspace.csproj b/src/LibTmux.Workspace/LibTmux.Workspace.csproj index ce0b66b..e95db03 100644 --- a/src/LibTmux.Workspace/LibTmux.Workspace.csproj +++ b/src/LibTmux.Workspace/LibTmux.Workspace.csproj @@ -11,10 +11,10 @@ tmux;tmuxp;workspace;yaml README.md - + false + + $(NoWarn);RS0017;RS0026;RS0036 @@ -26,4 +26,13 @@ + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + diff --git a/src/LibTmux.Workspace/PublicAPI.Shipped.txt b/src/LibTmux.Workspace/PublicAPI.Shipped.txt new file mode 100644 index 0000000..7dc5c58 --- /dev/null +++ b/src/LibTmux.Workspace/PublicAPI.Shipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/LibTmux.Workspace/PublicAPI.Unshipped.txt b/src/LibTmux.Workspace/PublicAPI.Unshipped.txt new file mode 100644 index 0000000..2ba5372 --- /dev/null +++ b/src/LibTmux.Workspace/PublicAPI.Unshipped.txt @@ -0,0 +1,42 @@ +#nullable enable +LibTmux.Workspace.WorkspaceBuilder +LibTmux.Workspace.WorkspaceBuilder.BuildAsync(LibTmux.Workspace.WorkspaceFile! workspace, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +LibTmux.Workspace.WorkspaceBuilder.WorkspaceBuilder(LibTmux.Server! server) -> void +LibTmux.Workspace.WorkspaceFile +LibTmux.Workspace.WorkspaceFile.Options.get -> System.Collections.Generic.IReadOnlyDictionary! +LibTmux.Workspace.WorkspaceFile.SessionName.get -> string? +LibTmux.Workspace.WorkspaceFile.StartDirectory.get -> string? +LibTmux.Workspace.WorkspaceFile.Windows.get -> System.Collections.Generic.IReadOnlyList! +LibTmux.Workspace.WorkspaceFile.WorkspaceFile(string? sessionName = null, string? startDirectory = null, System.Collections.Generic.IReadOnlyDictionary? options = null, System.Collections.Generic.IReadOnlyList? windows = null) -> void +LibTmux.Workspace.WorkspaceFormatException +LibTmux.Workspace.WorkspaceFormatException.WorkspaceFormatException(string! message, System.Exception? innerException = null) -> void +LibTmux.Workspace.WorkspacePane +LibTmux.Workspace.WorkspacePane.Focus.get -> bool +LibTmux.Workspace.WorkspacePane.ShellCommands.get -> System.Collections.Generic.IReadOnlyList! +LibTmux.Workspace.WorkspacePane.StartDirectory.get -> string? +LibTmux.Workspace.WorkspacePane.WorkspacePane(System.Collections.Generic.IReadOnlyList? shellCommands = null, string? startDirectory = null, bool focus = false) -> void +LibTmux.Workspace.WorkspaceResult +LibTmux.Workspace.WorkspaceResult.$() -> LibTmux.Workspace.WorkspaceResult! +LibTmux.Workspace.WorkspaceResult.Deconstruct(out LibTmux.Session! Session, out System.Collections.Generic.IReadOnlyList! Windows, out System.Collections.Generic.IReadOnlyList! Unsupported) -> void +LibTmux.Workspace.WorkspaceResult.Equals(LibTmux.Workspace.WorkspaceResult? other) -> bool +LibTmux.Workspace.WorkspaceResult.Session.get -> LibTmux.Session! +LibTmux.Workspace.WorkspaceResult.Session.init -> void +LibTmux.Workspace.WorkspaceResult.Unsupported.get -> System.Collections.Generic.IReadOnlyList! +LibTmux.Workspace.WorkspaceResult.Unsupported.init -> void +LibTmux.Workspace.WorkspaceResult.Windows.get -> System.Collections.Generic.IReadOnlyList! +LibTmux.Workspace.WorkspaceResult.Windows.init -> void +LibTmux.Workspace.WorkspaceResult.WorkspaceResult(LibTmux.Session! Session, System.Collections.Generic.IReadOnlyList! Windows, System.Collections.Generic.IReadOnlyList! Unsupported) -> void +LibTmux.Workspace.WorkspaceWindow +LibTmux.Workspace.WorkspaceWindow.Focus.get -> bool +LibTmux.Workspace.WorkspaceWindow.Layout.get -> string? +LibTmux.Workspace.WorkspaceWindow.Options.get -> System.Collections.Generic.IReadOnlyDictionary! +LibTmux.Workspace.WorkspaceWindow.Panes.get -> System.Collections.Generic.IReadOnlyList! +LibTmux.Workspace.WorkspaceWindow.StartDirectory.get -> string? +LibTmux.Workspace.WorkspaceWindow.WindowName.get -> string? +LibTmux.Workspace.WorkspaceWindow.WorkspaceWindow(string? windowName = null, string? startDirectory = null, string? layout = null, bool focus = false, System.Collections.Generic.IReadOnlyDictionary? options = null, System.Collections.Generic.IReadOnlyList? panes = null) -> void +override LibTmux.Workspace.WorkspaceResult.Equals(object? obj) -> bool +override LibTmux.Workspace.WorkspaceResult.GetHashCode() -> int +override LibTmux.Workspace.WorkspaceResult.ToString() -> string! +static LibTmux.Workspace.WorkspaceFile.Parse(string! yaml) -> LibTmux.Workspace.WorkspaceFile! +static LibTmux.Workspace.WorkspaceResult.operator !=(LibTmux.Workspace.WorkspaceResult? left, LibTmux.Workspace.WorkspaceResult? right) -> bool +static LibTmux.Workspace.WorkspaceResult.operator ==(LibTmux.Workspace.WorkspaceResult? left, LibTmux.Workspace.WorkspaceResult? right) -> bool diff --git a/src/LibTmux.Workspace/packages.lock.json b/src/LibTmux.Workspace/packages.lock.json index 11dad05..046a814 100644 --- a/src/LibTmux.Workspace/packages.lock.json +++ b/src/LibTmux.Workspace/packages.lock.json @@ -2,6 +2,12 @@ "version": 2, "dependencies": { "net10.0": { + "Microsoft.CodeAnalysis.PublicApiAnalyzers": { + "type": "Direct", + "requested": "[5.6.0, )", + "resolved": "5.6.0", + "contentHash": "W4kJGezNIKLzo0Ak5FAQDFvkMf2U7DtGL4THmHyRSApfKsKt5V+eX/bU0ZLKAt/uf9Bb2o1bi0YDKj/GRB/vYQ==" + }, "YamlDotNet": { "type": "Direct", "requested": "[18.1.0, )", @@ -30,6 +36,12 @@ } }, "net8.0": { + "Microsoft.CodeAnalysis.PublicApiAnalyzers": { + "type": "Direct", + "requested": "[5.6.0, )", + "resolved": "5.6.0", + "contentHash": "W4kJGezNIKLzo0Ak5FAQDFvkMf2U7DtGL4THmHyRSApfKsKt5V+eX/bU0ZLKAt/uf9Bb2o1bi0YDKj/GRB/vYQ==" + }, "YamlDotNet": { "type": "Direct", "requested": "[18.1.0, )", diff --git a/tests/LibTmux.UnitTests/Packaging/PublicApiContractTests.cs b/tests/LibTmux.UnitTests/Packaging/PublicApiContractTests.cs index b0661e1..98fe1b6 100644 --- a/tests/LibTmux.UnitTests/Packaging/PublicApiContractTests.cs +++ b/tests/LibTmux.UnitTests/Packaging/PublicApiContractTests.cs @@ -1,5 +1,6 @@ using System.Reflection; using System.Text.Json; +using System.Xml.Linq; namespace LibTmux.UnitTests.Packaging; @@ -80,6 +81,55 @@ public void Every_approved_member_exists_in_the_assembly() + string.Join(Environment.NewLine, absent)); } + [Fact] + public void Every_packable_library_has_a_Roslyn_public_API_baseline() + { + string repositoryRoot = Directory.GetParent(Path.GetDirectoryName(ContractPath())!)!.FullName; + string[] projects = + [ + .. Directory.EnumerateFiles( + Path.Combine(repositoryRoot, "src"), + "*.csproj", + SearchOption.AllDirectories) + .Where(IsPackableLibrary) + .Order(StringComparer.Ordinal), + ]; + + Assert.NotEmpty(projects); + foreach (string project in projects) + { + XDocument document = XDocument.Load(project); + string[] additionalFiles = + [ + .. document.Descendants("AdditionalFiles") + .Select(element => Path.GetFileName((string?)element.Attribute("Include"))) + .OfType(), + ]; + bool hasAnalyzer = document.Descendants("PackageReference").Any( + element => string.Equals( + (string?)element.Attribute("Include"), + "Microsoft.CodeAnalysis.PublicApiAnalyzers", + StringComparison.Ordinal)); + + Assert.Contains("PublicAPI.Shipped.txt", additionalFiles); + Assert.Contains("PublicAPI.Unshipped.txt", additionalFiles); + Assert.True(hasAnalyzer, $"{Path.GetFileName(project)} has no public API analyzer."); + Assert.True( + File.Exists(Path.Combine(Path.GetDirectoryName(project)!, "PublicAPI.Shipped.txt"))); + Assert.True( + File.Exists(Path.Combine(Path.GetDirectoryName(project)!, "PublicAPI.Unshipped.txt"))); + } + } + + private static bool IsPackableLibrary(string project) + { + XDocument document = XDocument.Load(project); + return document.Descendants("IsPackable").Any( + element => string.Equals(element.Value, "true", StringComparison.OrdinalIgnoreCase)) + && !document.Descendants("PackAsTool").Any( + element => string.Equals(element.Value, "true", StringComparison.OrdinalIgnoreCase)); + } + private static List AbsentApprovedMembers() { using FileStream stream = File.OpenRead(ContractPath()); From 935eff6225a7ebf548b1caf74f7763874f7daa63 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 07:25:37 -0500 Subject: [PATCH 028/129] Packaging(test[workspace]): Reach the shipped package why: package closure exercised core and Query.Json but could not detect a broken Workspace dependency or asset. what: - restore Workspace beside the other package references - parse a real workspace through the packed assembly - require the result on both target frameworks and the tmux support floor --- .../Packaging/PackageClosureTests.cs | 1 + .../LibTmux.PackageConsumer.csproj | 3 ++- tests/LibTmux.PackageConsumer/Program.cs | 17 +++++++++++++++++ 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/tests/LibTmux.IntegrationTests/Packaging/PackageClosureTests.cs b/tests/LibTmux.IntegrationTests/Packaging/PackageClosureTests.cs index ac6968b..469dca5 100644 --- a/tests/LibTmux.IntegrationTests/Packaging/PackageClosureTests.cs +++ b/tests/LibTmux.IntegrationTests/Packaging/PackageClosureTests.cs @@ -101,6 +101,7 @@ public async Task Packed_consumers_execute_on_both_frameworks() Assert.Contains("captured True", output, StringComparison.Ordinal); Assert.Contains("query-json True", output, StringComparison.Ordinal); + Assert.Contains("workspace-parse True", output, StringComparison.Ordinal); } } diff --git a/tests/LibTmux.PackageConsumer/LibTmux.PackageConsumer.csproj b/tests/LibTmux.PackageConsumer/LibTmux.PackageConsumer.csproj index dc1c2f5..13e9208 100644 --- a/tests/LibTmux.PackageConsumer/LibTmux.PackageConsumer.csproj +++ b/tests/LibTmux.PackageConsumer/LibTmux.PackageConsumer.csproj @@ -24,8 +24,9 @@ - + + diff --git a/tests/LibTmux.PackageConsumer/Program.cs b/tests/LibTmux.PackageConsumer/Program.cs index d8242c6..ce80644 100644 --- a/tests/LibTmux.PackageConsumer/Program.cs +++ b/tests/LibTmux.PackageConsumer/Program.cs @@ -3,6 +3,7 @@ using LibTmux.Query; using LibTmux.Query.Json; using LibTmux.Testing; +using LibTmux.Workspace; namespace LibTmux.PackageConsumer; @@ -25,6 +26,22 @@ private static async Task Main(string[] args) return 1; } + WorkspaceFile workspace = WorkspaceFile.Parse( + """ + session_name: package + windows: + - window_name: main + panes: + - shell_command: echo package + """); + bool workspaceParses = workspace.SessionName == "package" + && workspace.Windows is [{ Panes: [{ ShellCommands: ["echo package"] }] }]; + Console.WriteLine($"workspace-parse {workspaceParses}"); + if (!workspaceParses) + { + return 1; + } + if (args is ["--psmux"]) { Console.OutputEncoding = new UTF8Encoding(false, true); From ab6d420e35c30161223796ac389454aa8395d6d3 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 07:26:11 -0500 Subject: [PATCH 029/129] Workspace(docs[contract]): State failure boundaries why: the package readme promised inspectable partial results even though non-layout tmux failures throw after potentially creating part of a session. what: - distinguish layout diagnostics from thrown build failures - state that builds are not transactional - document the closed YAML subset, input bound, and relative-path behavior --- src/LibTmux.Workspace/README.md | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/src/LibTmux.Workspace/README.md b/src/LibTmux.Workspace/README.md index b34bf42..f214320 100644 --- a/src/LibTmux.Workspace/README.md +++ b/src/LibTmux.Workspace/README.md @@ -59,22 +59,28 @@ Reading one off disk is the same call: WorkspaceFile fromDisk = WorkspaceFile.Parse(File.ReadAllText("session.yaml")); ``` -## What the result tells you +`start_directory` values are passed to tmux unchanged. Relative paths are not +rebased to the directory containing `session.yaml`. -`BuildAsync` returns what it built rather than throwing away a session because -one pane's command was wrong, so a partial build is something you can inspect -and report instead of a stack trace. +## Failure behavior -A document that describes no session is a `WorkspaceFormatException` — that one -is not partial, it is unusable. +`BuildAsync` returns the session and windows it created. Its `Unsupported` list +contains only layouts that tmux rejected; those windows remain usable. + +Other tmux failures throw and can leave a partially built session. The builder +is not transactional. A missing session name or empty window list raises +`WorkspaceFormatException` before creating anything. ## What is in scope -This reads the workspace shape tmuxp writes: session name, start directory, -windows, panes, layouts, options, and the commands to send. +This reads a closed tmuxp subset: session name, start directory, scalar +options, windows, panes, layouts, focus, and scalar or ordered +`shell_command` values. Duplicate or unknown keys, wrong value shapes, +multiple YAML documents, and inputs over 1 MiB raise +`WorkspaceFormatException` instead of being ignored. It is **not** a tmuxp runtime. Plugins, before/after hooks, and tmuxp's own -configuration search path are out of scope — if you need those, run tmuxp. +configuration search path are rejected — if you need those, run tmuxp. ## Related packages From 0e8f0f198ba869ece284d859650fe28ba71c982b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 07:26:24 -0500 Subject: [PATCH 030/129] Query(test[parity]): Invoke wire predicate why: Component 09 only compiled its predicate, so the parity gate could pass without proving local filtering. what: - Compile the document for the live Session entity. - Invoke the predicate as part of the parity proof. --- .../Parity/Component09ParityTests.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/LibTmux.IntegrationTests/Parity/Component09ParityTests.cs b/tests/LibTmux.IntegrationTests/Parity/Component09ParityTests.cs index be4e2a7..5d1c7f8 100644 --- a/tests/LibTmux.IntegrationTests/Parity/Component09ParityTests.cs +++ b/tests/LibTmux.IntegrationTests/Parity/Component09ParityTests.cs @@ -50,14 +50,14 @@ private static async Task ProvesWireDocumentAsync( QueryDocument document = QueryEdgeParser.ParseNameContains(QueryTarget.Session, "dev"); IReadOnlyList sessions = await server.GetSessionsAsync(token); - Func, bool> predicate = - document.Compile>(); + Func predicate = document.Compile(); Assert.NotNull(predicate); return document.Target == QueryTarget.Session && document.Schema == QueryDocument.CurrentSchema && document.Version == QueryDocument.CurrentVersion && sessions.Count == 1 - && sessions[0].Snapshot?["session_name"] == "devbox"; + && sessions[0].Snapshot?["session_name"] == "devbox" + && predicate(sessions[0]); } } From 3ab82bc15abfd18b7663d4d2e4b2fa94a2b83fc9 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 07:27:55 -0500 Subject: [PATCH 031/129] Query(fix[binding]): Match exact entity types why: Caller projections named Session, Window, Pane, or Client were mistaken for LibTmux entities and could not compile their own translated documents. what: - Bind catalog property aliases to exact LibTmux entity types. - Preserve projection naming for caller-defined type-name collisions. - Add a red-green regression on both target frameworks. --- .../FieldCatalogGenerator.cs | 40 +++++++++---------- src/LibTmux/Query/QueryInterpreter.cs | 2 +- src/LibTmux/Query/QueryTranslator.cs | 2 +- .../Query/QuerySemanticsTests.cs | 14 +++++++ 4 files changed, 36 insertions(+), 22 deletions(-) diff --git a/src/LibTmux.Generators/FieldCatalogGenerator.cs b/src/LibTmux.Generators/FieldCatalogGenerator.cs index 01e6c31..a28c473 100644 --- a/src/LibTmux.Generators/FieldCatalogGenerator.cs +++ b/src/LibTmux.Generators/FieldCatalogGenerator.cs @@ -108,11 +108,9 @@ private static string Render() source.AppendLine(" }"); source.AppendLine(); source.AppendLine( - " internal static bool TryGetWireName(string owner, string property, " + " internal static bool TryGetWireName(global::System.Type owner, string property, " + "out string wireName)"); source.AppendLine(" {"); - source.AppendLine(" switch (owner + \".\" + property)"); - source.AppendLine(" {"); foreach ((string wireName, string target, _, _, string? property) in Fields) { if (property is null) @@ -120,23 +118,23 @@ private static string Render() continue; } - source.AppendLine($" case \"{target}.{property}\":"); - source.AppendLine($" wireName = \"{wireName}\";"); - source.AppendLine(" return true;"); + source.AppendLine( + $" if (owner == typeof(global::LibTmux.{target}) " + + $"&& property == \"{property}\")"); + source.AppendLine(" {"); + source.AppendLine($" wireName = \"{wireName}\";"); + source.AppendLine(" return true;"); + source.AppendLine(" }"); } - source.AppendLine(" default:"); - source.AppendLine(" wireName = string.Empty;"); - source.AppendLine(" return false;"); - source.AppendLine(" }"); + source.AppendLine(" wireName = string.Empty;"); + source.AppendLine(" return false;"); source.AppendLine(" }"); source.AppendLine(); source.AppendLine( - " internal static bool TryGetProperty(string owner, string wireName, " + " internal static bool TryGetProperty(global::System.Type owner, string wireName, " + "out string property)"); source.AppendLine(" {"); - source.AppendLine(" switch (owner + \".\" + wireName)"); - source.AppendLine(" {"); foreach ((string wireName, string target, _, _, string? property) in Fields) { if (property is null) @@ -144,15 +142,17 @@ private static string Render() continue; } - source.AppendLine($" case \"{target}.{wireName}\":"); - source.AppendLine($" property = \"{property}\";"); - source.AppendLine(" return true;"); + source.AppendLine( + $" if (owner == typeof(global::LibTmux.{target}) " + + $"&& wireName == \"{wireName}\")"); + source.AppendLine(" {"); + source.AppendLine($" property = \"{property}\";"); + source.AppendLine(" return true;"); + source.AppendLine(" }"); } - source.AppendLine(" default:"); - source.AppendLine(" property = string.Empty;"); - source.AppendLine(" return false;"); - source.AppendLine(" }"); + source.AppendLine(" property = string.Empty;"); + source.AppendLine(" return false;"); source.AppendLine(" }"); source.AppendLine(); source.AppendLine(" internal static IReadOnlyList WireNames { get; } ="); diff --git a/src/LibTmux/Query/QueryInterpreter.cs b/src/LibTmux/Query/QueryInterpreter.cs index dd89062..bf0287b 100644 --- a/src/LibTmux/Query/QueryInterpreter.cs +++ b/src/LibTmux/Query/QueryInterpreter.cs @@ -165,7 +165,7 @@ private static bool ReadBoolean(FieldNode field, object element) => // holds session_attached under Attached, and a row a caller declared // holds it under SessionAttached. string property = - QueryFieldCatalog.TryGetProperty(type.Name, field.WireName, out string mapped) + QueryFieldCatalog.TryGetProperty(type, field.WireName, out string mapped) ? mapped : ToClrName(field.WireName); diff --git a/src/LibTmux/Query/QueryTranslator.cs b/src/LibTmux/Query/QueryTranslator.cs index b9af19f..b14c8a5 100644 --- a/src/LibTmux/Query/QueryTranslator.cs +++ b/src/LibTmux/Query/QueryTranslator.cs @@ -231,7 +231,7 @@ private static FieldNode FieldFor(MemberInfo member) // type is a caller's own row, whose property names are wire names already. string wireName = member.DeclaringType is { } owner - && QueryFieldCatalog.TryGetWireName(owner.Name, member.Name, out string mapped) + && QueryFieldCatalog.TryGetWireName(owner, member.Name, out string mapped) ? mapped : ToWireName(member.Name); // The catalog is closed: a field it does not carry cannot be put on the diff --git a/tests/LibTmux.UnitTests/Query/QuerySemanticsTests.cs b/tests/LibTmux.UnitTests/Query/QuerySemanticsTests.cs index f0a72dd..597f894 100644 --- a/tests/LibTmux.UnitTests/Query/QuerySemanticsTests.cs +++ b/tests/LibTmux.UnitTests/Query/QuerySemanticsTests.cs @@ -57,6 +57,15 @@ public void A_row_a_caller_declares_still_names_the_wire_fields_itself() row => row.SessionName.StartsWith("dev", StringComparison.Ordinal)))); } + [Fact] + public void A_caller_type_that_shares_an_entity_name_remains_a_projection() + { + QueryDocument document = QueryExtensions.Translate( + row => row.SessionName == "dev"); + + Assert.True(document.Compile()(new Caller.Session("dev"))); + } + [Fact] public void A_property_outside_the_catalog_still_refuses_to_translate() { @@ -259,6 +268,11 @@ private sealed record Child(string WindowName); private sealed record Parent(IReadOnlyList SessionWindows); + private static class Caller + { + internal sealed record Session(string SessionName); + } + [Fact] public void And_and_or_nodes_use_ordered_structural_equality_and_hashing() { From 956ed101239d81a9309af0436b91c379b20622a8 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 07:29:17 -0500 Subject: [PATCH 032/129] Query(fix[numbers]): Preserve integer semantics why: Int32 projections threw when compared with Int64 wire constants, while floating-point constants were silently rounded into a different predicate. what: - Normalize integral CLR values to the wire Int64 domain. - Reject floating-point constants that have no v1 wire semantics. - Prove both behaviors on both target frameworks. --- src/LibTmux/Query/QueryInterpreter.cs | 20 +++++++++++++++++- src/LibTmux/Query/QueryTranslator.cs | 11 +++++++--- .../Query/QuerySemanticsTests.cs | 21 +++++++++++++++++++ 3 files changed, 48 insertions(+), 4 deletions(-) diff --git a/src/LibTmux/Query/QueryInterpreter.cs b/src/LibTmux/Query/QueryInterpreter.cs index bf0287b..5094c53 100644 --- a/src/LibTmux/Query/QueryInterpreter.cs +++ b/src/LibTmux/Query/QueryInterpreter.cs @@ -91,7 +91,11 @@ private static bool Compare(ComparisonNode comparison, object element) return comparison.Operator == QueryComparison.Equal ? equal : !equal; } - int order = Comparer.Default.Compare(left, right); + int order = comparison.Left is FieldNode field + && QueryFieldCatalog.TryGetKind(field.WireName, out QueryValueKind kind) + && kind == QueryValueKind.Int64 + ? ReadInt64(field, left).CompareTo(ReadInt64(field, right)) + : Comparer.Default.Compare(left, right); return comparison.Operator switch { QueryComparison.Equal => order == 0, @@ -104,6 +108,20 @@ private static bool Compare(ComparisonNode comparison, object element) }; } + private static long ReadInt64(FieldNode field, object value) => value switch + { + sbyte number => number, + byte number => number, + short number => number, + ushort number => number, + int number => number, + uint number => number, + long number => number, + ulong number when number <= long.MaxValue => (long)number, + _ => throw new UnsupportedQueryExpressionException( + $"Field '{field.WireName}' did not produce an integer value."), + }; + private static bool CompareText(StringNode text, object element) { string left = ReadText(text.Left, element) ?? string.Empty; diff --git a/src/LibTmux/Query/QueryTranslator.cs b/src/LibTmux/Query/QueryTranslator.cs index b14c8a5..04316f1 100644 --- a/src/LibTmux/Query/QueryTranslator.cs +++ b/src/LibTmux/Query/QueryTranslator.cs @@ -1,5 +1,4 @@ using System.Diagnostics.CodeAnalysis; -using System.Globalization; using System.Linq.Expressions; using System.Reflection; using System.Text.RegularExpressions; @@ -255,8 +254,14 @@ member.DeclaringType is { } owner SessionId id => new TypedIdConstant(QueryTarget.Session, id.ToString()), WindowId id => new TypedIdConstant(QueryTarget.Window, id.ToString()), PaneId id => new TypedIdConstant(QueryTarget.Pane, id.ToString()), - _ when value is IConvertible convertible => new Int64Constant( - convertible.ToInt64(CultureInfo.InvariantCulture)), + sbyte number => new Int64Constant(number), + byte number => new Int64Constant(number), + short number => new Int64Constant(number), + ushort number => new Int64Constant(number), + int number => new Int64Constant(number), + uint number => new Int64Constant(number), + long number => new Int64Constant(number), + ulong number when number <= long.MaxValue => new Int64Constant((long)number), _ => throw new UnsupportedQueryExpressionException( $"Constant of type '{declared.Name}' has no wire form."), }; diff --git a/tests/LibTmux.UnitTests/Query/QuerySemanticsTests.cs b/tests/LibTmux.UnitTests/Query/QuerySemanticsTests.cs index 597f894..534328d 100644 --- a/tests/LibTmux.UnitTests/Query/QuerySemanticsTests.cs +++ b/tests/LibTmux.UnitTests/Query/QuerySemanticsTests.cs @@ -11,6 +11,10 @@ private sealed record Row(string SessionName, bool SessionAttached); private sealed record SessionCountRow(string SessionName, long SessionWindows); + private sealed record SessionIntCountRow(string SessionName, int SessionWindows); + + private sealed record SessionDoubleCountRow(string SessionName, double SessionWindows); + private sealed record WindowCountRow(string WindowName, long WindowPanes); [Fact] @@ -151,6 +155,23 @@ public void Relation_fields_keep_their_scalar_tmux_value_in_row_projections() Assert.True(panes.Compile()(new WindowCountRow("main", 2))); } + [Fact] + public void Integer_projections_compare_as_wire_int64_values() + { + QueryDocument document = QueryExtensions.Translate( + row => row.SessionWindows > 1); + + Assert.True(document.Compile()(new SessionIntCountRow("dev", 2))); + } + + [Fact] + public void Integer_wire_fields_reject_floating_point_semantics() + { + Assert.Throws( + () => QueryExtensions.Translate( + row => row.SessionWindows > 1.5)); + } + [Fact] public void A_boolean_field_is_a_complete_predicate() { From 01230b64fb8ae2cf377b6a4ae61bbba085ce90e1 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 07:30:36 -0500 Subject: [PATCH 033/129] QueryJson(fix[regex]): Reject malformed patterns why: A syntactically invalid regular expression crossed the JSON trust boundary and failed only when a caller later evaluated it. what: - Parse supported patterns during document validation. - Share the one-second regex timeout with evaluation. - Add a red-green deserialization regression on both target frameworks. --- src/LibTmux/Query/QueryDocumentValidator.cs | 3 ++- src/LibTmux/Query/QueryInterpreter.cs | 11 +---------- src/LibTmux/Query/QueryRegexSemantics.cs | 16 ++++++++++++++++ .../Query/QueryJsonTrustBoundaryTests.cs | 12 ++++++++++++ 4 files changed, 31 insertions(+), 11 deletions(-) diff --git a/src/LibTmux/Query/QueryDocumentValidator.cs b/src/LibTmux/Query/QueryDocumentValidator.cs index 451903a..d469744 100644 --- a/src/LibTmux/Query/QueryDocumentValidator.cs +++ b/src/LibTmux/Query/QueryDocumentValidator.cs @@ -128,7 +128,8 @@ private static void ValidateRegex(RegexNode regex, QueryTarget expectedTarget) StringComparison.Ordinal) || !QueryTextSemantics.TryCountScalars(regex.Pattern, out int length) || length > QueryRegexSemantics.MaximumPatternLength - || !QueryRegexSemantics.IsSupported(regex.SemanticOptions)) + || !QueryRegexSemantics.IsSupported(regex.SemanticOptions) + || !QueryRegexSemantics.IsValidPattern(regex.Pattern, regex.SemanticOptions)) { throw Unsupported("Regex does not match the query wire semantics."); } diff --git a/src/LibTmux/Query/QueryInterpreter.cs b/src/LibTmux/Query/QueryInterpreter.cs index 5094c53..02dcf7b 100644 --- a/src/LibTmux/Query/QueryInterpreter.cs +++ b/src/LibTmux/Query/QueryInterpreter.cs @@ -16,15 +16,6 @@ internal static class QueryInterpreter internal const string TrimmingMessage = "Compiling a query reads public properties by name. Trimmed applications must preserve the filtered types' public properties."; - /// How long one regex may run before it is treated as hostile. - /// - /// A query document can come from outside this process, and a pattern - /// like (a+)+$ against a long subject can backtrack indefinitely. - /// Matching is bounded, so an over-budget pattern raises rather than - /// hangs the evaluating process. - /// - private static readonly TimeSpan RegexBudget = TimeSpan.FromSeconds(1); - [RequiresUnreferencedCode(TrimmingMessage)] internal static Func Compile(QueryDocument document) { @@ -44,7 +35,7 @@ internal static Func Compile(QueryDocument document) ReadText(regex.Input, element) ?? string.Empty, regex.Pattern, regex.SemanticOptions, - RegexBudget), + QueryRegexSemantics.MatchTimeout), QuantifierNode quantifier => Quantify(quantifier, element), FieldNode field => ReadBoolean(field, element), ConstantNode { Value: BooleanConstant boolean } => boolean.Value, diff --git a/src/LibTmux/Query/QueryRegexSemantics.cs b/src/LibTmux/Query/QueryRegexSemantics.cs index bf130e1..ea463d1 100644 --- a/src/LibTmux/Query/QueryRegexSemantics.cs +++ b/src/LibTmux/Query/QueryRegexSemantics.cs @@ -7,6 +7,9 @@ internal static class QueryRegexSemantics internal const string Dialect = "dotnet"; internal const int MaximumPatternLength = 1024; + // One match may consume this much CPU before hostile backtracking is refused. + internal static readonly TimeSpan MatchTimeout = TimeSpan.FromSeconds(1); + internal const RegexOptions AllowedOptions = RegexOptions.IgnoreCase | RegexOptions.Multiline @@ -18,4 +21,17 @@ internal static class QueryRegexSemantics internal static bool IsSupported(RegexOptions options) => (options & ~AllowedOptions) == 0 && (options & RegexOptions.CultureInvariant) != 0; + + internal static bool IsValidPattern(string pattern, RegexOptions options) + { + try + { + _ = new Regex(pattern, options, MatchTimeout); + return true; + } + catch (ArgumentException) + { + return false; + } + } } diff --git a/tests/LibTmux.UnitTests/Query/QueryJsonTrustBoundaryTests.cs b/tests/LibTmux.UnitTests/Query/QueryJsonTrustBoundaryTests.cs index edb314f..d49a5a5 100644 --- a/tests/LibTmux.UnitTests/Query/QueryJsonTrustBoundaryTests.cs +++ b/tests/LibTmux.UnitTests/Query/QueryJsonTrustBoundaryTests.cs @@ -103,6 +103,18 @@ public void Regex_options_without_culture_invariance_are_refused() Assert.Throws(() => QueryJson.Deserialize(json)); } + [Fact] + public void A_malformed_regex_is_refused_on_the_way_in() + { + string json = Document( + """ + {"kind":"regex","input":{"kind":"field","target":"session","wireName":"session_name"}, + "dialect":"dotnet","pattern":"(","semanticOptions":512} + """); + + Assert.Throws(() => QueryJson.Deserialize(json)); + } + [Fact] public void An_unknown_quantifier_is_refused_rather_than_treated_as_all() { From e90a1b011b4c41745b7d0d9dff97a29df8e9f977 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 07:31:18 -0500 Subject: [PATCH 034/129] Query(fix[nulls]): Distinguish missing members why: Present null values were mistaken for absent members, so NullConstant could not evaluate. what: - Separate reflective member lookup from value retrieval. - Prove null equality on both target frameworks. --- src/LibTmux/Query/QueryInterpreter.cs | 9 +++++++-- .../Query/QuerySemanticsTests.cs | 19 +++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/LibTmux/Query/QueryInterpreter.cs b/src/LibTmux/Query/QueryInterpreter.cs index 02dcf7b..64eff48 100644 --- a/src/LibTmux/Query/QueryInterpreter.cs +++ b/src/LibTmux/Query/QueryInterpreter.cs @@ -178,9 +178,14 @@ private static bool ReadBoolean(FieldNode field, object element) => ? mapped : ToClrName(field.WireName); - return type.GetProperty(property)?.GetValue(element) - ?? throw new UnsupportedQueryExpressionException( + var member = type.GetProperty(property); + if (member is null) + { + throw new UnsupportedQueryExpressionException( $"Element exposes no member for field '{field.WireName}'."); + } + + return member.GetValue(element); } private static object? Literal(QueryConstant constant) => constant switch diff --git a/tests/LibTmux.UnitTests/Query/QuerySemanticsTests.cs b/tests/LibTmux.UnitTests/Query/QuerySemanticsTests.cs index 534328d..56f2c78 100644 --- a/tests/LibTmux.UnitTests/Query/QuerySemanticsTests.cs +++ b/tests/LibTmux.UnitTests/Query/QuerySemanticsTests.cs @@ -15,6 +15,8 @@ private sealed record SessionIntCountRow(string SessionName, int SessionWindows) private sealed record SessionDoubleCountRow(string SessionName, double SessionWindows); + private sealed record NullableRow(string? SessionName); + private sealed record WindowCountRow(string WindowName, long WindowPanes); [Fact] @@ -186,6 +188,23 @@ public void A_boolean_field_is_a_complete_predicate() Assert.False(predicate(new Row("build", false))); } + [Fact] + public void A_present_property_can_match_a_null_constant() + { + QueryDocument document = new( + QueryDocument.CurrentSchema, + QueryDocument.CurrentVersion, + QueryTarget.Session, + new ComparisonNode( + QueryComparison.Equal, + new FieldNode(QueryTarget.Session, "session_name"), + new ConstantNode(new NullConstant()))); + Func predicate = document.Compile(); + + Assert.True(predicate(new NullableRow(null))); + Assert.False(predicate(new NullableRow("build"))); + } + [Fact] public void Reflection_based_evaluation_declares_its_trimming_contract() { From 08be7d643fd8b227eeb9545212e5432abd04fed7 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 07:32:02 -0500 Subject: [PATCH 035/129] Query(fix[ids]): Preserve projected ID equality why: String-backed row projections for typed tmux IDs were rejected as string operations, breaking Window.where and Window.find_where parity. what: - Classify equality from the wire field kind. - Emit typed ID constants for string-backed ID projections. - Prove the regression on both target frameworks. --- src/LibTmux/Query/QueryTranslator.cs | 16 +++++++++++++--- .../Query/QuerySemanticsTests.cs | 16 ++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/LibTmux/Query/QueryTranslator.cs b/src/LibTmux/Query/QueryTranslator.cs index 04316f1..9f19ac0 100644 --- a/src/LibTmux/Query/QueryTranslator.cs +++ b/src/LibTmux/Query/QueryTranslator.cs @@ -91,10 +91,20 @@ private static QueryNode TranslateBinary( QueryNode left = TranslateOperand(binary.Left, parameter); QueryNode right = TranslateOperand(binary.Right, parameter); if (comparison is QueryComparison.Equal or QueryComparison.NotEqual - && StripConvert(binary.Left).Type == typeof(string)) + && left is FieldNode field + && right is ConstantNode constant + && QueryFieldCatalog.TryGetKind(field.WireName, out QueryValueKind kind)) { - StringNode equality = new(QueryStringOperation.EqualsOrdinal, left, right); - return comparison == QueryComparison.Equal ? equality : new NotNode(equality); + if (kind == QueryValueKind.String && constant.Value is StringConstant) + { + StringNode equality = new(QueryStringOperation.EqualsOrdinal, left, right); + return comparison == QueryComparison.Equal ? equality : new NotNode(equality); + } + + if (kind == QueryValueKind.TypedId && constant.Value is StringConstant id) + { + right = new ConstantNode(new TypedIdConstant(field.Target, id.Value)); + } } return new ComparisonNode(comparison, left, right); diff --git a/tests/LibTmux.UnitTests/Query/QuerySemanticsTests.cs b/tests/LibTmux.UnitTests/Query/QuerySemanticsTests.cs index 56f2c78..78dbecf 100644 --- a/tests/LibTmux.UnitTests/Query/QuerySemanticsTests.cs +++ b/tests/LibTmux.UnitTests/Query/QuerySemanticsTests.cs @@ -17,6 +17,8 @@ private sealed record SessionDoubleCountRow(string SessionName, double SessionWi private sealed record NullableRow(string? SessionName); + private sealed record PaneIdRow(string PaneId); + private sealed record WindowCountRow(string WindowName, long WindowPanes); [Fact] @@ -188,6 +190,20 @@ public void A_boolean_field_is_a_complete_predicate() Assert.False(predicate(new Row("build", false))); } + [Fact] + public void A_typed_id_field_can_be_compared_through_a_string_projection() + { + QueryDocument document = QueryExtensions.Translate(row => row.PaneId == "%1"); + + ComparisonNode comparison = Assert.IsType(document.Predicate); + Assert.Equal( + new TypedIdConstant(QueryTarget.Pane, "%1"), + Assert.IsType(comparison.Right).Value); + Func predicate = document.Compile(); + Assert.True(predicate(new PaneIdRow("%1"))); + Assert.False(predicate(new PaneIdRow("%2"))); + } + [Fact] public void A_present_property_can_match_a_null_constant() { From 2258e83ff1f42a7b4df8df782a2cf31267ac9311 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 07:32:34 -0500 Subject: [PATCH 036/129] Query(fix[depth]): Capture scalar relations why: Scalar relation fields need their child collection even when a query compares only the tmux count. what: - derive relation depth from every relation field node - cover session window and window pane count projections --- src/LibTmux/Query/QueryDocument.cs | 2 ++ tests/LibTmux.UnitTests/Query/QuerySemanticsTests.cs | 12 ++++++++++++ 2 files changed, 14 insertions(+) diff --git a/src/LibTmux/Query/QueryDocument.cs b/src/LibTmux/Query/QueryDocument.cs index b9e80dd..49966c0 100644 --- a/src/LibTmux/Query/QueryDocument.cs +++ b/src/LibTmux/Query/QueryDocument.cs @@ -46,6 +46,8 @@ public sealed record QueryDocument( Depth(comparison.Right, target)), StringNode text => Deepest(Depth(text.Left, target), Depth(text.Right, target)), RegexNode regex => Depth(regex.Input, target), + FieldNode field when QueryFieldCatalog.IsRelation(field.WireName) => + RelationDepth(field.WireName), FieldNode field => Base(field.Target), _ => Base(target), }; diff --git a/tests/LibTmux.UnitTests/Query/QuerySemanticsTests.cs b/tests/LibTmux.UnitTests/Query/QuerySemanticsTests.cs index 78dbecf..d30cfb4 100644 --- a/tests/LibTmux.UnitTests/Query/QuerySemanticsTests.cs +++ b/tests/LibTmux.UnitTests/Query/QuerySemanticsTests.cs @@ -159,6 +159,18 @@ public void Relation_fields_keep_their_scalar_tmux_value_in_row_projections() Assert.True(panes.Compile()(new WindowCountRow("main", 2))); } + [Fact] + public void Scalar_relation_fields_require_their_capture_depth() + { + QueryDocument sessions = QueryExtensions.Translate( + row => row.SessionWindows > 1); + QueryDocument panes = QueryExtensions.Translate( + row => row.WindowPanes == 2); + + Assert.Equal(SnapshotDepth.Windows, sessions.RequiredSnapshotDepth); + Assert.Equal(SnapshotDepth.Panes, panes.RequiredSnapshotDepth); + } + [Fact] public void Integer_projections_compare_as_wire_int64_values() { From b1bce649c95578d5c2900b087cab144707cc03ec Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 07:48:57 -0500 Subject: [PATCH 037/129] Query(perf[compile]): Bind plans once why: Recursive evaluation repeated member discovery and regex construction for every row and could not distinguish scalar relation counts from relation traversal. what: - bind constants, members, relations, and regexes once per query compilation - generate direct built-in entity accessors with a validated projection fallback - reject invalid projection shapes before enumeration and cover the binding contract --- .../FieldCatalogGenerator.cs | 82 +++++ src/LibTmux/Query/QueryDocumentValidator.cs | 68 +++- src/LibTmux/Query/QueryInterpreter.cs | 321 +++++++++++++----- src/LibTmux/Query/QueryPlanBindings.cs | 194 +++++++++++ src/LibTmux/Query/QueryRegexSemantics.cs | 9 +- .../Query/QueryCompilationTests.cs | 84 +++++ 6 files changed, 652 insertions(+), 106 deletions(-) create mode 100644 src/LibTmux/Query/QueryPlanBindings.cs create mode 100644 tests/LibTmux.UnitTests/Query/QueryCompilationTests.cs diff --git a/src/LibTmux.Generators/FieldCatalogGenerator.cs b/src/LibTmux.Generators/FieldCatalogGenerator.cs index a28c473..784266d 100644 --- a/src/LibTmux.Generators/FieldCatalogGenerator.cs +++ b/src/LibTmux.Generators/FieldCatalogGenerator.cs @@ -155,6 +155,88 @@ private static string Render() source.AppendLine(" return false;"); source.AppendLine(" }"); source.AppendLine(); + source.AppendLine( + " internal static bool TryBindEntityScalar(global::System.Type owner, " + + "string wireName, out QueryFieldAccessor accessor)"); + source.AppendLine(" {"); + foreach ((string wireName, string target, string kind, bool relation, string? property) in Fields) + { + if (property is null) + { + continue; + } + + string read = relation + ? $"checked((long)((global::LibTmux.{target})element).{property}.Count)" + : kind switch + { + "Boolean" => $"(bool)((global::LibTmux.{target})element).{property}", + "String" => $"(string)((global::LibTmux.{target})element).{property}", + "TypedId" => + $"(global::LibTmux.{target}Id)((global::LibTmux.{target})element).{property}", + _ => $"((global::LibTmux.{target})element).{property}", + }; + string valueType = relation + ? "typeof(long)" + : kind switch + { + "Boolean" => "typeof(bool)", + "String" => "typeof(string)", + "TypedId" => $"typeof(global::LibTmux.{target}Id)", + _ => + $"typeof(global::LibTmux.{target}).GetProperty(\"{property}\")!.PropertyType", + }; + + source.AppendLine( + $" if (owner == typeof(global::LibTmux.{target}) " + + $"&& wireName == \"{wireName}\")"); + source.AppendLine(" {"); + source.AppendLine( + " accessor = new QueryFieldAccessor(" + + $"static element => {read}, {valueType});"); + source.AppendLine(" return true;"); + source.AppendLine(" }"); + } + + source.AppendLine(" accessor = null!;"); + source.AppendLine(" return false;"); + source.AppendLine(" }"); + source.AppendLine(); + source.AppendLine( + " internal static bool TryBindEntityRelation(global::System.Type owner, " + + "string wireName, out QueryFieldAccessor accessor)"); + source.AppendLine(" {"); + foreach ((string wireName, string target, _, bool relation, string? property) in Fields) + { + if (!relation || property is null) + { + continue; + } + + string child = wireName switch + { + "session_windows" => "Window", + "window_panes" => "Pane", + _ => throw new System.InvalidOperationException( + $"Unknown relation field '{wireName}'."), + }; + source.AppendLine( + $" if (owner == typeof(global::LibTmux.{target}) " + + $"&& wireName == \"{wireName}\")"); + source.AppendLine(" {"); + source.AppendLine( + " accessor = new QueryFieldAccessor(" + + $"static element => (global::LibTmux.CapturedRelation)" + + $"((global::LibTmux.{target})element).{property}, " + + $"typeof(global::LibTmux.CapturedRelation));"); + source.AppendLine(" return true;"); + source.AppendLine(" }"); + } + + source.AppendLine(" accessor = null!;"); + source.AppendLine(" return false;"); + source.AppendLine(" }"); + source.AppendLine(); source.AppendLine(" internal static IReadOnlyList WireNames { get; } ="); source.AppendLine(" ["); foreach ((string wireName, _, _, _, _) in Fields) diff --git a/src/LibTmux/Query/QueryDocumentValidator.cs b/src/LibTmux/Query/QueryDocumentValidator.cs index d469744..d1c8611 100644 --- a/src/LibTmux/Query/QueryDocumentValidator.cs +++ b/src/LibTmux/Query/QueryDocumentValidator.cs @@ -1,8 +1,10 @@ +using System.Text.RegularExpressions; + namespace LibTmux.Query; internal static class QueryDocumentValidator { - internal static void Validate(QueryDocument document) + internal static QueryValidationResult Validate(QueryDocument document) { ArgumentNullException.ThrowIfNull(document); if (!string.Equals( @@ -15,10 +17,15 @@ internal static void Validate(QueryDocument document) } _ = Target(document.Target); - ValidatePredicate(document.Predicate, document.Target); + QueryValidationResult result = new(); + ValidatePredicate(document.Predicate, document.Target, result); + return result; } - private static void ValidatePredicate(QueryNode? node, QueryTarget expectedTarget) + private static void ValidatePredicate( + QueryNode? node, + QueryTarget expectedTarget, + QueryValidationResult result) { switch (node) { @@ -26,13 +33,13 @@ private static void ValidatePredicate(QueryNode? node, QueryTarget expectedTarge case ConstantNode { Value: BooleanConstant }: return; case AndNode and: - ValidateOperands(and.Operands, expectedTarget); + ValidateOperands(and.Operands, expectedTarget, result); return; case OrNode or: - ValidateOperands(or.Operands, expectedTarget); + ValidateOperands(or.Operands, expectedTarget, result); return; case NotNode not: - ValidatePredicate(not.Operand, expectedTarget); + ValidatePredicate(not.Operand, expectedTarget, result); return; case ComparisonNode comparison: ValidateComparison(comparison, expectedTarget); @@ -41,10 +48,10 @@ private static void ValidatePredicate(QueryNode? node, QueryTarget expectedTarge ValidateString(text, expectedTarget); return; case RegexNode regex: - ValidateRegex(regex, expectedTarget); + ValidateRegex(regex, expectedTarget, result); return; case QuantifierNode quantifier: - ValidateQuantifier(quantifier, expectedTarget); + ValidateQuantifier(quantifier, expectedTarget, result); return; case null: throw Unsupported("Query predicate is null."); @@ -55,11 +62,12 @@ private static void ValidatePredicate(QueryNode? node, QueryTarget expectedTarge private static void ValidateOperands( IReadOnlyList operands, - QueryTarget expectedTarget) + QueryTarget expectedTarget, + QueryValidationResult result) { foreach (QueryNode operand in operands) { - ValidatePredicate(operand, expectedTarget); + ValidatePredicate(operand, expectedTarget, result); } } @@ -118,7 +126,10 @@ private static void ValidateString(StringNode text, QueryTarget expectedTarget) }; } - private static void ValidateRegex(RegexNode regex, QueryTarget expectedTarget) + private static void ValidateRegex( + RegexNode regex, + QueryTarget expectedTarget, + QueryValidationResult result) { if (regex.Input is not FieldNode field || ResolveField(field, expectedTarget) != QueryValueKind.String @@ -129,7 +140,7 @@ private static void ValidateRegex(RegexNode regex, QueryTarget expectedTarget) || !QueryTextSemantics.TryCountScalars(regex.Pattern, out int length) || length > QueryRegexSemantics.MaximumPatternLength || !QueryRegexSemantics.IsSupported(regex.SemanticOptions) - || !QueryRegexSemantics.IsValidPattern(regex.Pattern, regex.SemanticOptions)) + || !result.TryAddRegex(regex)) { throw Unsupported("Regex does not match the query wire semantics."); } @@ -137,7 +148,8 @@ private static void ValidateRegex(RegexNode regex, QueryTarget expectedTarget) private static void ValidateQuantifier( QuantifierNode quantifier, - QueryTarget expectedTarget) + QueryTarget expectedTarget, + QueryValidationResult result) { _ = ResolveField(quantifier.Relation, expectedTarget); if (quantifier.Quantifier is not QueryQuantifier.Any and not QueryQuantifier.All @@ -152,7 +164,7 @@ private static void ValidateQuantifier( "window_panes" => QueryTarget.Pane, _ => throw Unsupported("Quantifier does not name a supported relation."), }; - ValidatePredicate(quantifier.Predicate, childTarget); + ValidatePredicate(quantifier.Predicate, childTarget, result); } private static QueryValueKind ResolveField(FieldNode field, QueryTarget expectedTarget) @@ -209,3 +221,31 @@ private static void ValidateConstant( private static UnsupportedQueryExpressionException Unsupported(string message) => new(message); } + +internal sealed class QueryValidationResult +{ + private readonly Dictionary _regexes = []; + + internal int RegexCount => _regexes.Count; + + internal Regex GetRegex(RegexNode node) => _regexes[node]; + + internal bool TryAddRegex(RegexNode node) + { + if (_regexes.ContainsKey(node)) + { + return true; + } + + if (!QueryRegexSemantics.TryCreate( + node.Pattern, + node.SemanticOptions, + out Regex? regex)) + { + return false; + } + + _regexes.Add(node, regex); + return true; + } +} diff --git a/src/LibTmux/Query/QueryInterpreter.cs b/src/LibTmux/Query/QueryInterpreter.cs index 64eff48..11e72e3 100644 --- a/src/LibTmux/Query/QueryInterpreter.cs +++ b/src/LibTmux/Query/QueryInterpreter.cs @@ -1,6 +1,5 @@ using System.Diagnostics.CodeAnalysis; using System.Globalization; -using System.Text.RegularExpressions; namespace LibTmux.Query; @@ -17,49 +16,126 @@ internal static class QueryInterpreter "Compiling a query reads public properties by name. Trimmed applications must preserve the filtered types' public properties."; [RequiresUnreferencedCode(TrimmingMessage)] - internal static Func Compile(QueryDocument document) + internal static Func Compile(QueryDocument document) => + Compile(document, out _); + + [RequiresUnreferencedCode(TrimmingMessage)] + internal static Func Compile( + QueryDocument document, + out QueryBindingMetrics metrics) { ArgumentNullException.ThrowIfNull(document); - QueryDocumentValidator.Validate(document); - return element => Evaluate(document.Predicate, element!); - } - - private static bool Evaluate(QueryNode node, object element) => node switch - { - AndNode and => and.Operands.All(operand => Evaluate(operand, element)), - OrNode or => or.Operands.Any(operand => Evaluate(operand, element)), - NotNode not => !Evaluate(not.Operand, element), - ComparisonNode comparison => Compare(comparison, element), - StringNode text => CompareText(text, element), - RegexNode regex => Regex.IsMatch( - ReadText(regex.Input, element) ?? string.Empty, - regex.Pattern, - regex.SemanticOptions, - QueryRegexSemantics.MatchTimeout), - QuantifierNode quantifier => Quantify(quantifier, element), - FieldNode field => ReadBoolean(field, element), - ConstantNode { Value: BooleanConstant boolean } => boolean.Value, - _ => throw new UnsupportedQueryExpressionException( - $"Node '{node.GetType().Name}' has no interpretation."), - }; + QueryValidationResult validation = QueryDocumentValidator.Validate(document); + QueryPlanBindings bindings = new(validation); + Func predicate = BindPredicate(document.Predicate, typeof(T), bindings); + metrics = bindings.Metrics; + return element => predicate(element!); + } - private static bool Quantify(QuantifierNode quantifier, object element) + private static Func BindPredicate( + QueryNode node, + Type elementType, + QueryPlanBindings bindings) => node switch + { + AndNode and => BindAnd(and, elementType, bindings), + OrNode or => BindOr(or, elementType, bindings), + NotNode not => BindNot(not, elementType, bindings), + ComparisonNode comparison => BindComparison(comparison, elementType, bindings), + StringNode text => BindText(text, elementType, bindings), + RegexNode regex => BindRegex(regex, elementType, bindings), + QuantifierNode quantifier => BindQuantifier(quantifier, elementType, bindings), + FieldNode field => BindBoolean(field, elementType, bindings), + ConstantNode { Value: BooleanConstant boolean } => _ => boolean.Value, + _ => throw new UnsupportedQueryExpressionException( + $"Node '{node.GetType().Name}' has no interpretation."), + }; + + private static Func BindAnd( + AndNode and, + Type elementType, + QueryPlanBindings bindings) { - object? relation = Read(quantifier.Relation, element); - IEnumerable children = relation is System.Collections.IEnumerable sequence - ? sequence.Cast() - : []; - // Any over nothing is false and All over nothing is true, matching both - // the design spec and LINQ. - return quantifier.Quantifier == QueryQuantifier.Any - ? children.Any(child => Evaluate(quantifier.Predicate, child)) - : children.All(child => Evaluate(quantifier.Predicate, child)); + Func[] operands = + [.. and.Operands.Select(operand => BindPredicate(operand, elementType, bindings))]; + return element => AllOperands(operands, element); + } + + private static Func BindOr( + OrNode or, + Type elementType, + QueryPlanBindings bindings) + { + Func[] operands = + [.. or.Operands.Select(operand => BindPredicate(operand, elementType, bindings))]; + return element => AnyOperand(operands, element); + } + + private static bool AllOperands(Func[] operands, object element) + { + foreach (Func operand in operands) + { + if (!operand(element)) + { + return false; + } + } + + return true; } - private static bool Compare(ComparisonNode comparison, object element) + private static bool AnyOperand(Func[] operands, object element) + { + foreach (Func operand in operands) + { + if (operand(element)) + { + return true; + } + } + + return false; + } + + private static Func BindNot( + NotNode not, + Type elementType, + QueryPlanBindings bindings) + { + Func operand = BindPredicate(not.Operand, elementType, bindings); + return element => !operand(element); + } + + private static Func BindComparison( + ComparisonNode comparison, + Type elementType, + QueryPlanBindings bindings) + { + Func leftReader = BindOperand( + comparison.Left, + elementType, + bindings); + Func rightReader = BindOperand( + comparison.Right, + elementType, + bindings); + QueryValueKind? kind = comparison.Left is FieldNode field + && QueryFieldCatalog.TryGetKind(field.WireName, out QueryValueKind resolved) + ? resolved + : null; + + return element => Compare( + comparison, + kind, + leftReader(element), + rightReader(element)); + } + + private static bool Compare( + ComparisonNode comparison, + QueryValueKind? kind, + object? left, + object? right) { - object? left = Read(comparison.Left, element); - object? right = Read(comparison.Right, element); if (left is null || right is null) { return comparison.Operator switch @@ -82,10 +158,9 @@ private static bool Compare(ComparisonNode comparison, object element) return comparison.Operator == QueryComparison.Equal ? equal : !equal; } - int order = comparison.Left is FieldNode field - && QueryFieldCatalog.TryGetKind(field.WireName, out QueryValueKind kind) - && kind == QueryValueKind.Int64 - ? ReadInt64(field, left).CompareTo(ReadInt64(field, right)) + int order = kind == QueryValueKind.Int64 + ? ReadInt64((FieldNode)comparison.Left, left) + .CompareTo(ReadInt64((FieldNode)comparison.Left, right)) : Comparer.Default.Compare(left, right); return comparison.Operator switch { @@ -113,11 +188,27 @@ private static bool Compare(ComparisonNode comparison, object element) $"Field '{field.WireName}' did not produce an integer value."), }; - private static bool CompareText(StringNode text, object element) + private static Func BindText( + StringNode text, + Type elementType, + QueryPlanBindings bindings) { - string left = ReadText(text.Left, element) ?? string.Empty; - string right = ReadText(text.Right, element) ?? string.Empty; - return text.Operator switch + Func leftReader = BindOperand(text.Left, elementType, bindings); + Func rightReader = BindOperand(text.Right, elementType, bindings); + return element => CompareText( + text.Operator, + ReadText(leftReader(element)), + ReadText(rightReader(element))); + } + + private static bool CompareText( + QueryStringOperation operation, + string? leftValue, + string? rightValue) + { + string left = leftValue ?? string.Empty; + string right = rightValue ?? string.Empty; + return operation switch { QueryStringOperation.EqualsOrdinal => string.Equals(left, right, StringComparison.Ordinal), @@ -133,61 +224,115 @@ private static bool CompareText(StringNode text, object element) }; } - private static string? ReadText(QueryNode node, object element) => - Read(node, element) is object value - ? Convert.ToString(value, CultureInfo.InvariantCulture) - : null; - - private static bool ReadBoolean(FieldNode field, object element) => - Read(field, element) is bool value - ? value - : throw new UnsupportedQueryExpressionException( - $"Field '{field.WireName}' did not produce a Boolean value."); + private static Func BindRegex( + RegexNode node, + Type elementType, + QueryPlanBindings bindings) + { + Func input = BindOperand(node.Input, elementType, bindings); + var regex = bindings.Regex(node); + return element => regex.IsMatch(ReadText(input(element)) ?? string.Empty); + } - private static object? Read(QueryNode node, object element) => node switch + private static Func BindQuantifier( + QuantifierNode quantifier, + Type elementType, + QueryPlanBindings bindings) { - ConstantNode constant => Literal(constant.Value), - FieldNode field => ReadMember(field, element), - _ => throw new UnsupportedQueryExpressionException( - $"Node '{node.GetType().Name}' is not an operand."), - }; + QueryFieldAccessor relation = bindings.Field( + quantifier.Relation, + elementType, + QueryFieldRole.Relation); + Type childType = QueryPlanBindings.RelationElementType( + quantifier.Relation, + relation.ValueType); + Func predicate = BindPredicate( + quantifier.Predicate, + childType, + bindings); - [UnconditionalSuppressMessage( - "Trimming", - "IL2075:Members might be removed", - Justification = "Every public compilation entry point declares the metadata requirement.")] - private static object? ReadMember(FieldNode field, object element) + return quantifier.Quantifier == QueryQuantifier.Any + ? element => Any(relation.Read(element), predicate) + : element => All(relation.Read(element), predicate); + } + + private static bool Any(object? relation, Func predicate) { - Type type = element.GetType(); + if (relation is not System.Collections.IEnumerable children) + { + return false; + } - // A document can be deserialized from elsewhere, so a FieldNode may - // not be one this library minted. Resolving an unknown wire name by - // convention would let a forged node read any public property, so the - // name is checked against the catalog first. - if (!QueryFieldCatalog.TryGetTarget(field.WireName, out _)) + foreach (object child in children) { - throw new UnsupportedQueryExpressionException( - $"Field '{field.WireName}' is not in the query catalog."); + if (predicate(child)) + { + return true; + } } - // Reading has to resolve the same pair translating wrote: an entity - // holds session_attached under Attached, and a row a caller declared - // holds it under SessionAttached. - string property = - QueryFieldCatalog.TryGetProperty(type, field.WireName, out string mapped) - ? mapped - : ToClrName(field.WireName); + return false; + } + + private static bool All(object? relation, Func predicate) + { + if (relation is not System.Collections.IEnumerable children) + { + return true; + } - var member = type.GetProperty(property); - if (member is null) + foreach (object child in children) { - throw new UnsupportedQueryExpressionException( - $"Element exposes no member for field '{field.WireName}'."); + if (!predicate(child)) + { + return false; + } } - return member.GetValue(element); + return true; + } + + private static Func BindBoolean( + FieldNode field, + Type elementType, + QueryPlanBindings bindings) + { + QueryFieldAccessor accessor = bindings.Field( + field, + elementType, + QueryFieldRole.Scalar); + return element => accessor.Read(element) is bool value + ? value + : throw new UnsupportedQueryExpressionException( + $"Field '{field.WireName}' did not produce a Boolean value."); + } + + private static Func BindOperand( + QueryNode node, + Type elementType, + QueryPlanBindings bindings) => + node switch + { + ConstantNode constant => BindConstant(constant), + FieldNode field => bindings.Field( + field, + elementType, + QueryFieldRole.Scalar).Read, + _ => throw new UnsupportedQueryExpressionException( + $"Node '{node.GetType().Name}' is not an operand."), + }; + + private static Func BindConstant(ConstantNode constant) + { + object? value = Literal(constant.Value); + return _ => value; } + private static string? ReadText(object? operand) => + operand is object value + ? Convert.ToString(value, CultureInfo.InvariantCulture) + : null; + private static object? Literal(QueryConstant constant) => constant switch { NullConstant => null, @@ -201,8 +346,4 @@ private static bool ReadBoolean(FieldNode field, object element) => $"Constant '{constant.GetType().Name}' has no value."), }; - private static string ToClrName(string wireName) => - string.Concat( - wireName.Split('_', StringSplitOptions.RemoveEmptyEntries) - .Select(static part => char.ToUpperInvariant(part[0]) + part[1..])); } diff --git a/src/LibTmux/Query/QueryPlanBindings.cs b/src/LibTmux/Query/QueryPlanBindings.cs new file mode 100644 index 0000000..2033b59 --- /dev/null +++ b/src/LibTmux/Query/QueryPlanBindings.cs @@ -0,0 +1,194 @@ +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using System.Text.RegularExpressions; + +namespace LibTmux.Query; + +internal sealed record QueryFieldAccessor( + Func Read, + Type ValueType); + +internal enum QueryFieldRole +{ + Scalar, + Relation, +} + +internal readonly record struct QueryBindingMetrics( + int FieldBindings, + int RegexBindings); + +internal sealed class QueryPlanBindings +{ + private readonly Dictionary _fields = []; + private readonly QueryValidationResult _validation; + + internal QueryPlanBindings(QueryValidationResult validation) => + _validation = validation; + + internal QueryBindingMetrics Metrics => new(_fields.Count, _validation.RegexCount); + + internal Regex Regex(RegexNode node) => _validation.GetRegex(node); + + internal QueryFieldAccessor Field( + FieldNode field, + Type elementType, + QueryFieldRole role) + { + FieldKey key = new(elementType, field.WireName, role); + if (_fields.TryGetValue(key, out QueryFieldAccessor? accessor)) + { + return accessor; + } + + accessor = ResolveField(field, elementType, role); + _fields.Add(key, accessor); + return accessor; + } + + [UnconditionalSuppressMessage( + "Trimming", + "IL2070:Members might be removed", + Justification = "Every public compilation entry point declares the metadata requirement.")] + private static QueryFieldAccessor ResolveField( + FieldNode field, + Type elementType, + QueryFieldRole role) + { + if (!QueryFieldCatalog.TryGetTarget(field.WireName, out _)) + { + throw Unsupported($"Field '{field.WireName}' is not in the query catalog."); + } + + QueryFieldAccessor? accessor = role switch + { + QueryFieldRole.Scalar when QueryFieldCatalog.TryBindEntityScalar( + elementType, + field.WireName, + out QueryFieldAccessor scalar) => scalar, + QueryFieldRole.Relation when QueryFieldCatalog.TryBindEntityRelation( + elementType, + field.WireName, + out QueryFieldAccessor relation) => relation, + _ => null, + }; + if (accessor is null) + { + string property = + QueryFieldCatalog.TryGetProperty(elementType, field.WireName, out string mapped) + ? mapped + : ToClrName(field.WireName); + PropertyInfo? member; + try + { + member = elementType.GetProperty( + property, + BindingFlags.Instance | BindingFlags.Public); + } + catch (AmbiguousMatchException) + { + member = null; + } + + if (member?.GetMethod is not { IsStatic: false, IsPublic: true } + || member.GetIndexParameters().Length != 0) + { + throw Unsupported( + $"Type '{elementType.Name}' exposes no readable member for field " + + $"'{field.WireName}'."); + } + + accessor = new QueryFieldAccessor(member.GetValue, member.PropertyType); + } + + if (role == QueryFieldRole.Scalar) + { + RequireScalarType(field, accessor.ValueType); + } + + return accessor; + } + + internal static Type RelationElementType(FieldNode field, Type relationType) => + SequenceElementType(relationType) + ?? throw Unsupported($"Field '{field.WireName}' is not a typed relation."); + + [UnconditionalSuppressMessage( + "Trimming", + "IL2070:Interfaces might be removed", + Justification = "Every public compilation entry point declares the metadata requirement.")] + private static Type? SequenceElementType(Type type) + { + if (type.IsArray) + { + return type.GetElementType(); + } + + IEnumerable candidates = type.IsGenericType + && type.GetGenericTypeDefinition() == typeof(IEnumerable<>) + ? [type] + : type.GetInterfaces().Where(static candidate => + candidate.IsGenericType + && candidate.GetGenericTypeDefinition() == typeof(IEnumerable<>)); + Type[] elements = + [ + .. candidates.Select(static candidate => candidate.GetGenericArguments()[0]) + .Distinct(), + ]; + return elements.Length == 1 ? elements[0] : null; + } + + private static void RequireScalarType(FieldNode field, Type propertyType) + { + if (!QueryFieldCatalog.TryGetKind(field.WireName, out QueryValueKind kind)) + { + throw Unsupported($"Field '{field.WireName}' is not in the query catalog."); + } + + Type valueType = Nullable.GetUnderlyingType(propertyType) ?? propertyType; + bool compatible = kind switch + { + QueryValueKind.Boolean => valueType == typeof(bool), + QueryValueKind.Int64 => IsInteger(valueType), + QueryValueKind.String => valueType == typeof(string), + QueryValueKind.Instant => IsInteger(valueType), + QueryValueKind.Enum => valueType == typeof(string) || valueType.IsEnum, + QueryValueKind.TypedId => IsTypedId(field.Target, valueType), + _ => false, + }; + if (!compatible) + { + throw Unsupported( + $"Member for field '{field.WireName}' has incompatible type " + + $"'{propertyType.Name}'."); + } + } + + private static bool IsInteger(Type type) => + type == typeof(sbyte) + || type == typeof(byte) + || type == typeof(short) + || type == typeof(ushort) + || type == typeof(int) + || type == typeof(uint) + || type == typeof(long) + || type == typeof(ulong); + + private static bool IsTypedId(QueryTarget target, Type type) => + type == typeof(string) + || target == QueryTarget.Session && type == typeof(SessionId) + || target == QueryTarget.Window && type == typeof(WindowId) + || target == QueryTarget.Pane && type == typeof(PaneId); + + private static string ToClrName(string wireName) => + string.Concat( + wireName.Split('_', StringSplitOptions.RemoveEmptyEntries) + .Select(static part => char.ToUpperInvariant(part[0]) + part[1..])); + + private static UnsupportedQueryExpressionException Unsupported(string message) => new(message); + + private readonly record struct FieldKey( + Type Owner, + string WireName, + QueryFieldRole Role); +} diff --git a/src/LibTmux/Query/QueryRegexSemantics.cs b/src/LibTmux/Query/QueryRegexSemantics.cs index ea463d1..e61dadd 100644 --- a/src/LibTmux/Query/QueryRegexSemantics.cs +++ b/src/LibTmux/Query/QueryRegexSemantics.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using System.Text.RegularExpressions; namespace LibTmux.Query; @@ -22,15 +23,19 @@ internal static bool IsSupported(RegexOptions options) => (options & ~AllowedOptions) == 0 && (options & RegexOptions.CultureInvariant) != 0; - internal static bool IsValidPattern(string pattern, RegexOptions options) + internal static bool TryCreate( + string pattern, + RegexOptions options, + [NotNullWhen(true)] out Regex? regex) { try { - _ = new Regex(pattern, options, MatchTimeout); + regex = new Regex(pattern, options, MatchTimeout); return true; } catch (ArgumentException) { + regex = null; return false; } } diff --git a/tests/LibTmux.UnitTests/Query/QueryCompilationTests.cs b/tests/LibTmux.UnitTests/Query/QueryCompilationTests.cs new file mode 100644 index 0000000..c003dbc --- /dev/null +++ b/tests/LibTmux.UnitTests/Query/QueryCompilationTests.cs @@ -0,0 +1,84 @@ +using System.Text.RegularExpressions; +using LibTmux.Internal; +using LibTmux.Query; + +namespace LibTmux.UnitTests.Query; + +public sealed class QueryCompilationTests +{ + private sealed record Row(string SessionName); + + private sealed record SessionCountRow(long SessionWindows); + + private sealed record MissingNameRow(string Other); + + private sealed record IncompatibleNameRow(int SessionName); + + private sealed class WriteOnlyNameRow + { + public string SessionName { private get; set; } = string.Empty; + } + + [Fact] + public void Compilation_rejects_a_missing_projection_member() + { + QueryDocument document = QueryEdgeParser.ParseNameContains(QueryTarget.Session, "dev"); + + UnsupportedQueryExpressionException error = + Assert.Throws(document.Compile); + + Assert.Contains("session_name", error.Message, StringComparison.Ordinal); + } + + [Fact] + public void Compilation_binds_a_reused_member_and_regex_once() + { + FieldNode field = new(QueryTarget.Session, "session_name"); + RegexNode regex = new( + field, + QueryRegexSemantics.Dialect, + "^build", + RegexOptions.CultureInvariant); + QueryDocument document = new( + QueryDocument.CurrentSchema, + QueryDocument.CurrentVersion, + QueryTarget.Session, + new AndNode([regex, regex])); + + Func predicate = QueryInterpreter.Compile( + document, + out QueryBindingMetrics metrics); + + Assert.Equal(new QueryBindingMetrics(1, 1), metrics); + Assert.True(predicate(new Row("build-one"))); + Assert.False(predicate(new Row("other"))); + } + + [Fact] + public void Compilation_rejects_unreadable_and_incompatible_projection_members() + { + QueryDocument document = QueryEdgeParser.ParseNameContains(QueryTarget.Session, "dev"); + + Assert.Throws(document.Compile); + Assert.Throws(document.Compile); + } + + [Fact] + public void Relation_fields_read_counts_from_captured_entities() + { + var dispatcher = new TmuxCommandDispatcher( + static (_, _) => throw new InvalidOperationException("No command expected.")); + Window[] windows = + [ + new Window(dispatcher, "@1"), + new Window(dispatcher, "@2"), + ]; + var session = new Session(dispatcher, "$1").WithCaptured( + () => CapturedRelation.Capture(windows, "windows", SnapshotDepth.Windows), + CapturedRelation.Capture([], "panes", SnapshotDepth.Panes)); + QueryDocument sessions = QueryExtensions.Translate( + row => row.SessionWindows > 1); + + Assert.True(sessions.Compile()(session)); + } +} From 80bf7efe926de79cde9cdbb888767760c54aa9a5 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 07:51:40 -0500 Subject: [PATCH 038/129] Query(fix[translation]): Keep constant capture pure why: Constant translation compiled arbitrary parameter-independent expressions, which executed caller methods and getters and excluded NativeAOT. what: - read only literals and compiler-generated closure fields - reject methods, getters, static fields, and nested member access without execution - exercise captured-local translation in the NativeAOT smoke --- src/LibTmux/Query/QueryExtensions.cs | 4 -- src/LibTmux/Query/QueryTranslator.cs | 28 ++++---- tests/LibTmux.AotSmoke/Program.cs | 11 ++++ .../Query/QueryTranslationSafetyTests.cs | 66 +++++++++++++++++++ 4 files changed, 90 insertions(+), 19 deletions(-) create mode 100644 tests/LibTmux.UnitTests/Query/QueryTranslationSafetyTests.cs diff --git a/src/LibTmux/Query/QueryExtensions.cs b/src/LibTmux/Query/QueryExtensions.cs index 6659640..1e2a1ad 100644 --- a/src/LibTmux/Query/QueryExtensions.cs +++ b/src/LibTmux/Query/QueryExtensions.cs @@ -18,8 +18,6 @@ public static class QueryExtensions /// /// The expression contains a node the query vocabulary does not cover. /// - [RequiresDynamicCode( - "Translating an expression evaluates its captured values, which needs runtime code generation.")] public static QueryDocument Translate(Expression> predicate) => QueryTranslator.Translate(predicate); @@ -36,8 +34,6 @@ public static Func Compile(this QueryDocument document) => /// The captured elements. /// The predicate to translate and apply. /// The matching elements. - [RequiresDynamicCode( - "Translating an expression evaluates its captured values, which needs runtime code generation.")] [RequiresUnreferencedCode(QueryInterpreter.TrimmingMessage)] public static IReadOnlyList Matching( this IEnumerable source, diff --git a/src/LibTmux/Query/QueryTranslator.cs b/src/LibTmux/Query/QueryTranslator.cs index 9f19ac0..aa496d2 100644 --- a/src/LibTmux/Query/QueryTranslator.cs +++ b/src/LibTmux/Query/QueryTranslator.cs @@ -1,6 +1,6 @@ -using System.Diagnostics.CodeAnalysis; using System.Linq.Expressions; using System.Reflection; +using System.Runtime.CompilerServices; using System.Text.RegularExpressions; namespace LibTmux.Query; @@ -11,11 +11,6 @@ namespace LibTmux.Query; /// raises rather than being /// left for in-memory evaluation, so one predicate cannot mean two things. /// -// Reading a captured value out of an expression means running the code that -// produced it, and running code an expression describes needs the runtime to -// generate it. Ahead-of-time publishing cannot, so every caller is told. -[RequiresDynamicCode( - "Translating an expression evaluates its captured values, which needs runtime code generation.")] internal static class QueryTranslator { internal static QueryDocument Translate(Expression> predicate) @@ -284,18 +279,21 @@ private static bool TryConstant(Expression expression, out object? value) return true; } - try + if (expression is MemberExpression + { + Expression: ConstantExpression { Value: not null } closure, + Member: FieldInfo { IsStatic: false } field, + } + && field.DeclaringType?.IsDefined( + typeof(CompilerGeneratedAttribute), + inherit: false) == true) { - value = Expression.Lambda(Expression.Convert(expression, typeof(object))) - .Compile() - .DynamicInvoke(); + value = field.GetValue(closure.Value); return true; } - catch (InvalidOperationException) - { - value = null; - return false; - } + + value = null; + return false; } private static Expression StripConvert(Expression expression) => diff --git a/tests/LibTmux.AotSmoke/Program.cs b/tests/LibTmux.AotSmoke/Program.cs index 282dfec..08ec184 100644 --- a/tests/LibTmux.AotSmoke/Program.cs +++ b/tests/LibTmux.AotSmoke/Program.cs @@ -46,6 +46,7 @@ private static async Task Main() bool queryRoundTrips = QueryJson.Deserialize(QueryJson.Serialize(query)) == query; bool queryMatches = CompileQuery(query); + bool queryTranslates = TranslateQuery(); Server server = scope.Server; Session session = scope.Session; Window window = scope.Window; @@ -64,10 +65,12 @@ private static async Task Main() Console.WriteLine($"buffer {buffer}"); Console.WriteLine($"query-json {queryRoundTrips}"); Console.WriteLine($"query-compile {queryMatches}"); + Console.WriteLine($"query-translate {queryTranslates}"); return option.Value.Boolean == false && buffer == "aot" && queryRoundTrips && queryMatches + && queryTranslates ? 0 : 1; } @@ -80,4 +83,12 @@ private static async Task Main() Justification = "The dynamic dependency preserves the reflected query row.")] private static bool CompileQuery(QueryDocument query) => query.Compile()(new QueryRow("package-aot")); + + private static bool TranslateQuery() + { + string expected = "aot"; + QueryDocument query = QueryExtensions.Translate( + row => row.SessionName.Contains(expected)); + return CompileQuery(query); + } } diff --git a/tests/LibTmux.UnitTests/Query/QueryTranslationSafetyTests.cs b/tests/LibTmux.UnitTests/Query/QueryTranslationSafetyTests.cs new file mode 100644 index 0000000..c7ba740 --- /dev/null +++ b/tests/LibTmux.UnitTests/Query/QueryTranslationSafetyTests.cs @@ -0,0 +1,66 @@ +using LibTmux.Query; + +namespace LibTmux.UnitTests.Query; + +public sealed class QueryTranslationSafetyTests +{ + private sealed record Row(string SessionName); + + private sealed class SideEffectSource + { + internal int Reads { get; private set; } + + internal string Value + { + get + { + Reads++; + return "dev"; + } + } + + internal string Read() + { + Reads++; + return "dev"; + } + } + + [Fact] + public void Translation_rejects_method_constants_without_invoking_them() + { + var source = new SideEffectSource(); + + Exception? error = Record.Exception( + () => QueryExtensions.Translate(row => row.SessionName == source.Read())); + + Assert.Equal(0, source.Reads); + Assert.IsType(error); + } + + [Fact] + public void Translation_rejects_property_constants_without_reading_them() + { + var source = new SideEffectSource(); + + Exception? error = Record.Exception( + () => QueryExtensions.Translate(row => row.SessionName == source.Value)); + + Assert.Equal(0, source.Reads); + Assert.IsType(error); + } + + [Fact] + public void Translation_freezes_a_captured_local() + { + string expected = "dev"; + + QueryDocument document = QueryExtensions.Translate( + row => row.SessionName == expected); + + StringNode equality = Assert.IsType(document.Predicate); + Assert.Equal( + new StringConstant("dev"), + Assert.IsType(equality.Right).Value); + } +} From efa725b56f9649cb56b50fb7582ce6da28e14aba Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 07:52:16 -0500 Subject: [PATCH 039/129] ControlMode(fix[events]): Honor backlog cancellation why: Event iteration observed cancellation only while waiting on an empty buffer, so a canceled consumer could keep draining queued notifications. what: - check cancellation before every event dequeue - cover cancellation with a retained backlog on both target frameworks --- .../ControlMode/ControlModeEventBuffer.cs | 1 + .../ControlModeEventBufferTests.cs | 20 +++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/src/LibTmux/ControlMode/ControlModeEventBuffer.cs b/src/LibTmux/ControlMode/ControlModeEventBuffer.cs index abcdc6c..8a44004 100644 --- a/src/LibTmux/ControlMode/ControlModeEventBuffer.cs +++ b/src/LibTmux/ControlMode/ControlModeEventBuffer.cs @@ -71,6 +71,7 @@ internal async IAsyncEnumerable ReadAllAsync( { while (true) { + cancellationToken.ThrowIfCancellationRequested(); TmuxEvent? item = null; Task? wait = null; long dropped = 0; diff --git a/tests/LibTmux.UnitTests/ControlMode/ControlModeEventBufferTests.cs b/tests/LibTmux.UnitTests/ControlMode/ControlModeEventBufferTests.cs index 0a7782f..d9defa2 100644 --- a/tests/LibTmux.UnitTests/ControlMode/ControlModeEventBufferTests.cs +++ b/tests/LibTmux.UnitTests/ControlMode/ControlModeEventBufferTests.cs @@ -83,5 +83,25 @@ public async Task A_drop_after_dequeue_is_reported_after_the_held_event() Assert.False(await reader.MoveNextAsync()); } + [Fact] + public async Task Cancellation_stops_a_reader_before_it_drains_buffered_events() + { + using var canceled = CancellationTokenSource.CreateLinkedTokenSource( + TestContext.Current.CancellationToken); + var buffer = new ControlModeEventBuffer(capacity: 2); + Assert.True(buffer.TryWrite(Notification("first"))); + Assert.True(buffer.TryWrite(Notification("second"))); + + await using IAsyncEnumerator reader = + buffer.ReadAllAsync(canceled.Token).GetAsyncEnumerator(canceled.Token); + Assert.True(await reader.MoveNextAsync()); + Assert.Equal("first", Assert.IsType(reader.Current).Name); + + canceled.Cancel(); + + await Assert.ThrowsAnyAsync( + async () => await reader.MoveNextAsync()); + } + private static TmuxNotificationEvent Notification(string name) => new(name, []); } From 78b304d6c0d6f9d9d577161c98c800f9e7667ca3 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 07:52:49 -0500 Subject: [PATCH 040/129] ControlMode(fix[protocol]): Reject stray output why: A line outside a command block that was not a notification was silently discarded, hiding a protocol desynchronization. what: - fail the control session on output outside a block - cover the fail-closed boundary with the scripted process --- src/LibTmux/ControlMode/ControlModeSession.cs | 6 ++---- .../ControlMode/ControlModeCorrelationTests.cs | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/LibTmux/ControlMode/ControlModeSession.cs b/src/LibTmux/ControlMode/ControlModeSession.cs index cae25f4..5e72cb9 100644 --- a/src/LibTmux/ControlMode/ControlModeSession.cs +++ b/src/LibTmux/ControlMode/ControlModeSession.cs @@ -458,10 +458,8 @@ private async Task PumpAsync() if (!line.StartsWith('%')) { - // tmux prints nothing outside a block that is not a - // notification, so anything here is a protocol the reader - // does not know rather than data to guess at. - continue; + throw new InvalidDataException( + "The tmux control client sent output outside a block."); } (string name, IReadOnlyList arguments) = SplitNotification(line); diff --git a/tests/LibTmux.UnitTests/ControlMode/ControlModeCorrelationTests.cs b/tests/LibTmux.UnitTests/ControlMode/ControlModeCorrelationTests.cs index 76bd3e3..4fa664c 100644 --- a/tests/LibTmux.UnitTests/ControlMode/ControlModeCorrelationTests.cs +++ b/tests/LibTmux.UnitTests/ControlMode/ControlModeCorrelationTests.cs @@ -310,6 +310,21 @@ public async Task An_empty_error_block_does_not_invent_a_reported_line() Assert.Empty(error.ErrorLines); } + [Fact] + public async Task Output_outside_a_block_fails_the_session() + { + CancellationToken token = TestContext.Current.CancellationToken; + var process = new ScriptedProcess(expectedWrites: 0); + var session = new ControlModeSession(process); + await session.WaitForReadyAsync(token); + + process.EmitProtocolLine("unexpected output"); + + InvalidDataException error = await Assert.ThrowsAsync( + () => session.DisposeAsync().AsTask()); + Assert.Equal("The tmux control client sent output outside a block.", error.Message); + } + [Theory] [InlineData("%begin")] [InlineData("%begin malformed")] From 53fcf7dc762f70d0d0f7eb3982a5a44611bc0113 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 07:53:16 -0500 Subject: [PATCH 041/129] Workspace(fix[layouts]): Report tmux rejections why: Preflight layout refusals were reported, but malformed checksum layouts reached tmux and escaped as command failures after their windows were built. what: - classify both local and dispatched layout refusals as unsupported - exercise a safe bad-checksum layout on tmux 3.2a and current tmux --- src/LibTmux.Workspace/WorkspaceBuilder.cs | 7 ++++--- .../Workspace/WorkspaceBuilderTests.cs | 4 ++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/LibTmux.Workspace/WorkspaceBuilder.cs b/src/LibTmux.Workspace/WorkspaceBuilder.cs index 1fe9f8c..13e84dc 100644 --- a/src/LibTmux.Workspace/WorkspaceBuilder.cs +++ b/src/LibTmux.Workspace/WorkspaceBuilder.cs @@ -168,11 +168,12 @@ await target.SendTextAsync(command, cancellationToken: cancellationToken) cancellationToken) .ConfigureAwait(false); } - catch (TmuxWindowException failure) + catch (LibTmuxException failure) when ( + failure is TmuxWindowException or TmuxCommandException) { unsupported.Add( - $"window '{described.WindowName}' asks for layout " - + $"'{described.Layout}', which this tmux does not have: {failure.Message}"); + $"window '{described.WindowName}' layout '{described.Layout}' " + + $"was rejected: {failure.Message}"); } } diff --git a/tests/LibTmux.IntegrationTests/Workspace/WorkspaceBuilderTests.cs b/tests/LibTmux.IntegrationTests/Workspace/WorkspaceBuilderTests.cs index 508c029..ba7a060 100644 --- a/tests/LibTmux.IntegrationTests/Workspace/WorkspaceBuilderTests.cs +++ b/tests/LibTmux.IntegrationTests/Workspace/WorkspaceBuilderTests.cs @@ -95,7 +95,7 @@ public async Task What_tmux_cannot_do_is_reported_rather_than_dropped() session_name: libtmux-unsupported windows: - window_name: only - layout: not-a-layout + layout: "0000,not-a-layout" panes: - echo hello """); @@ -108,7 +108,7 @@ public async Task What_tmux_cannot_do_is_reported_rather_than_dropped() Assert.Equal("libtmux-unsupported", result.Session.Name); Assert.Contains( result.Unsupported, - message => message.Contains("not-a-layout", StringComparison.Ordinal)); + message => message.Contains("0000,not-a-layout", StringComparison.Ordinal)); } [UnixFact] From a6c5ef1fafaca3241c7e43a10f88bfaeb822513a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 07:56:30 -0500 Subject: [PATCH 042/129] Workspace(fix[result]): Freeze returned collections why: Workspace results exposed caller-owned mutable lists through read-only interfaces, and record equality compared those list objects by identity. what: - snapshot constructor and with-expression collection inputs - compare result collections structurally with matching hashes - give the durable result type its own source file --- src/LibTmux.Workspace/WorkspaceBuilder.cs | 9 -- src/LibTmux.Workspace/WorkspaceResult.cs | 97 +++++++++++++++++++ .../Workspace/WorkspaceResultTests.cs | 46 +++++++++ 3 files changed, 143 insertions(+), 9 deletions(-) create mode 100644 src/LibTmux.Workspace/WorkspaceResult.cs create mode 100644 tests/LibTmux.IntegrationTests/Workspace/WorkspaceResultTests.cs diff --git a/src/LibTmux.Workspace/WorkspaceBuilder.cs b/src/LibTmux.Workspace/WorkspaceBuilder.cs index 13e84dc..d6e4544 100644 --- a/src/LibTmux.Workspace/WorkspaceBuilder.cs +++ b/src/LibTmux.Workspace/WorkspaceBuilder.cs @@ -2,15 +2,6 @@ namespace LibTmux.Workspace; -/// Describes a built workspace and any layout tmux rejected. -/// The session that was built. -/// The windows, in the order the file listed them. -/// The layouts tmux rejected after creating their windows. -public sealed record WorkspaceResult( - Session Session, - IReadOnlyList Windows, - IReadOnlyList Unsupported); - /// Builds a tmux session from a tmuxp workspace file. [UnsupportedOSPlatform("windows")] public sealed class WorkspaceBuilder diff --git a/src/LibTmux.Workspace/WorkspaceResult.cs b/src/LibTmux.Workspace/WorkspaceResult.cs new file mode 100644 index 0000000..ddfc5d3 --- /dev/null +++ b/src/LibTmux.Workspace/WorkspaceResult.cs @@ -0,0 +1,97 @@ +using System.Collections.ObjectModel; + +namespace LibTmux.Workspace; + +/// Describes a built workspace and any layout tmux rejected. +public sealed record WorkspaceResult +{ + private Session _session = null!; + private ReadOnlyCollection _windows = null!; + private ReadOnlyCollection _unsupported = null!; + + /// Initializes a workspace result. + /// The session that was built. + /// The windows, in the order the file listed them. + /// The layouts tmux rejected after creating their windows. + public WorkspaceResult( + Session Session, + IReadOnlyList Windows, + IReadOnlyList Unsupported) + { + this.Session = Session; + this.Windows = Windows; + this.Unsupported = Unsupported; + } + + /// Gets the session that was built. + public Session Session + { + get => _session; + init + { + ArgumentNullException.ThrowIfNull(value); + _session = value; + } + } + + /// Gets the windows, in the order the file listed them. + public IReadOnlyList Windows + { + get => _windows; + init + { + ArgumentNullException.ThrowIfNull(value); + _windows = WorkspaceCollections.Copy(value, nameof(Windows)); + } + } + + /// Gets the layouts tmux rejected after creating their windows. + public IReadOnlyList Unsupported + { + get => _unsupported; + init + { + ArgumentNullException.ThrowIfNull(value); + _unsupported = WorkspaceCollections.Copy(value, nameof(Unsupported)); + } + } + + /// + public bool Equals(WorkspaceResult? other) => + other is not null + && EqualityComparer.Default.Equals(Session, other.Session) + && Windows.SequenceEqual(other.Windows) + && Unsupported.SequenceEqual(other.Unsupported, StringComparer.Ordinal); + + /// + public override int GetHashCode() + { + var hash = new HashCode(); + hash.Add(Session); + foreach (Window window in Windows) + { + hash.Add(window); + } + + foreach (string unsupported in Unsupported) + { + hash.Add(unsupported, StringComparer.Ordinal); + } + + return hash.ToHashCode(); + } + + /// Deconstructs the result into the built session, windows, and rejected layouts. + /// The session that was built. + /// The windows, in workspace order. + /// The layouts tmux rejected. + public void Deconstruct( + out Session Session, + out IReadOnlyList Windows, + out IReadOnlyList Unsupported) + { + Session = this.Session; + Windows = this.Windows; + Unsupported = this.Unsupported; + } +} diff --git a/tests/LibTmux.IntegrationTests/Workspace/WorkspaceResultTests.cs b/tests/LibTmux.IntegrationTests/Workspace/WorkspaceResultTests.cs new file mode 100644 index 0000000..77baa70 --- /dev/null +++ b/tests/LibTmux.IntegrationTests/Workspace/WorkspaceResultTests.cs @@ -0,0 +1,46 @@ +using LibTmux.Internal; +using LibTmux.Workspace; + +namespace LibTmux.IntegrationTests; + +public sealed class WorkspaceResultTests +{ + [Fact] + public void Collection_initializers_snapshot_their_inputs() + { + (Session session, Window window) = Entities(); + var windows = new List { window }; + var unsupported = new List { "layout" }; + var result = new WorkspaceResult(session, windows, unsupported); + + windows.Clear(); + unsupported.Clear(); + var replacement = new List { "replacement" }; + WorkspaceResult changed = result with { Unsupported = replacement }; + replacement.Clear(); + + Assert.Equal([window], result.Windows); + Assert.Equal(["layout"], result.Unsupported); + Assert.Equal(["replacement"], changed.Unsupported); + } + + [Fact] + public void Equality_uses_collection_contents() + { + (Session session, Window window) = Entities(); + var left = new WorkspaceResult(session, [window], ["layout"]); + var equal = new WorkspaceResult(session, [window], ["layout"]); + var different = new WorkspaceResult(session, [window], ["other"]); + + Assert.Equal(left, equal); + Assert.Equal(left.GetHashCode(), equal.GetHashCode()); + Assert.NotEqual(left, different); + } + + private static (Session Session, Window Window) Entities() + { + var dispatcher = new TmuxCommandDispatcher( + static (_, _) => throw new InvalidOperationException("No command expected.")); + return (new Session(dispatcher, "$1"), new Window(dispatcher, "@1")); + } +} From 7e166670ae7ab4f2291c0bf80e9c9fca60193e42 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 08:00:47 -0500 Subject: [PATCH 043/129] Query(refactor[execution]): Keep typed matching local why: Native tmux filters use an executable format language, while automatic pushdown would add a large safety and compatibility subsystem to save only local pipe traffic. what: - record local snapshot evaluation as the production query boundary - correct README and XML claims about typed query execution - rename planner-shaped files around the actual unsafe native-filter behavior --- README.md | 14 +-- docs/decisions/0003-query-bakeoff.md | 110 ++++++------------ eng/parity/tests/test_production_plan.py | 4 +- eng/parity/verify_production_plan.py | 4 +- src/LibTmux.Query.Json/README.md | 5 +- ...{QueryPlanner.cs => NativeFilterSearch.cs} | 0 src/LibTmux/Query/QueryExtensions.cs | 6 +- src/LibTmux/Query/QueryTranslator.cs | 3 +- src/LibTmux/README.md | 10 +- ...ingTests.cs => NativeFilterSearchTests.cs} | 8 +- 10 files changed, 64 insertions(+), 100 deletions(-) rename src/LibTmux/Query/{QueryPlanner.cs => NativeFilterSearch.cs} (100%) rename tests/LibTmux.IntegrationTests/Query/{QueryPlanningTests.cs => NativeFilterSearchTests.cs} (93%) diff --git a/README.md b/README.md index c881bd0..b3bf657 100644 --- a/README.md +++ b/README.md @@ -184,13 +184,13 @@ QueryDocument document = QueryExtensions.Translate( Console.WriteLine(document.Target); // Session ``` -You write C# and tmux receives tmux: `Session.Name` goes on the wire as -`session_name`, and `Client.IsControlClient` as `client_control`. The catalog -carries that pair for all twelve queryable fields, and it is closed — a field -outside it throws `UnsupportedQueryExpressionException` rather than falling -back, so an expression that translates is one tmux can answer. -[LibTmux.Query.Json](src/LibTmux.Query.Json/README.md) puts the document on the -wire. +The document uses stable wire names: `Session.Name` becomes `session_name`, +and `Client.IsControlClient` becomes `client_control`. The catalog carries that +pair for all twelve queryable fields and rejects anything outside it. Typed +queries evaluate locally over captured objects; they are never assembled into +tmux's executable format language. [LibTmux.Query.Json](src/LibTmux.Query.Json/README.md) +puts the document on an application-controlled wire. `UnsafeTmuxFilter` is the +separate opt-in for callers that deliberately want native tmux `-f` behavior. ## Options and hooks diff --git a/docs/decisions/0003-query-bakeoff.md b/docs/decisions/0003-query-bakeoff.md index 3ecfb3a..66c53af 100644 --- a/docs/decisions/0003-query-bakeoff.md +++ b/docs/decisions/0003-query-bakeoff.md @@ -2,7 +2,19 @@ ## Status -Accepted for the first production query surface. +Accepted for the local query document surface. Automatic native pushdown is +rejected for production. + +The retained bakeoff measured pushdown, but production does not assemble typed +documents into tmux formats. A native `-f` expression is executable tmux +format-language input, including shell-job forms. Avoiding a few kilobytes on a +local pipe does not justify adding an escaping, version-profile, command-budget, +residual-evaluation, and relation-capture subsystem to that boundary. Typed +documents therefore evaluate only over captured objects. `UnsafeTmuxFilter` +remains the explicit native escape hatch and makes no equivalence guarantee. + +The remote-planning results and graft list below are historical bakeoff evidence, +not unimplemented production requirements. ## Context @@ -40,14 +52,12 @@ not metadata-free execution. ## Decision Use a source-generated closed field catalog with one immutable canonical query -AST shared by expression translation, direct local interpretation, JSON, and -remote planning. The production generator and emitted catalog are internal -implementation details; public API does not expose contender, generator, or -planner-infrastructure vocabulary. +AST shared by expression translation, direct local interpretation, and JSON. +The production generator and emitted catalog are internal implementation +details; public API does not expose contender or generator vocabulary. -Public query entry points do not require a catalog or capability object. Explain -results are immutable read-only data. Version parsing, physical mappings, -planner profiles, row framing, and raw argv construction remain internal. +Public query entry points do not require a catalog or capability object. Query +translation and interpretation are independent of the connected tmux version. Public query documents have structural value equality and hashing across sequence-bearing nodes. Equality does not depend on `ImmutableArray` backing @@ -74,38 +84,15 @@ require `CultureInvariant`, reject unsupported option bits and inline culture-dependent case behavior, count pattern limits by Unicode scalar, and execute with a timeout. -Remote planning validates the complete document, target command, physical -fields, tmux version, format recursion, and exact packed command size before -process start. Disabled mode leaves the complete predicate local. Automatic -mode pushes only safe materializable work and keeps the rest residual. Required -mode rejects any residual. Only conjunctions split; emitted conjunction filters -are balanced. If the combined filter cannot fit the tmux format or command -protocol, Automatic keeps the entire predicate residual and Required fails -before dispatch. - -Boolean fields use zero/nonzero tmux truth. Only typed-ID equality and -inequality are initially eligible scalar comparisons. Regex, relations, -ordinal-ignore-case strings, other string operations, and numeric or instant -comparisons remain residual because their tmux representation or semantics are -not proven equivalent. - -The production query executor owns plan execution and always applies the -residual predicate after materializing pushdown candidates. It derives the -required relation depth from the residual AST and either captures that depth or -fails before evaluation. Safe plan filter strings remain internal. Native tmux -filters are exposed only through a separately named unsafe operation that makes -no semantic-equivalence guarantee. - -The measured `list-*` projections prove tmux filter behavior, not a production -row-framing or acquisition-policy design. Production candidate materialization -uses ADR 0001 byte-length framing and selects the list command from the owning -Server, Session, or Window acquisition context. It does not parse -delimiter-joined rows or replace ADR 0002's command-specific list-error -policies with one global command per query target. - -Physical fields, target commands, and protocol limits are internal immutable -data selected from the exact tmux profile. The production API does not accept -caller-defined mappings that can change remote query semantics. +Typed documents are not lowered into native tmux filters. Callers capture or +list entities, then use `Matching()`; relation predicates declare the snapshot +depth they need and fail on uncaptured relations. Raw native filters remain a +separate `UnsafeTmuxFilter` operation with no typed-query equivalence claim. + +The measured `list-*` projections remain historical evidence about tmux filter +behavior. Ordinary production listings continue to use ADR 0001 framing and +ADR 0002 acquisition and error policies; typed queries add no second listing +path. ## Matrix observations @@ -160,40 +147,19 @@ not thresholds. | Static | 43,072 | 52,880 | | Generated | 48,760 | 52,832 | -## Production grafts +## Production consequences -The production implementation must add the following behavior without copying -a contender wholesale: +The production implementation retains the parts that serve local portable +queries: - An internal generator and generated catalog over the approved production snapshots, with the exact closed manifest retained as a compile-time test. -- Catalog-free public query entry points and immutable read-only explain - results. - One public immutable query-document contract shared with the optional JSON package, without public contender or source-generator vocabulary. - Structural equality and hashing for the full public AST, including sequence-bearing Boolean nodes. -- An internal executor that owns candidate materialization and always applies - the residual predicate before returning results. -- Residual-plan relation-depth requirements that drive capture or fail before - local evaluation. -- A separately named unsafe native-filter operation with no typed-query - equivalence claim. -- ADR 0001 byte-length row framing rather than delimiter parsing for query - candidate materialization. -- Acquisition-scoped Server, Session, and Window list commands that preserve - ADR 0002's list-error policies, plus exact physical-field profiles for each - supported tmux version. -- Server-wide filter capability profiles, including Client filter support - beginning at 3.4. -- Internal source-bound physical mappings and protocol profiles with no public - widening or remapping constructor. -- An internal parsed-version capability service rather than caller-authored - version strings or source-label aliases. -- Balanced filter composition and fail-before-dispatch format-depth and packed - argv checks using immutable protocol limits. -- The direct interpreter as the semantic owner, with any dynamic-code fast path - differential-tested and optional. +- The direct interpreter as semantic owner, with one-time binding and no + compilation of the caller's original expression. - ADR 0002 snapshot-depth and captured-relation behavior for relation quantifiers, including incomplete-snapshot errors before enumeration. - Python parity dispositions for the complete QueryList inventory while keeping @@ -222,6 +188,7 @@ a contender wholesale: - A public plan shape that lets callers omit residual evaluation. - Residual relation evaluation without a declared snapshot-depth requirement. - Safe typed queries and raw native filters sharing one execution surface. +- Automatic lowering of typed documents into tmux's executable format language. - Delimiter-joined tmux rows as a production materialization protocol. - One global list command per target that erases acquisition scope and list- error policy. @@ -234,16 +201,11 @@ a contender wholesale: ## Remaining unknowns -- The exact query capability profile for the current tmux development branch. -- macOS behavior for NativeAOT publication and real-tmux pushdown. +- macOS behavior for NativeAOT publication. - Analyzer NuGet layout, transitivity, compiler compatibility, and package validation for the production generator. - Shipping trimming, Public API, and platform-annotation results. -- Production query execution over the complete approved hierarchy and snapshot - materializer rather than bakeoff snapshots. -- Scoped Session and Window query execution through the production acquisition - and list-error policy adapters. -- Allocation and candidate-materialization behavior on large real topologies. +- Allocation and local-matching behavior on large captured topologies. - Final public names and exhaustive Python inventory dispositions, which belong to the public-API approval decision. @@ -255,7 +217,7 @@ not claim the complete tmux command-flag or format-field and operator surface. Framework-design, Python-parity, and tmux-protocol reviews are recorded in `evidence/0003/critic-reviews.md`. Every accepted finding is represented by a -causal fix, a bounded measured claim, or a required production graft. No review +causal fix, a bounded measured claim, or a production consequence. No review blocks the generated closed-catalog decision. ## Study-source removal proof diff --git a/eng/parity/tests/test_production_plan.py b/eng/parity/tests/test_production_plan.py index a612ae5..236eb37 100644 --- a/eng/parity/tests/test_production_plan.py +++ b/eng/parity/tests/test_production_plan.py @@ -145,13 +145,13 @@ "src/LibTmux/Query/QueryNode.cs", "src/LibTmux/Query/QueryTranslator.cs", "src/LibTmux/Query/QueryInterpreter.cs", - "src/LibTmux/Query/QueryPlanner.cs", + "src/LibTmux/Query/NativeFilterSearch.cs", "src/LibTmux/Query/QueryExtensions.cs", "src/LibTmux/Query/NameContainsLookupParser.cs", "src/LibTmux.Generators/LibTmux.Generators.csproj", "src/LibTmux.Generators/FieldCatalogGenerator.cs", "tests/LibTmux.UnitTests/Query/QuerySemanticsTests.cs", - "tests/LibTmux.IntegrationTests/Query/QueryPlanningTests.cs", + "tests/LibTmux.IntegrationTests/Query/NativeFilterSearchTests.cs", "tests/LibTmux.IntegrationTests/Parity/Component08ParityTests.cs", "src/LibTmux/Exceptions/UnsupportedQueryExpressionException.cs", "src/LibTmux.Generators/packages.lock.json", diff --git a/eng/parity/verify_production_plan.py b/eng/parity/verify_production_plan.py index 2215475..fb6dbf3 100644 --- a/eng/parity/verify_production_plan.py +++ b/eng/parity/verify_production_plan.py @@ -158,13 +158,13 @@ "src/LibTmux/Query/QueryNode.cs", "src/LibTmux/Query/QueryTranslator.cs", "src/LibTmux/Query/QueryInterpreter.cs", - "src/LibTmux/Query/QueryPlanner.cs", + "src/LibTmux/Query/NativeFilterSearch.cs", "src/LibTmux/Query/QueryExtensions.cs", "src/LibTmux/Query/NameContainsLookupParser.cs", "src/LibTmux.Generators/LibTmux.Generators.csproj", "src/LibTmux.Generators/FieldCatalogGenerator.cs", "tests/LibTmux.UnitTests/Query/QuerySemanticsTests.cs", - "tests/LibTmux.IntegrationTests/Query/QueryPlanningTests.cs", + "tests/LibTmux.IntegrationTests/Query/NativeFilterSearchTests.cs", "tests/LibTmux.IntegrationTests/Parity/Component08ParityTests.cs", "src/LibTmux/Exceptions/UnsupportedQueryExpressionException.cs", "src/LibTmux.Generators/packages.lock.json", diff --git a/src/LibTmux.Query.Json/README.md b/src/LibTmux.Query.Json/README.md index b737b7a..d3f216c 100644 --- a/src/LibTmux.Query.Json/README.md +++ b/src/LibTmux.Query.Json/README.md @@ -108,8 +108,9 @@ You write these as the properties they are — `Session.Name`, `Client.IsControlClient` — and the wire carries the tmux spelling. A field outside it throws `UnsupportedQueryExpressionException` at translation -rather than falling back to filtering in memory, so a document that exists is -one tmux can answer. +rather than falling back. The document is interpreted locally or by an +application that deliberately accepts this wire contract; LibTmux does not +turn it into a native tmux filter. ## Related packages diff --git a/src/LibTmux/Query/QueryPlanner.cs b/src/LibTmux/Query/NativeFilterSearch.cs similarity index 100% rename from src/LibTmux/Query/QueryPlanner.cs rename to src/LibTmux/Query/NativeFilterSearch.cs diff --git a/src/LibTmux/Query/QueryExtensions.cs b/src/LibTmux/Query/QueryExtensions.cs index 1e2a1ad..2e376de 100644 --- a/src/LibTmux/Query/QueryExtensions.cs +++ b/src/LibTmux/Query/QueryExtensions.cs @@ -5,9 +5,9 @@ namespace LibTmux.Query; /// Translates, compiles, and applies declarative query predicates. /// -/// One expression surface serves both sides: the same predicate translates to -/// the wire document and compiles to an in-memory delegate, so a filter cannot -/// mean one thing locally and another on the wire. +/// The same predicate translates to the portable document and compiles to an +/// in-memory delegate, so its stored form and local interpretation share one +/// meaning. /// public static class QueryExtensions { diff --git a/src/LibTmux/Query/QueryTranslator.cs b/src/LibTmux/Query/QueryTranslator.cs index aa496d2..c8dc98d 100644 --- a/src/LibTmux/Query/QueryTranslator.cs +++ b/src/LibTmux/Query/QueryTranslator.cs @@ -238,8 +238,7 @@ member.DeclaringType is { } owner && QueryFieldCatalog.TryGetWireName(owner, member.Name, out string mapped) ? mapped : ToWireName(member.Name); - // The catalog is closed: a field it does not carry cannot be put on the - // wire, so translating it would produce a document tmux cannot answer. + // The catalog is closed: a field it does not carry has no wire form. if (!QueryFieldCatalog.TryGetTarget(wireName, out QueryTarget target)) { throw new UnsupportedQueryExpressionException( diff --git a/src/LibTmux/README.md b/src/LibTmux/README.md index f0f35bf..e1874ca 100644 --- a/src/LibTmux/README.md +++ b/src/LibTmux/README.md @@ -216,9 +216,9 @@ QueryDocument document = QueryExtensions.Translate( && session.Attached); ``` -You write C# and tmux receives tmux. The catalog carries the pair for all -twelve queryable fields — `Session.Name` is `session_name`, -`Client.IsControlClient` is `client_control` — and it is closed: +The document carries stable wire names: `Session.Name` is `session_name` and +`Client.IsControlClient` is `client_control`. The catalog is closed over twelve +queryable fields: | Session | Window | Pane | Client | |---|---|---|---| @@ -233,7 +233,9 @@ internal sealed record PaneRow(string PaneId, string PaneCommand); ``` A field outside the catalog throws `UnsupportedQueryExpressionException` rather -than falling back, so an expression that translates is one tmux can answer. +than falling back. Typed queries evaluate locally over captured objects and are +never assembled into tmux's executable format language. `UnsafeTmuxFilter` is +the separate opt-in for native tmux `-f` behavior. Put it on the wire with [LibTmux.Query.Json](https://www.nuget.org/packages/LibTmux.Query.Json). diff --git a/tests/LibTmux.IntegrationTests/Query/QueryPlanningTests.cs b/tests/LibTmux.IntegrationTests/Query/NativeFilterSearchTests.cs similarity index 93% rename from tests/LibTmux.IntegrationTests/Query/QueryPlanningTests.cs rename to tests/LibTmux.IntegrationTests/Query/NativeFilterSearchTests.cs index 1e94ace..7aecea4 100644 --- a/tests/LibTmux.IntegrationTests/Query/QueryPlanningTests.cs +++ b/tests/LibTmux.IntegrationTests/Query/NativeFilterSearchTests.cs @@ -5,13 +5,13 @@ namespace LibTmux.IntegrationTests.Query; [UnsupportedOSPlatform("windows")] -public sealed class QueryPlanningTests +public sealed class NativeFilterSearchTests { [Fact( Skip = "Requires a Unix process environment.", SkipType = typeof(UnixTestEnvironment), SkipUnless = nameof(UnixTestEnvironment.IsUnix))] - public async Task A_tmux_side_filter_and_a_local_predicate_agree() + public async Task A_native_filter_can_match_a_local_predicate() { await using RawTmuxTestContext raw = await RawTmuxTestContext.StartAsync( TestContext.Current.CancellationToken); @@ -27,8 +27,8 @@ public async Task A_tmux_side_filter_and_a_local_predicate_agree() .Where(session => session.Snapshot?["session_name"]?.StartsWith("dev", StringComparison.Ordinal) == true) .ToList(); - // The same question answered on either side of the wire must give the - // same objects; that equivalence is what makes pushdown safe. + // This one supported expression agrees; raw filters make no general + // equivalence guarantee with the typed query vocabulary. Assert.Single(pushedDown); Assert.Single(local); Assert.Equal(local[0].Id, pushedDown[0].Id); From 5a4cb3ad3d7ee1cd6b70ff37d28c425179793dc5 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 08:10:07 -0500 Subject: [PATCH 044/129] Packaging(test[aot]): Prove packed artifacts why: The NativeAOT smoke followed project references, so it could pass while the published packages failed for a downstream AOT consumer. what: - consume freshly packed packages through an isolated mapped source - keep runtime restore state in the standalone smoke project - remove redundant runtime and lock graphs from portable libraries --- .github/CONTRIBUTING.md | 46 ++++--- .github/workflows/dotnet.yml | 16 ++- LibTmux.slnx | 1 - eng/parity/tests/test_workflows.py | 8 +- eng/parity/verify_workflows.py | 2 + .../LibTmux.Generators.csproj | 5 - src/LibTmux.Generators/packages.lock.json | 5 +- .../LibTmux.Query.Json.csproj | 3 - src/LibTmux.Query.Json/packages.lock.json | 6 +- .../packages.packed.lock.json | 79 ------------ src/LibTmux/LibTmux.csproj | 6 - src/LibTmux/packages.lock.json | 6 +- .../LibTmux.AotSmoke/LibTmux.AotSmoke.csproj | 13 +- tests/LibTmux.AotSmoke/packages.lock.json | 117 ------------------ .../LibTmux.PackageConsumer.csproj | 9 +- .../Packaging/WorkflowContractTests.cs | 42 ++++++- .../NuGet.config | 2 +- 17 files changed, 108 insertions(+), 258 deletions(-) delete mode 100644 src/LibTmux.Query.Json/packages.packed.lock.json delete mode 100644 tests/LibTmux.AotSmoke/packages.lock.json rename tests/{LibTmux.PackageConsumer => }/NuGet.config (91%) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 8643668..94d3df4 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -141,6 +141,12 @@ $ mise exec -- dotnet pack \ $ uv run python eng/parity/inspect_packages.py ``` +```console +$ mise exec -- dotnet restore \ + tests/LibTmux.PackageConsumer/LibTmux.PackageConsumer.csproj \ + --configfile tests/NuGet.config +``` + ```console $ mise exec -- dotnet run \ --project tests/LibTmux.PackageConsumer/LibTmux.PackageConsumer.csproj \ @@ -149,17 +155,25 @@ $ mise exec -- dotnet run \ --no-restore ``` +```console +$ mise exec -- dotnet restore \ + tests/LibTmux.AotSmoke/LibTmux.AotSmoke.csproj \ + --runtime linux-x64 \ + --configfile tests/NuGet.config +``` + ```console $ mise exec -- dotnet publish \ tests/LibTmux.AotSmoke/LibTmux.AotSmoke.csproj \ --configuration Release \ --framework net10.0 \ - --runtime linux-x64 + --runtime linux-x64 \ + --no-restore ``` -`LibTmux.PackageConsumer` is deliberately absent from `LibTmux.slnx`. It exists -to prove the packaged artifact rather than a project reference, so it restores -and runs standalone. +`LibTmux.PackageConsumer` and `LibTmux.AotSmoke` are deliberately absent from +`LibTmux.slnx`. Both restore the packed artifacts rather than project +references, so they run only after `dotnet pack`. ### Validators that read documents, not the build @@ -208,22 +222,18 @@ The engineering scripts have tests of their own: $ uv run --with pytest --with tomlkit python -m pytest eng --quiet ``` -### NU1004, which is not a dependency problem - -Publishing ahead of time names a runtime identifier, and restore then writes -one into the lock file of every project in that graph — including the -library's, where the section is empty because no package resolves differently. -That is why `src/LibTmux` and `src/LibTmux.Generators` declare -`RuntimeIdentifiers`: without it the lock files disagree with the projects and -the *next* `restore --locked-mode` fails with NU1004, which reads like a -dependency problem and is not one. +### AOT restore ownership -Adding a platform to the matrix means adding its identifier there and -regenerating: +Only `LibTmux.AotSmoke` names a runtime identifier. The libraries and their +lock files stay portable because the smoke project consumes their packages +instead of adding its runtime to their project graph. The smoke project has no +checked-in lock: its package inputs keep the development version while their +bytes change with each commit. CI combines a clean package cache with +`tests/NuGet.config` source mapping so it cannot substitute a stale or public +package. -```console -$ mise exec -- dotnet restore LibTmux.slnx --force-evaluate -``` +Adding a platform means adding its identifier to `LibTmux.AotSmoke` and adding +the matching standalone restore and publish to the workflow. ### The other workflows diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index f356c89..5e09b0d 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -87,7 +87,8 @@ jobs: env: NUGET_PACKAGES: ${{ runner.temp }}/libtmux-package-consumer run: | - dotnet restore tests/LibTmux.PackageConsumer/LibTmux.PackageConsumer.csproj + dotnet restore tests/LibTmux.PackageConsumer/LibTmux.PackageConsumer.csproj \ + --configfile tests/NuGet.config for framework in net8.0 net10.0; do dotnet run \ --project tests/LibTmux.PackageConsumer/LibTmux.PackageConsumer.csproj \ @@ -98,16 +99,21 @@ jobs: - name: Ahead-of-time smoke test # Trim and ahead-of-time warnings are only complete once something is - # published that way and run. This publishes where the closure tests - # below look for it, which is the default location rather than one - # this job picked. + # published that way and run. The isolated cache and mapped source make + # this a test of the packages above rather than the project graph. + env: + NUGET_PACKAGES: ${{ runner.temp }}/libtmux-aot-smoke run: | sudo apt-get install --yes clang zlib1g-dev + dotnet restore tests/LibTmux.AotSmoke/LibTmux.AotSmoke.csproj \ + --runtime linux-x64 \ + --configfile tests/NuGet.config for framework in net8.0 net10.0; do dotnet publish tests/LibTmux.AotSmoke/LibTmux.AotSmoke.csproj \ --configuration Release \ --framework "${framework}" \ - --runtime linux-x64 + --runtime linux-x64 \ + --no-restore "tests/LibTmux.AotSmoke/bin/Release/${framework}/linux-x64/native/LibTmux.AotSmoke" done diff --git a/LibTmux.slnx b/LibTmux.slnx index 81af89c..c4f983f 100644 --- a/LibTmux.slnx +++ b/LibTmux.slnx @@ -11,7 +11,6 @@ - diff --git a/eng/parity/tests/test_workflows.py b/eng/parity/tests/test_workflows.py index ef52212..dbc4045 100644 --- a/eng/parity/tests/test_workflows.py +++ b/eng/parity/tests/test_workflows.py @@ -38,7 +38,11 @@ def verify(root: pathlib.Path) -> list[str]: - run: dotnet format --verify-no-changes - run: dotnet build --warnaserror - run: dotnet pack src/LibTmux/LibTmux.csproj - - run: dotnet publish tests/LibTmux.AotSmoke/LibTmux.AotSmoke.csproj + - env: + NUGET_PACKAGES: ${{ runner.temp }}/libtmux-aot-smoke + run: | + dotnet restore tests/LibTmux.AotSmoke/LibTmux.AotSmoke.csproj --configfile tests/NuGet.config + dotnet publish tests/LibTmux.AotSmoke/LibTmux.AotSmoke.csproj --no-restore - env: NUGET_PACKAGES: ${{ runner.temp }}/libtmux-package-consumer run: dotnet run --project tests/LibTmux.PackageConsumer @@ -155,6 +159,8 @@ def test_skipped_integration_tests_are_reported(tmp_path: pathlib.Path) -> None: "--locked-mode", "--warnaserror", "dotnet pack", + "NUGET_PACKAGES: ${{ runner.temp }}/libtmux-aot-smoke", + "--configfile tests/NuGet.config", "LibTmux.PackageConsumer", "LibTmux.ExampleTests", "render_api_reference.py --check", diff --git a/eng/parity/verify_workflows.py b/eng/parity/verify_workflows.py index e0e3340..490f339 100644 --- a/eng/parity/verify_workflows.py +++ b/eng/parity/verify_workflows.py @@ -23,6 +23,8 @@ "--warnaserror", "dotnet pack", "LibTmux.AotSmoke", + "NUGET_PACKAGES: ${{ runner.temp }}/libtmux-aot-smoke", + "--configfile tests/NuGet.config", "LibTmux.PackageConsumer", "NUGET_PACKAGES: ${{ runner.temp }}/libtmux-package-consumer", "LibTmux.Examples", diff --git a/src/LibTmux.Generators/LibTmux.Generators.csproj b/src/LibTmux.Generators/LibTmux.Generators.csproj index 3a16c24..c6ce1a0 100644 --- a/src/LibTmux.Generators/LibTmux.Generators.csproj +++ b/src/LibTmux.Generators/LibTmux.Generators.csproj @@ -5,11 +5,6 @@ LibTmux.Generators false - - linux-x64 true false - linux-x64 - LibTmux.Query.Json System.Text.Json support for LibTmux query documents. The core library does not reference it, so a caller who does not want a JSON dependency does not get one. tmux;json;query;serialization diff --git a/src/LibTmux.Query.Json/packages.lock.json b/src/LibTmux.Query.Json/packages.lock.json index 40daea9..237dfa8 100644 --- a/src/LibTmux.Query.Json/packages.lock.json +++ b/src/LibTmux.Query.Json/packages.lock.json @@ -35,7 +35,6 @@ } } }, - "net10.0/linux-x64": {}, "net8.0": { "Microsoft.CodeAnalysis.PublicApiAnalyzers": { "type": "Direct", @@ -69,7 +68,6 @@ "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0" } } - }, - "net8.0/linux-x64": {} + } } -} \ No newline at end of file +} diff --git a/src/LibTmux.Query.Json/packages.packed.lock.json b/src/LibTmux.Query.Json/packages.packed.lock.json deleted file mode 100644 index f5134d5..0000000 --- a/src/LibTmux.Query.Json/packages.packed.lock.json +++ /dev/null @@ -1,79 +0,0 @@ -{ - "version": 2, - "dependencies": { - "net10.0": { - "Microsoft.CodeAnalysis.PublicApiAnalyzers": { - "type": "Direct", - "requested": "[5.6.0, )", - "resolved": "5.6.0", - "contentHash": "W4kJGezNIKLzo0Ak5FAQDFvkMf2U7DtGL4THmHyRSApfKsKt5V+eX/bU0ZLKAt/uf9Bb2o1bi0YDKj/GRB/vYQ==" - }, - "Microsoft.NET.ILLink.Tasks": { - "type": "Direct", - "requested": "[10.0.10, )", - "resolved": "10.0.10", - "contentHash": "f5VCIE7AJpd5YvzNTeMGVzQIgyE9tX+AreTYwQF+REbu+DZo/2Ae+jNSwhPEYrVz6RRkd7y8ubXjk6Nn6Ka+Cg==" - }, - "Microsoft.Extensions.DependencyInjection.Abstractions": { - "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "z/2xXlFw2aLGjHyEm6E0tQ+In6VfzQzTrtArbQ2c0TQE16ZbyDCMGPvaUT9I0s8rgy9sRWlU2P9waW37qV04qA==" - }, - "libtmux": { - "type": "Project", - "dependencies": { - "Microsoft.Extensions.Logging.Abstractions": "[10.0.10, )" - } - }, - "Microsoft.Extensions.Logging.Abstractions": { - "type": "CentralTransitive", - "requested": "[10.0.10, )", - "resolved": "10.0.10", - "contentHash": "zkFxGYUvdxAvIKTyXHrmW+Sux53D4SezD9dMyZ6hrwwzPQJNuwCRy1f5W7AvYTqacEGhWF2XderRQG1OvbV8og==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10" - } - } - }, - "net8.0": { - "Microsoft.CodeAnalysis.PublicApiAnalyzers": { - "type": "Direct", - "requested": "[5.6.0, )", - "resolved": "5.6.0", - "contentHash": "W4kJGezNIKLzo0Ak5FAQDFvkMf2U7DtGL4THmHyRSApfKsKt5V+eX/bU0ZLKAt/uf9Bb2o1bi0YDKj/GRB/vYQ==" - }, - "Microsoft.NET.ILLink.Tasks": { - "type": "Direct", - "requested": "[8.0.29, )", - "resolved": "8.0.29", - "contentHash": "HSBTfrkIZijz8z3ybLRKB7E8rHk4QQufFwpHa9fc5CMIgRhRzdn4mBGmlyXZqaueiMPtuJcnjresGvSTfaW8Mg==" - }, - "Microsoft.Extensions.DependencyInjection.Abstractions": { - "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "z/2xXlFw2aLGjHyEm6E0tQ+In6VfzQzTrtArbQ2c0TQE16ZbyDCMGPvaUT9I0s8rgy9sRWlU2P9waW37qV04qA==" - }, - "System.Diagnostics.DiagnosticSource": { - "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "cjtKi6ERMYWp6b9UTVPcwDT29PjKDtlM3W9OwnWL5abRsI8ku42Q2wqZoLIIXJnT/XF2s2CjuK8Nl4a3mmTxQQ==" - }, - "libtmux": { - "type": "Project", - "dependencies": { - "Microsoft.Extensions.Logging.Abstractions": "[10.0.10, )" - } - }, - "Microsoft.Extensions.Logging.Abstractions": { - "type": "CentralTransitive", - "requested": "[10.0.10, )", - "resolved": "10.0.10", - "contentHash": "zkFxGYUvdxAvIKTyXHrmW+Sux53D4SezD9dMyZ6hrwwzPQJNuwCRy1f5W7AvYTqacEGhWF2XderRQG1OvbV8og==", - "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", - "System.Diagnostics.DiagnosticSource": "10.0.10" - } - } - } - } -} \ No newline at end of file diff --git a/src/LibTmux/LibTmux.csproj b/src/LibTmux/LibTmux.csproj index a487c2e..8d75947 100644 --- a/src/LibTmux/LibTmux.csproj +++ b/src/LibTmux/LibTmux.csproj @@ -6,12 +6,6 @@ true true - - linux-x64 - + false + false + false + $(VersionPrefix) + $(VersionPrefix)-$(VersionSuffix) + true + linux-x64 true false - - + + false - + false false diff --git a/tests/LibTmux.UnitTests/Packaging/WorkflowContractTests.cs b/tests/LibTmux.UnitTests/Packaging/WorkflowContractTests.cs index 6f76d52..d0b6b43 100644 --- a/tests/LibTmux.UnitTests/Packaging/WorkflowContractTests.cs +++ b/tests/LibTmux.UnitTests/Packaging/WorkflowContractTests.cs @@ -76,6 +76,39 @@ public void The_build_workflow_proves_the_package_before_publishing_it() Assert.Contains("fetch-depth: 0", workflow, StringComparison.Ordinal); } + [Fact] + public void The_aot_smoke_consumes_the_packages_it_proves() + { + string project = ReadRepositoryFile( + "tests", + "LibTmux.AotSmoke", + "LibTmux.AotSmoke.csproj"); + string solution = ReadRepositoryFile("LibTmux.slnx"); + string workflow = ReadWorkflow("dotnet.yml"); + + Assert.Contains(" + ReadRepositoryFile(".github", "workflows", name); + + private static string ReadRepositoryFile(params string[] path) { DirectoryInfo? directory = new(AppContext.BaseDirectory); while (directory is not null) { - string candidate = Path.Combine(directory.FullName, ".github", "workflows", name); + string candidate = Path.Combine([directory.FullName, .. path]); if (File.Exists(candidate)) { return File.ReadAllText(candidate); @@ -125,6 +161,6 @@ private static string ReadWorkflow(string name) directory = directory.Parent; } - throw new FileNotFoundException($"The workflow '{name}' was not found."); + throw new FileNotFoundException($"The repository file '{string.Join('/', path)}' was not found."); } } diff --git a/tests/LibTmux.PackageConsumer/NuGet.config b/tests/NuGet.config similarity index 91% rename from tests/LibTmux.PackageConsumer/NuGet.config rename to tests/NuGet.config index 94261a3..78fd8e1 100644 --- a/tests/LibTmux.PackageConsumer/NuGet.config +++ b/tests/NuGet.config @@ -4,7 +4,7 @@ public feed they use in a downstream project. --> - + From a0c8bd9987665432a1c23fbe9c0d3ab61a2c4b5d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 10:39:22 -0500 Subject: [PATCH 045/129] Engineering(refactor[plan]): Retire closed scaffold why: The production plan is absent and no live gate invokes its 7,000-line phase validator, while one test still imported a helper duplicated by the maintained ledger validator. what: - remove the completed plan validator and synthetic contract suite - route approval snapshots through the live ledger validator - update contributor and decision records to describe durable gates --- .github/CONTRIBUTING.md | 10 +- docs/decisions/0004-public-api-approval.md | 8 +- eng/parity/tests/test_production_plan.py | 3717 -------------------- eng/parity/tests/test_public_api.py | 8 +- eng/parity/verify_production_plan.py | 3260 ----------------- 5 files changed, 14 insertions(+), 6989 deletions(-) delete mode 100644 eng/parity/tests/test_production_plan.py delete mode 100644 eng/parity/verify_production_plan.py diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 94d3df4..b9820eb 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -307,11 +307,11 @@ differs between 3.2a and 3.7b goes through the capability model, and each difference names the test that proves it in [`docs/parity/version-deltas.json`](../docs/parity/version-deltas.json). -**A public API addition needs five edits, and each will tell you.** The Roslyn -analyzer baseline (`PublicAPI.Unshipped.txt`), the type and its members in -`docs/public-api.json`, its values if it is an enum, and its owning component -in `eng/parity/verify_production_plan.py`. They fail independently and by name; -follow the errors. +**A public API addition changes both enforced contracts.** Update the Roslyn +analyzer baseline (`PublicAPI.Unshipped.txt`) and the type and member records in +`docs/public-api.json`, including explicit enum values. If the addition maps a +Python symbol, update its row in `docs/parity/parity-ledger.json`. The validators +report each missing contract independently. **A documented example is compiled, and a `csharp run` block is executed against a live tmux.** `ReadmeExampleTests` compiles every C# block in the diff --git a/docs/decisions/0004-public-api-approval.md b/docs/decisions/0004-public-api-approval.md index 9e84cd6..4060664 100644 --- a/docs/decisions/0004-public-api-approval.md +++ b/docs/decisions/0004-public-api-approval.md @@ -2,7 +2,9 @@ ## Status -Accepted as the production implementation contract. +Accepted; implementation complete. The production plan and its phase validator +were transient execution scaffolding and were removed after closure. The +canonical API and parity documents retain the durable contract. ADR 0005 supersedes this decision's exact capability-profile selection rule and closed stable-version support boundary. @@ -270,5 +272,5 @@ The public contract is accepted only while all of the following remain true: - public member IDs and overloads are unique; - async, cancellation, ownership, platform, query, ID, and exception rules validate mechanically; and -- the ignored production plan owns every row once and names the full completion - gates. +- at the approval boundary, the production plan owned every row once and named + the full completion gates. diff --git a/eng/parity/tests/test_production_plan.py b/eng/parity/tests/test_production_plan.py deleted file mode 100644 index 236eb37..0000000 --- a/eng/parity/tests/test_production_plan.py +++ /dev/null @@ -1,3717 +0,0 @@ -"""Contract tests for the production implementation plan validator.""" - -# ruff: noqa: E501 - -from __future__ import annotations - -import copy -import os -import pathlib -import runpy -import shlex -import typing as t - -import pytest - -COMPONENT_IDS = tuple(range(1, 19)) -COMPONENT_FILES: dict[int, tuple[str, ...]] = { - 1: ( - "LibTmux.slnx", - "src/LibTmux/LibTmux.csproj", - "src/LibTmux/packages.lock.json", - "tests/LibTmux.UnitTests/LibTmux.UnitTests.csproj", - "tests/LibTmux.UnitTests/packages.lock.json", - "tests/LibTmux.IntegrationTests/LibTmux.IntegrationTests.csproj", - "tests/LibTmux.IntegrationTests/packages.lock.json", - "src/LibTmux/Transport/TmuxCommandRequest.cs", - "src/LibTmux/Transport/TmuxCommandResult.cs", - "src/LibTmux/Transport/TmuxProcessTransport.cs", - "src/LibTmux/Transport/TmuxCommandDispatcher.cs", - "src/LibTmux/Transport/TmuxCommandFailure.cs", - "src/LibTmux/Transport/TmuxTransportLimits.cs", - "src/LibTmux/Transport/Utf8BackslashDecoder.cs", - "src/LibTmux/Server.cs", - "src/LibTmux/Session.cs", - "src/LibTmux/Window.cs", - "src/LibTmux/Pane.cs", - "src/LibTmux/Client.cs", - "src/LibTmux/Server.Command.cs", - "src/LibTmux/Session.Command.cs", - "src/LibTmux/Window.Command.cs", - "src/LibTmux/Pane.Command.cs", - "tests/LibTmux.UnitTests/Entities/EntityShellTests.cs", - "tests/LibTmux.UnitTests/Transport/TmuxProcessTransportTests.cs", - "tests/LibTmux.IntegrationTests/Transport/ProcessTransportTests.cs", - "tests/LibTmux.IntegrationTests/Parity/Component01ParityTests.cs", - "src/LibTmux/Exceptions/LibTmuxException.cs", - "src/LibTmux/Exceptions/TmuxCommandException.cs", - "src/LibTmux/Exceptions/TmuxCommandNotFoundException.cs", - "src/LibTmux/Exceptions/TmuxTransportException.cs", - "src/LibTmux/Exceptions/TmuxOperationCanceledException.cs", - "src/LibTmux/Exceptions/TmuxCleanupException.cs", - "src/LibTmux/Exceptions/TmuxWaitTimeoutException.cs", - "tests/LibTmux.IntegrationTests/Infrastructure/RawTmuxTestContext.cs", - "tests/LibTmux.IntegrationTests/Infrastructure/ControlModeClientScope.cs", - "tests/LibTmux.IntegrationTests/Infrastructure/PtyAttachedClientScope.cs", - "eng/parity/require_red.py", - "eng/parity/tests/test_require_red.py", - "tests/LibTmux.TestChild/LibTmux.TestChild.csproj", - "tests/LibTmux.TestChild/packages.lock.json", - "tests/LibTmux.TestChild/Program.cs", - ), - 2: ( - "src/LibTmux/Connection/TmuxConnection.cs", - "src/LibTmux/Connection/TmuxConnectionOptions.cs", - "src/LibTmux/Connection/ServerGeneration.cs", - "src/LibTmux/Server.Identity.cs", - "src/LibTmux/Session.Identity.cs", - "src/LibTmux/Window.Identity.cs", - "src/LibTmux/Pane.Identity.cs", - "src/LibTmux/Targets/TmuxTarget.cs", - "src/LibTmux/SessionId.cs", - "src/LibTmux/WindowId.cs", - "src/LibTmux/PaneId.cs", - "src/LibTmux/TmuxColorMode.cs", - "tests/LibTmux.UnitTests/Connection/TmuxConnectionTests.cs", - "tests/LibTmux.IntegrationTests/Connection/ServerGenerationTests.cs", - "tests/LibTmux.IntegrationTests/Parity/Component02ParityTests.cs", - "src/LibTmux/Exceptions/StaleServerGenerationException.cs", - "src/LibTmux/Exceptions/TmuxObjectNotFoundException.cs", - ), - 3: ( - "src/LibTmux/Constants/TmuxConstants.cs", - "src/LibTmux/Constants/TmuxEnums.cs", - "src/LibTmux/Formats/TmuxFormats.cs", - "src/LibTmux/Versioning/TmuxVersion.cs", - "src/LibTmux/Server.Version.cs", - "src/LibTmux/Versioning/TmuxCapabilities.cs", - "src/LibTmux/Internal/CommandFlagCatalog.cs", - "src/LibTmux/Internal/FormatCatalog.cs", - "src/LibTmux/Internal/FormatFieldDescriptor.cs", - "tests/LibTmux.UnitTests/Versioning/TmuxCapabilitiesTests.cs", - "tests/LibTmux.IntegrationTests/Versioning/VersionParityTests.cs", - "tests/LibTmux.IntegrationTests/Parity/Component03ParityTests.cs", - "src/LibTmux/Exceptions/TmuxVersionTooLowException.cs", - "docs/parity/version-deltas.json", - ), - 4: ( - "src/LibTmux/Materialization/FormatProjection.cs", - "src/LibTmux/Materialization/SeparatedRowFramer.cs", - "src/LibTmux/Materialization/TmuxMaterializer.cs", - "src/LibTmux/Materialization/TmuxMaterializationQuery.cs", - "src/LibTmux/Materialization/MaterializationContext.cs", - "src/LibTmux/Materialization/EntityMaterializationState.cs", - "tests/LibTmux.UnitTests/Materialization/SeparatedRowFramerTests.cs", - "tests/LibTmux.UnitTests/Materialization/FormatProjectionTests.cs", - "tests/LibTmux.UnitTests/Materialization/TmuxMaterializerTests.cs", - "tests/LibTmux.IntegrationTests/Materialization/MaterializationTests.cs", - "tests/LibTmux.IntegrationTests/Parity/Component04ParityTests.cs", - ), - 5: ( - "src/LibTmux/Snapshots/CapturedRelation.cs", - "src/LibTmux/Snapshots/SnapshotDepth.cs", - "src/LibTmux/Snapshots/ServerSnapshot.cs", - "src/LibTmux/Snapshots/WindowEntityKey.cs", - "src/LibTmux/Snapshots/SessionWindowEdge.cs", - "src/LibTmux/Session.Relations.cs", - "src/LibTmux/Window.Relations.cs", - "src/LibTmux/Pane.Relations.cs", - "tests/LibTmux.UnitTests/Snapshots/CapturedRelationTests.cs", - "tests/LibTmux.IntegrationTests/Snapshots/HierarchySnapshotTests.cs", - "tests/LibTmux.IntegrationTests/Parity/Component05ParityTests.cs", - "src/LibTmux/Exceptions/IncompleteSnapshotException.cs", - ), - 6: ( - "src/LibTmux/Environment/TmuxEnvironment.cs", - "src/LibTmux/Environment/ChildProcessEnvironment.cs", - "src/LibTmux/Server.Environment.cs", - "src/LibTmux/Session.Environment.cs", - "src/LibTmux/Window.Environment.cs", - "src/LibTmux/Pane.Environment.cs", - "tests/LibTmux.UnitTests/Environment/TmuxEnvironmentTests.cs", - "tests/LibTmux.IntegrationTests/Environment/ChildEnvironmentTests.cs", - "tests/LibTmux.IntegrationTests/Parity/Component06ParityTests.cs", - ), - 7: ( - "src/LibTmux/Collections/SnapshotCollectionExtensions.cs", - "src/LibTmux/Collections/SnapshotLookup.cs", - "src/LibTmux/Server.Collections.cs", - "tests/LibTmux.UnitTests/Collections/SnapshotCollectionTests.cs", - "tests/LibTmux.IntegrationTests/Collections/ScopedCollectionTests.cs", - "tests/LibTmux.IntegrationTests/Parity/Component07ParityTests.cs", - ), - 8: ( - "src/LibTmux/Query/QueryDocument.cs", - "src/LibTmux/Query/QueryNode.cs", - "src/LibTmux/Query/QueryTranslator.cs", - "src/LibTmux/Query/QueryInterpreter.cs", - "src/LibTmux/Query/NativeFilterSearch.cs", - "src/LibTmux/Query/QueryExtensions.cs", - "src/LibTmux/Query/NameContainsLookupParser.cs", - "src/LibTmux.Generators/LibTmux.Generators.csproj", - "src/LibTmux.Generators/FieldCatalogGenerator.cs", - "tests/LibTmux.UnitTests/Query/QuerySemanticsTests.cs", - "tests/LibTmux.IntegrationTests/Query/NativeFilterSearchTests.cs", - "tests/LibTmux.IntegrationTests/Parity/Component08ParityTests.cs", - "src/LibTmux/Exceptions/UnsupportedQueryExpressionException.cs", - "src/LibTmux.Generators/packages.lock.json", - ), - 9: ( - "src/LibTmux.Query.Json/LibTmux.Query.Json.csproj", - "src/LibTmux.Query.Json/QueryJsonSerializerContext.cs", - "src/LibTmux.Query.Json/QueryDocumentJsonConverter.cs", - "src/LibTmux.Query.Json/libtmux-query-v1.schema.json", - "tests/LibTmux.UnitTests/Query/QueryJsonTests.cs", - "tests/LibTmux.IntegrationTests/Parity/Component09ParityTests.cs", - "src/LibTmux.Query.Json/packages.lock.json", - ), - 10: ( - "src/LibTmux/Server.Lifecycle.cs", - "src/LibTmux/Session.Lifecycle.cs", - "src/LibTmux/Requests/NewSessionRequest.cs", - "src/LibTmux/Requests/AttachSessionRequest.cs", - "src/LibTmux/Testing/TemporaryServerScope.cs", - "src/LibTmux/Testing/TemporarySessionScope.cs", - "tests/LibTmux.IntegrationTests/Hierarchy/ServerSessionLifecycleTests.cs", - "tests/LibTmux.IntegrationTests/Parity/Component10ParityTests.cs", - "src/LibTmux/Exceptions/TmuxSessionExistsException.cs", - "src/LibTmux/Internal/SessionName.cs", - "src/LibTmux/Internal/StartDirectory.cs", - ), - 11: ( - "src/LibTmux/Session.WindowNavigation.cs", - "src/LibTmux/Window.Topology.cs", - "src/LibTmux/Requests/NewWindowRequest.cs", - "src/LibTmux/Requests/MoveWindowRequest.cs", - "src/LibTmux/Requests/LinkWindowRequest.cs", - "src/LibTmux/Requests/ResizeWindowRequest.cs", - "src/LibTmux/Requests/SelectLayoutRequest.cs", - "src/LibTmux/Requests/SplitPaneRequest.cs", - "src/LibTmux/Requests/DisplayMessageRequest.cs", - "src/LibTmux/Requests/NewPaneRequest.cs", - "src/LibTmux/Requests/RespawnRequest.cs", - "src/LibTmux/Testing/TemporaryWindowScope.cs", - "tests/LibTmux.IntegrationTests/Hierarchy/WindowTopologyTests.cs", - "tests/LibTmux.IntegrationTests/Parity/Component11ParityTests.cs", - "src/LibTmux/Exceptions/TmuxWindowException.cs", - ), - 12: ( - "src/LibTmux/Window.PaneNavigation.cs", - "src/LibTmux/Pane.Operations.cs", - "src/LibTmux/Requests/CapturePaneRequest.cs", - "src/LibTmux/Requests/DisplayPopupRequest.cs", - "src/LibTmux/Requests/SendKeysRequest.cs", - "src/LibTmux/Requests/ResizePaneRequest.cs", - "src/LibTmux/Requests/MovePaneRequest.cs", - "src/LibTmux/Requests/SwapPaneRequest.cs", - "src/LibTmux/Requests/SelectPaneRequest.cs", - "src/LibTmux/Requests/CopyModeRequest.cs", - "src/LibTmux/Requests/PasteBufferRequest.cs", - "src/LibTmux/Requests/PipePaneRequest.cs", - "src/LibTmux/Requests/ChooseTreeRequest.cs", - "src/LibTmux/Requests/FindWindowRequest.cs", - "tests/LibTmux.IntegrationTests/Hierarchy/PaneOperationsTests.cs", - "tests/LibTmux.IntegrationTests/Parity/Component12ParityTests.cs", - "src/LibTmux/Exceptions/TmuxPaneException.cs", - ), - 13: ( - "src/LibTmux/Server.Clients.cs", - "src/LibTmux/Client.Administration.cs", - "src/LibTmux/ClientAttachment.cs", - "tests/LibTmux.IntegrationTests/Clients/ClientAdministrationTests.cs", - "tests/LibTmux.IntegrationTests/Parity/Component13ParityTests.cs", - ), - 14: ( - "src/LibTmux/Options/TmuxOptionValue.cs", - "src/LibTmux/Options/TmuxOptions.cs", - "src/LibTmux/Internal/OptionParser.cs", - "src/LibTmux/Internal/OptionFailure.cs", - "src/LibTmux/Requests/GetOptionRequest.cs", - "src/LibTmux/Requests/GetOptionsRequest.cs", - "src/LibTmux/Requests/SetOptionRequest.cs", - "src/LibTmux/Requests/UnsetOptionRequest.cs", - "src/LibTmux/Server.Options.cs", - "src/LibTmux/Session.Options.cs", - "src/LibTmux/Window.Options.cs", - "src/LibTmux/Pane.Options.cs", - "tests/LibTmux.UnitTests/Options/TmuxOptionValueTests.cs", - "tests/LibTmux.IntegrationTests/Options/TmuxOptionsTests.cs", - "tests/LibTmux.IntegrationTests/Parity/Component14ParityTests.cs", - "src/LibTmux/Exceptions/TmuxOptionException.cs", - ), - 15: ( - "src/LibTmux/Hooks/TmuxHooks.cs", - "src/LibTmux/Environment/TmuxEnvironmentOperations.cs", - "src/LibTmux/Requests/HookRequest.cs", - "src/LibTmux/Requests/ListHooksRequest.cs", - "src/LibTmux/Requests/SetHookRequest.cs", - "src/LibTmux/Requests/SetHooksRequest.cs", - "src/LibTmux/Server.Hooks.cs", - "src/LibTmux/Session.Hooks.cs", - "src/LibTmux/Window.Hooks.cs", - "src/LibTmux/Pane.Hooks.cs", - "src/LibTmux/Server.EnvironmentOperations.cs", - "src/LibTmux/Session.EnvironmentOperations.cs", - "tests/LibTmux.IntegrationTests/Hooks/HookOperationsTests.cs", - "tests/LibTmux.IntegrationTests/Environment/EnvironmentOperationsTests.cs", - "tests/LibTmux.IntegrationTests/Parity/Component15ParityTests.cs", - ), - 16: ( - "src/LibTmux/Utilities/ServerUtilities.cs", - "src/LibTmux/Server.Utilities.cs", - "src/LibTmux/Utilities/TmuxBuffer.cs", - "src/LibTmux/Utilities/TmuxMenuItem.cs", - "src/LibTmux/Requests/BindKeyRequest.cs", - "src/LibTmux/Requests/CommandPromptRequest.cs", - "src/LibTmux/Requests/ConfirmBeforeRequest.cs", - "src/LibTmux/Requests/DisplayMenuRequest.cs", - "src/LibTmux/Requests/IfShellRequest.cs", - "src/LibTmux/Requests/ListBuffersRequest.cs", - "src/LibTmux/Requests/RunShellRequest.cs", - "src/LibTmux/Requests/ServerAccessRequest.cs", - "src/LibTmux/Requests/UnbindKeyRequest.cs", - "src/LibTmux/Requests/WaitForRequest.cs", - "tests/LibTmux.IntegrationTests/Utilities/ServerUtilitiesTests.cs", - "tests/LibTmux.IntegrationTests/Parity/Component16ParityTests.cs", - ), - 17: ( - "src/LibTmux/Diagnostics/TmuxLog.cs", - "src/LibTmux/Compatibility/SupportedAliases.cs", - "tests/LibTmux.UnitTests/Diagnostics/ExceptionContractTests.cs", - "tests/LibTmux.IntegrationTests/Diagnostics/StructuredLoggingTests.cs", - "tests/LibTmux.IntegrationTests/Parity/Component17ParityTests.cs", - ), - 18: ( - "src/LibTmux/Testing/TmuxWait.cs", - "src/LibTmux/Testing/TmuxNameGenerator.cs", - "src/LibTmux/Testing/TestEnvironment.cs", - "src/LibTmux/Testing/TmuxTestOptions.cs", - "src/LibTmux/Testing/TmuxTestFactory.cs", - "src/LibTmux/Testing/TmuxTestContext.cs", - "src/LibTmux/Testing/TemporaryHierarchyScope.cs", - "tests/LibTmux.PackageConsumer/LibTmux.PackageConsumer.csproj", - "examples/LibTmux.Examples/LibTmux.Examples.csproj", - "examples/LibTmux.Examples/Program.cs", - "tests/LibTmux.IntegrationTests/Parity/Component18ParityTests.cs", - "tests/LibTmux.IntegrationTests/Testing/TestingHelpersTests.cs", - ".github/workflows/dotnet.yml", - ".github/workflows/dotnet-tmux.yml", - "README.md", - "src/LibTmux/PublicAPI.Shipped.txt", - "src/LibTmux/PublicAPI.Unshipped.txt", - "src/LibTmux.Query.Json/PublicAPI.Shipped.txt", - "src/LibTmux.Query.Json/PublicAPI.Unshipped.txt", - "eng/parity/verify_workflows.py", - "eng/parity/tests/test_workflows.py", - "eng/parity/inspect_packages.py", - "eng/parity/tests/test_packages.py", - "eng/evidence/verify_source_binding.py", - "eng/evidence/tests/test_source_binding.py", - "tests/LibTmux.AotSmoke/LibTmux.AotSmoke.csproj", - "tests/LibTmux.AotSmoke/packages.lock.json", - "tests/LibTmux.AotSmoke/Program.cs", - "tests/LibTmux.PackageConsumer/packages.lock.json", - "tests/LibTmux.PackageConsumer/Program.cs", - "tests/LibTmux.PackageConsumer/NuGet.config", - "tests/LibTmux.IntegrationTests/Packaging/PackageClosureTests.cs", - "tests/LibTmux.UnitTests/Packaging/PublicApiContractTests.cs", - "tests/LibTmux.UnitTests/Packaging/WorkflowContractTests.cs", - "examples/LibTmux.Examples/packages.lock.json", - "src/LibTmux.Query.Json/packages.packed.lock.json", - ), -} -COMPONENT_API_TYPES: dict[int, tuple[str, ...]] = { - 1: ( - "T:LibTmux.Client", - "T:LibTmux.ControlModeCommandException", - "T:LibTmux.IControlModeSession", - "T:LibTmux.Internal.TmuxCommandDispatcher", - "T:LibTmux.Internal.TmuxCommandFailure", - "T:LibTmux.Internal.TmuxProcessTransport", - "T:LibTmux.LibTmuxException", - "T:LibTmux.Pane", - "T:LibTmux.Server", - "T:LibTmux.Session", - "T:LibTmux.TmuxCleanupException", - "T:LibTmux.TmuxCommandException", - "T:LibTmux.TmuxCommandNotFoundException", - "T:LibTmux.TmuxChain", - "T:LibTmux.TmuxChaining", - "T:LibTmux.TmuxCommand", - "T:LibTmux.TmuxEvent", - "T:LibTmux.TmuxEventsDroppedEvent", - "T:LibTmux.TmuxExitEvent", - "T:LibTmux.TmuxNotificationEvent", - "T:LibTmux.TmuxOutputEvent", - "T:LibTmux.TmuxCommandResult", - "T:LibTmux.TmuxOperationCanceledException", - "T:LibTmux.TmuxTransportException", - "T:LibTmux.TmuxWaitTimeoutException", - "T:LibTmux.Window", - ), - 2: ( - "T:LibTmux.PaneId", - "T:LibTmux.PsmuxCaptureOptions", - "T:LibTmux.PsmuxConnectionOptions", - "T:LibTmux.PsmuxPane", - "T:LibTmux.PsmuxServer", - "T:LibTmux.PsmuxSession", - "T:LibTmux.PsmuxWindow", - "T:LibTmux.ServerConnectionOptions", - "T:LibTmux.ServerGeneration", - "T:LibTmux.SessionId", - "T:LibTmux.StaleServerGenerationException", - "T:LibTmux.TmuxColorMode", - "T:LibTmux.TmuxObjectNotFoundException", - "T:LibTmux.WindowId", - ), - 3: ( - "T:LibTmux.Internal.CommandFlagCatalog", - "T:LibTmux.Internal.FormatCatalog", - "T:LibTmux.Internal.FormatFieldDescriptor", - "T:LibTmux.LibTmuxInfo", - "T:LibTmux.OptionScope", - "T:LibTmux.PaneDirection", - "T:LibTmux.ResizeDirection", - "T:LibTmux.TmuxDispatchState", - "T:LibTmux.TmuxVersion", - "T:LibTmux.TmuxVersionTooLowException", - "T:LibTmux.WindowDirection", - ), - 4: ( - "T:LibTmux.Internal.FormatProjection", - "T:LibTmux.Internal.SeparatedRowFramer", - "T:LibTmux.Internal.MaterializationContext", - "T:LibTmux.Internal.MaterializationQuery", - "T:LibTmux.Internal.Materializer", - "T:LibTmux.Internal.ServerProjection", - "T:LibTmux.Internal.ServerProjectionDescriptor", - ), - 5: ( - "T:LibTmux.CapturedRelation`1", - "T:LibTmux.IncompleteSnapshotException", - "T:LibTmux.SessionWindowEdge", - "T:LibTmux.SnapshotDepth", - "T:LibTmux.WindowEntityKey", - ), - 6: ("T:LibTmux.TmuxEnvironment", "T:LibTmux.TmuxEnvironmentEntry"), - 7: ("not applicable",), - 8: ( - "T:LibTmux.Query.AndNode", - "T:LibTmux.Query.BooleanConstant", - "T:LibTmux.Query.ComparisonNode", - "T:LibTmux.Query.ConstantNode", - "T:LibTmux.Query.EnumConstant", - "T:LibTmux.Query.FieldNode", - "T:LibTmux.Query.InstantConstant", - "T:LibTmux.Query.Int64Constant", - "T:LibTmux.Query.NotNode", - "T:LibTmux.Query.NullConstant", - "T:LibTmux.Query.OrNode", - "T:LibTmux.Query.QuantifierNode", - "T:LibTmux.Query.QueryComparison", - "T:LibTmux.Query.QueryConstant", - "T:LibTmux.Query.QueryDocument", - "T:LibTmux.Query.QueryEdgeParser", - "T:LibTmux.Query.QueryExtensions", - "T:LibTmux.Query.QueryNode", - "T:LibTmux.Query.QueryQuantifier", - "T:LibTmux.Query.QueryStringOperation", - "T:LibTmux.Query.QueryTarget", - "T:LibTmux.Query.RegexNode", - "T:LibTmux.Query.StringConstant", - "T:LibTmux.Query.StringNode", - "T:LibTmux.Query.TypedIdConstant", - "T:LibTmux.UnsafeTmuxFilter", - "T:LibTmux.UnsupportedQueryExpressionException", - ), - 9: ("T:LibTmux.Query.Json.QueryJson", "T:LibTmux.Query.Json.QueryJsonLimits"), - 10: ( - "T:LibTmux.AttachSessionRequest", - "T:LibTmux.Internal.SessionName", - "T:LibTmux.NewSessionRequest", - "T:LibTmux.OwnedServerScope", - "T:LibTmux.OwnedSessionScope", - "T:LibTmux.Testing.TemporaryServerScope", - "T:LibTmux.Testing.TemporarySessionScope", - "T:LibTmux.TmuxSessionExistsException", - ), - 11: ( - "T:LibTmux.DisplayMessageRequest", - "T:LibTmux.LinkWindowRequest", - "T:LibTmux.MoveWindowRequest", - "T:LibTmux.NewPaneRequest", - "T:LibTmux.NewWindowRequest", - "T:LibTmux.OwnedWindowScope", - "T:LibTmux.ResizeWindowRequest", - "T:LibTmux.RespawnRequest", - "T:LibTmux.SelectLayoutMode", - "T:LibTmux.SelectLayoutRequest", - "T:LibTmux.SplitPaneRequest", - "T:LibTmux.Testing.TemporaryWindowScope", - "T:LibTmux.TmuxWindowException", - "T:LibTmux.WindowResizeMode", - "T:LibTmux.WindowRotationDirection", - ), - 12: ( - "T:LibTmux.CapturePanePosition", - "T:LibTmux.CapturePaneRequest", - "T:LibTmux.ChooseTreeRequest", - "T:LibTmux.ChooseTreeSort", - "T:LibTmux.CopyModeRequest", - "T:LibTmux.DisplayPopupRequest", - "T:LibTmux.FindWindowRequest", - "T:LibTmux.MovePaneRequest", - "T:LibTmux.PaneInputMode", - "T:LibTmux.PaneSelectDirection", - "T:LibTmux.PaneSwapDirection", - "T:LibTmux.PasteBufferRequest", - "T:LibTmux.PipePaneRequest", - "T:LibTmux.PopupCloseMode", - "T:LibTmux.ResizePaneRequest", - "T:LibTmux.SelectPaneRequest", - "T:LibTmux.SendKeysRequest", - "T:LibTmux.SwapPaneRequest", - "T:LibTmux.TmuxPaneException", - ), - 13: ("T:LibTmux.ClientAttachment",), - 14: ( - "T:LibTmux.GetOptionRequest", - "T:LibTmux.GetOptionsRequest", - "T:LibTmux.Internal.OptionFailure", - "T:LibTmux.Internal.OptionParser", - "T:LibTmux.SetOptionRequest", - "T:LibTmux.TmuxOption", - "T:LibTmux.TmuxOptionException", - "T:LibTmux.TmuxOptionState", - "T:LibTmux.TmuxOptionValue", - "T:LibTmux.TmuxOptions", - "T:LibTmux.UnsetOptionRequest", - ), - 15: ( - "T:LibTmux.HookRequest", - "T:LibTmux.ListHooksRequest", - "T:LibTmux.SetHookRequest", - "T:LibTmux.SetHooksRequest", - "T:LibTmux.TmuxHook", - "T:LibTmux.TmuxHookEntry", - "T:LibTmux.TmuxHooks", - ), - 16: ( - "T:LibTmux.BindKeyRequest", - "T:LibTmux.CommandPromptRequest", - "T:LibTmux.ConfirmBeforeRequest", - "T:LibTmux.DisplayMenuRequest", - "T:LibTmux.IfShellRequest", - "T:LibTmux.ListBuffersRequest", - "T:LibTmux.PromptType", - "T:LibTmux.RunShellRequest", - "T:LibTmux.ServerAccessRequest", - "T:LibTmux.ShowMessagesMode", - "T:LibTmux.TmuxBuffer", - "T:LibTmux.TmuxMenuItem", - "T:LibTmux.TmuxWaitMode", - "T:LibTmux.UnbindKeyRequest", - "T:LibTmux.WaitForRequest", - ), - 17: ("T:LibTmux.Internal.TmuxCommandContext",), - 18: ( - "T:LibTmux.Testing.TemporaryHierarchyScope", - "T:LibTmux.Testing.TestEnvironment", - "T:LibTmux.Testing.TmuxNameGenerator", - "T:LibTmux.Testing.TmuxTestContext", - "T:LibTmux.Testing.TmuxTestFactory", - "T:LibTmux.Testing.TmuxTestOptions", - "T:LibTmux.Testing.TmuxWait", - ), -} -TMUX_LANES = ("3.2a", "3.3a", "3.4", "3.5", "3.6", "3.7a", "3.7b") -CLOSURE_GATES = ( - "Package", - "Public API", - "Independent review", - "Repository quality", - "Diff integrity", - "Staged scope", - "Clean worktree", - "Publication boundary", - "Platform workflow configuration", - "macOS tmux workflow configuration", - "External workflow evidence", - "Packed consumers", - "Executable examples", - "NativeAOT", - "Final matrix evidence", -) -CLOSURE_DETAILS = { - "Package": ( - "Inspect package metadata, `.nupkg`, `.snupkg`, SourceLink JSON, " - "repository revision, exact dependencies, and privacy redaction." - ), - "Public API": ( - "Approve Public API analyzer output and require no parity-ledger " - "implementation or evidence gaps." - ), - "Independent review": ( - "Resolve fresh Framework Design Guidelines, Python-parity, and tmux reviews." - ), - "Repository quality": ( - "Run root Ruff formatting and lint, mypy, pytest doctests, and the docs build." - ), - "Diff integrity": "Run `git diff --check`.", - "Staged scope": ( - "Require the staged paths to match the declared component allow-list exactly." - ), - "Clean worktree": "Require empty `git status --porcelain` output.", - "Publication boundary": ( - "Create local commits only: this plan never runs push or tag-creation " - "commands; record local branch, HEAD, tag, and upstream provenance without " - "claiming remote publication proof." - ), - "Platform workflow configuration": ( - "Validate Linux, macOS, and Windows build and unit workflow configuration " - "only; this local check does not execute those runtime jobs." - ), - "macOS tmux workflow configuration": ( - "Validate the current-stable macOS tmux integration workflow configuration " - "only; this local check does not execute tmux on macOS." - ), - "External workflow evidence": ( - "After a user-owned push, hand off collection of immutable workflow run IDs " - "and URLs; local configuration validation is not runtime evidence." - ), - "Packed consumers": "Run packed consumers on `net8.0` and `net10.0`.", - "Executable examples": "Execute every documented real-tmux example.", - "NativeAOT": "Publish and execute trimmed NativeAOT for `net8.0` and `net10.0`.", - "Final matrix evidence": ( - "Generate the source-bound final tmux matrix evidence bundle from the clean " - "production commit, retain it in an evidence-only closure commit, require its evaluated " - "commit to equal `HEAD^`, recompute the evaluated-commit tree fingerprint, " - "and constrain the descendant diff to the final evidence root." - ), -} -BOOTSTRAP_FILES = ( - "LibTmux.slnx", - "src/LibTmux/LibTmux.csproj", - "src/LibTmux/packages.lock.json", - "tests/LibTmux.UnitTests/LibTmux.UnitTests.csproj", - "tests/LibTmux.UnitTests/packages.lock.json", - "tests/LibTmux.IntegrationTests/LibTmux.IntegrationTests.csproj", - "tests/LibTmux.IntegrationTests/packages.lock.json", -) -COMPONENT_DEPENDENCIES = { - 1: ("none",), - 2: ("component 1",), - 3: ("component 1", "component 2"), - 4: ("component 1", "component 2", "component 3"), - 5: ("component 2", "component 4"), - 6: ("component 1", "component 2"), - 7: ("component 1", "component 2", "component 4", "component 5"), - 8: ("component 3", "component 4", "component 5", "component 7"), - 9: ("component 8",), - 10: ( - "component 1", - "component 2", - "component 3", - "component 4", - "component 5", - "component 7", - ), - 11: ("component 3", "component 10"), - 12: ("component 3", "component 10", "component 11"), - 13: ("component 10", "component 11", "component 12"), - 14: ("component 1", "component 2", "component 3"), - 15: ("component 1", "component 2", "component 3", "component 14"), - 16: ("component 10", "component 12", "component 13"), - 17: tuple(f"component {component}" for component in range(1, 17)), - 18: tuple(f"component {component}" for component in range(1, 18)), -} -COMPONENT_SHARED_FILES: dict[int, tuple[str, ...]] = dict.fromkeys( - COMPONENT_IDS, - ("docs/parity/parity-ledger.json",), -) -COMPONENT_SHARED_FILES[3] += ( - "eng/tmux/build-version.sh", - "eng/tmux/run-matrix.sh", - "eng/evidence/assemble_bundle.py", - "eng/evidence/tests/test_transactions.py", - "eng/parity/reconcile_versions.py", - "eng/parity/tests/test_reconcile_versions.py", - "eng/evidence/validate.py", - "eng/evidence/tests/test_validate.py", - "tests/LibTmux.IntegrationTests/Infrastructure/PtyAttachedClientScope.cs", -) -COMPONENT_SHARED_FILES[4] += ( - "src/LibTmux/Server.Identity.cs", - "src/LibTmux/Session.Identity.cs", - "src/LibTmux/Window.Identity.cs", - "src/LibTmux/Pane.Identity.cs", - "src/LibTmux/Transport/TmuxTransportLimits.cs", - "src/LibTmux/Transport/Utf8BackslashDecoder.cs", - "src/LibTmux/Internal/FormatCatalog.cs", -) -COMPONENT_SHARED_FILES[5] += ( - "src/LibTmux/Materialization/EntityMaterializationState.cs", -) -COMPONENT_SHARED_FILES[8] += ( - "LibTmux.slnx", - "src/LibTmux/LibTmux.csproj", -) -COMPONENT_SHARED_FILES[9] += ( - "LibTmux.slnx", - "tests/LibTmux.UnitTests/LibTmux.UnitTests.csproj", - "tests/LibTmux.UnitTests/packages.lock.json", -) -VERSION_POLICY_SHARED_FILES = ( - "docs/parity/version-deltas.json", - "tests/LibTmux.IntegrationTests/Versioning/VersionParityTests.cs", - "eng/parity/reconcile_versions.py", - "eng/parity/tests/test_reconcile_versions.py", -) -VERSION_POLICY_OWNER_COMPONENTS = (10, 11, 12, 13, 15, 16) -# Policy rows stay pending until cohort closure, so a policy-owning component -# never edits the version-policy documents; declaring them shared here would -# demand a change the component has no cause to make. -_VERSION_POLICY_NAMESPACE = runpy.run_path( - str(pathlib.Path(__file__).parents[1] / "reconcile_versions.py") -) -VERSION_POLICY_PROOFS_BY_COMPONENT: dict[int, tuple[str, ...]] = { - component_id: tuple( - ( - f"{capability} | {test} | supported=" - f"{_VERSION_POLICY_NAMESPACE['POLICY_PROOF_CONTRACTS'][capability]['supportedBoundary']}" - " | unsupported=" - f"{_VERSION_POLICY_NAMESPACE['POLICY_PROOF_CONTRACTS'][capability]['unsupportedBoundary']}" - " | evidenceStatus=pending until cohort closure" - ) - for capability, components in _VERSION_POLICY_NAMESPACE[ - "POLICY_OWNER_COMPONENTS" - ].items() - for owner, test in zip( - components, - _VERSION_POLICY_NAMESPACE["POLICY_WRAPPER_TESTS"][capability], - strict=True, - ) - if owner == component_id - ) - for component_id in VERSION_POLICY_OWNER_COMPONENTS -} -ENTITY_SHELL_FILES = ( - "src/LibTmux/Server.cs", - "src/LibTmux/Session.cs", - "src/LibTmux/Window.cs", - "src/LibTmux/Pane.cs", - "src/LibTmux/Client.cs", -) -ENTITY_FRAGMENT_FILES = { - 1: ( - "src/LibTmux/Server.Command.cs", - "src/LibTmux/Session.Command.cs", - "src/LibTmux/Window.Command.cs", - "src/LibTmux/Pane.Command.cs", - ), - 2: ( - "src/LibTmux/Server.Identity.cs", - "src/LibTmux/Session.Identity.cs", - "src/LibTmux/Window.Identity.cs", - "src/LibTmux/Pane.Identity.cs", - ), - 3: ("src/LibTmux/Server.Version.cs",), - 5: ( - "src/LibTmux/Session.Relations.cs", - "src/LibTmux/Window.Relations.cs", - "src/LibTmux/Pane.Relations.cs", - ), - 6: ( - "src/LibTmux/Server.Environment.cs", - "src/LibTmux/Session.Environment.cs", - "src/LibTmux/Window.Environment.cs", - "src/LibTmux/Pane.Environment.cs", - ), - 7: ("src/LibTmux/Server.Collections.cs",), - 10: ( - "src/LibTmux/Server.Lifecycle.cs", - "src/LibTmux/Session.Lifecycle.cs", - ), - 11: ( - "src/LibTmux/Session.WindowNavigation.cs", - "src/LibTmux/Window.Topology.cs", - ), - 12: ( - "src/LibTmux/Window.PaneNavigation.cs", - "src/LibTmux/Pane.Operations.cs", - ), - 13: ( - "src/LibTmux/Server.Clients.cs", - "src/LibTmux/Client.Administration.cs", - ), - 14: ( - "src/LibTmux/Server.Options.cs", - "src/LibTmux/Session.Options.cs", - "src/LibTmux/Window.Options.cs", - "src/LibTmux/Pane.Options.cs", - ), - 15: ( - "src/LibTmux/Server.Hooks.cs", - "src/LibTmux/Session.Hooks.cs", - "src/LibTmux/Window.Hooks.cs", - "src/LibTmux/Pane.Hooks.cs", - ), - 16: ("src/LibTmux/Server.Utilities.cs",), -} -EXCEPTION_FILES = ( - "src/LibTmux/Exceptions/LibTmuxException.cs", - "src/LibTmux/Exceptions/TmuxCommandException.cs", - "src/LibTmux/Exceptions/TmuxCommandNotFoundException.cs", - "src/LibTmux/Exceptions/TmuxTransportException.cs", - "src/LibTmux/Exceptions/TmuxOperationCanceledException.cs", - "src/LibTmux/Exceptions/TmuxCleanupException.cs", - "src/LibTmux/Exceptions/TmuxWaitTimeoutException.cs", - "src/LibTmux/Exceptions/StaleServerGenerationException.cs", - "src/LibTmux/Exceptions/TmuxObjectNotFoundException.cs", - "src/LibTmux/Exceptions/TmuxVersionTooLowException.cs", - "src/LibTmux/Exceptions/IncompleteSnapshotException.cs", - "src/LibTmux/Exceptions/UnsupportedQueryExpressionException.cs", - "src/LibTmux/Exceptions/TmuxSessionExistsException.cs", - "src/LibTmux/Exceptions/TmuxWindowException.cs", - "src/LibTmux/Exceptions/TmuxPaneException.cs", - "src/LibTmux/Exceptions/TmuxOptionException.cs", -) -# Every tmux command passes through one dispatcher, so the diagnostics it -# records belong there rather than repeated in each entity. -DIAGNOSTIC_SHARED_FILES = ( - "src/LibTmux/Transport/TmuxCommandDispatcher.cs", - "src/LibTmux/Connection/TmuxConnection.cs", -) -COMPONENT_SHARED_FILES[17] += DIAGNOSTIC_SHARED_FILES -COMPONENT_SHARED_FILES[18] += ( - "LibTmux.slnx", - "Directory.Packages.props", - "src/LibTmux/LibTmux.csproj", - "src/LibTmux/packages.lock.json", - "src/LibTmux.Query.Json/LibTmux.Query.Json.csproj", - "src/LibTmux.Query.Json/packages.lock.json", -) -FOUNDATIONAL_FILES = ( - *ENTITY_SHELL_FILES, - *EXCEPTION_FILES[:7], - "src/LibTmux/Transport/TmuxCommandDispatcher.cs", - "src/LibTmux/Transport/TmuxCommandFailure.cs", - "tests/LibTmux.IntegrationTests/Infrastructure/RawTmuxTestContext.cs", - "tests/LibTmux.IntegrationTests/Infrastructure/ControlModeClientScope.cs", - "tests/LibTmux.IntegrationTests/Infrastructure/PtyAttachedClientScope.cs", - "tests/LibTmux.TestChild/LibTmux.TestChild.csproj", - "tests/LibTmux.TestChild/packages.lock.json", - "tests/LibTmux.TestChild/Program.cs", -) -PROJECT_WIRING = { - 1: ( - "mise exec -- dotnet sln LibTmux.slnx add src/LibTmux/LibTmux.csproj tests/LibTmux.UnitTests/LibTmux.UnitTests.csproj tests/LibTmux.IntegrationTests/LibTmux.IntegrationTests.csproj tests/LibTmux.TestChild/LibTmux.TestChild.csproj", - "mise exec -- dotnet add tests/LibTmux.UnitTests/LibTmux.UnitTests.csproj reference src/LibTmux/LibTmux.csproj", - "mise exec -- dotnet add tests/LibTmux.IntegrationTests/LibTmux.IntegrationTests.csproj reference src/LibTmux/LibTmux.csproj", - "src/LibTmux/LibTmux.csproj declares InternalsVisibleTo for LibTmux.UnitTests and LibTmux.IntegrationTests", - "tests/LibTmux.UnitTests/LibTmux.UnitTests.csproj references Microsoft.CodeAnalysis.CSharp so EntityShellTests parses source syntax instead of reflecting partial declarations", - "Server, Session, Window, and Pane shells expose an internal dispatcher plus default-target constructor seam used by their Component 1 command fragments before public Open and typed IDs arrive", - ), - 8: ( - "mise exec -- dotnet sln LibTmux.slnx add src/LibTmux.Generators/LibTmux.Generators.csproj", - "src/LibTmux/LibTmux.csproj references src/LibTmux.Generators/LibTmux.Generators.csproj with OutputItemType=Analyzer and ReferenceOutputAssembly=false", - ), - 9: ( - "mise exec -- dotnet sln LibTmux.slnx add src/LibTmux.Query.Json/LibTmux.Query.Json.csproj", - "mise exec -- dotnet add src/LibTmux.Query.Json/LibTmux.Query.Json.csproj reference src/LibTmux/LibTmux.csproj", - "mise exec -- dotnet add tests/LibTmux.UnitTests/LibTmux.UnitTests.csproj reference src/LibTmux.Query.Json/LibTmux.Query.Json.csproj", - ), - 18: ( - "mise exec -- dotnet sln LibTmux.slnx add tests/LibTmux.AotSmoke/LibTmux.AotSmoke.csproj tests/LibTmux.PackageConsumer/LibTmux.PackageConsumer.csproj examples/LibTmux.Examples/LibTmux.Examples.csproj", - "mise exec -- dotnet add examples/LibTmux.Examples/LibTmux.Examples.csproj reference src/LibTmux/LibTmux.csproj src/LibTmux.Query.Json/LibTmux.Query.Json.csproj", - "Directory.Packages.props declares exact central PackageVersion entries for LibTmux and LibTmux.Query.Json at [0.1.0-local]", - "tests/LibTmux.AotSmoke/LibTmux.AotSmoke.csproj and tests/LibTmux.PackageConsumer/LibTmux.PackageConsumer.csproj declare versionless PackageReference entries for LibTmux and LibTmux.Query.Json so Central Package Management supplies [0.1.0-local]", - "src/LibTmux.Query.Json/LibTmux.Query.Json.csproj declares its LibTmux ProjectReference only when UsePackedLibTmux is not true", - "src/LibTmux.Query.Json/LibTmux.Query.Json.csproj declares a versionless LibTmux PackageReference only when UsePackedLibTmux is true so Central Package Management supplies exactly [0.1.0-local]", - "src/LibTmux.Query.Json/LibTmux.Query.Json.csproj sets NuGetLockFilePath to $(MSBuildProjectDirectory)/packages.packed.lock.json only when UsePackedLibTmux is true and otherwise uses packages.lock.json", - "default and packed Query.Json locked restores use distinct owned lock files for their mutually exclusive dependency graphs", - "src/LibTmux/LibTmux.csproj and src/LibTmux.Query.Json/LibTmux.Query.Json.csproj declare PackageReference Include=Microsoft.CodeAnalysis.PublicApiAnalyzers with PrivateAssets=all", - ), -} -COMPONENT_ONE_TRANSPORT_CONTRACT = ( - "ExecuteCommandAsync accepts exactly one logical tmux command; a literal ; argument remains data and is never a structural separator", - "Only the internal TmuxCommandRequest and transport overload represent structural command groups with typed separators; no public grouping overload exists", - "TmuxCommandResult.Arguments is a defensively copied logical argument sequence that excludes the tmux binary, connection prefixes, and guard arguments, and record equality compares the sequence deeply", - "StandardOutput and StandardError preserve exact bytes while line projections normalize CRLF and lone CR to LF with Python universal-newline behavior", - "ThrowIfFailed throws for any nonempty projected stderr regardless of exit code; has-session stderr leniency is dispatcher policy and never mutates raw bytes", - "TmuxTransportLimits is an internal seam with MaxArguments=4096, MaxCapturedBytesPerStream=64 MiB, and CleanupTimeout=5s defaults; argument or stream overflow raises TmuxTransportException and every post-start failure performs bounded cleanup", - "PtyAttachedClientScope uses script-backed PTY execution on Linux and macOS, or a behaviorally equivalent PTY implementation, and has an executable smoke test", - "TmuxProcessTransport injects internal launcher and clock seams plus TmuxTransportLimits so tests control process start, deadlines, pumps, descendants, and cleanup faults without wall-clock sleeps", - "LibTmux.TestChild exposes deterministic modes for arbitrary concurrent raw stdout and stderr, invalid bytes, partial final output, nonzero exit, a held pump, descendant survival, and cleanup faults", - "Server.cs, Session.cs, Window.cs, Pane.cs, and Client.cs remain declaration-only; dispatcher fields and internal constructors for command-capable entities live only in Server.Command.cs, Session.Command.cs, Window.Command.cs, and Pane.Command.cs", -) -C4_MATERIALIZATION_CONTRACT = ( - "Each row carries exactly projection.Fields.Count values, each terminated by FormatProjection.RowSeparator; wire names are not sent and values are read positionally; every field is expanded exactly once, because a byte-count prefix would expand it twice and a field that moved in between would desynchronise the payload; copied value bytes remain undecoded until Utf8BackslashDecoder", - "tmux LF separates rows, CRLF is accepted, and a complete final row may end at EOF; embedded CR and LF remain value data", - "Empty values map to null with their key present; a row that ends before every field is read, a value that never closes, an oversized value, and a row not terminated by a newline each throws InvalidDataException; returned memories are copied", - "TmuxTransportLimits adds MaxFramedFieldBytes=64 MiB by default, requires a positive value no greater than MaxCapturedBytesPerStream, and SeparatedRowFramer enforces it per value", - "MaterializationQuery maps low-level InvalidDataException to TmuxTransportException carrying the logical tmux arguments", - "FormatCatalog.ObjProjection contains 178 Obj fields; the existing catalog union is 82 with overlap 72, adds 106 fields, and yields 188 combined fields", - "Format scopes contain universal=9, session=23, window=34, pane=70, client=25, buffer=3, event=9, and context=5 fields", - "client_uid, client_user, pane_dead_signal, and pane_dead_time require tmux 3.3; the eleven approved 3.7 fields require tmux 3.7; every other field requires tmux 3.2a", - "FormatProjection.Create emits 123/125/136 fields for sessions, windows, and panes at 3.2a/3.3a-3.6/3.7a+, and 146/150/161 fields for clients; FramedFieldCount is twice Fields.Count", - "MaterializationQuery.FetchAsync returns all decoded dictionaries; FetchOneAsync uses the canonical tmux session for window and pane lookup, returns one dictionary, distinguishes a missing target from an unreachable server, and accepts a final CancellationToken", - "Materializer dictionary overloads create Session, Window, and Pane handles with explicit MaterializationContext.Server ownership after Utf8BackslashDecoder projects copied raw values", - "Private EntityMaterializationState carries copied raw fields, the owning Server, parent SessionId and WindowId identities, a Window SessionWindowEdge, and default uncaptured relation slots; an internal replacement or factory path lets Component 5 assign edge ordinals and captured relations without editing Component 4 files", - "Every materialized row carries universal pid and start_time; MaterializationQuery rejects an unmaterialized MaterializationContext.Server.Generation before live acquisition, and both MaterializationQuery and Materializer reject parsed generation unequal to the owner with StaleServerGenerationException; MaterializationTests.Materializer_uses_server_context_and_returns_typed_raw_fields proves this owner and generation validation", -) -SOLUTION_RESTORE_PAIR = ( - "mise exec -- dotnet restore LibTmux.slnx", - "mise exec -- dotnet restore LibTmux.slnx --locked-mode", -) -RED_BOOTSTRAP = { - 1: ( - "Create compile-ready C1 production signature stubs, TestChild modes, the selected behavioral test, require_red.py, and require_red tests before restore; do not implement the behavior and never hand-author lock files", - *SOLUTION_RESTORE_PAIR, - "uv run pytest eng/parity/tests/test_require_red.py", - ), - 8: ( - "Create compile-ready generator, core, and test signature stubs plus the selected behavioral test before restore; do not implement the behavior and never hand-author lock files", - *SOLUTION_RESTORE_PAIR, - ), - 9: ( - "Create compile-ready Query.Json and unit-test signature stubs plus the selected behavioral test before restore; do not implement the behavior and never hand-author lock files", - *SOLUTION_RESTORE_PAIR, - ), -} -RED_RUNNER_CONTRACT = ( - "require_red.py invokes Microsoft Testing Platform with --no-restore, --filter-method for the exact declared --test identity, and the xUnit TRX reporter at the exact evidence path", - "require_red.py accepts only a nonzero test-process exit with well-formed TRX containing at least one executed test and the selected behavioral test exactly once with outcome Failed", - "require_red.py rejects build or discovery failures, zero tests, all skipped tests, aborted or canceled runs, malformed or missing TRX, unexpected test identities, and successful test runs", - "Every component RED command invokes require_red.py directly with Release, --no-restore, one exact --test identity, and its retained TRX path; no shell negation or failure-swallowing command may decide RED", -) -RED_EVIDENCE_FRESHNESS_CONTRACT = ( - "require_red.py removes any pre-existing evidence path before invoking dotnet test and accepts only a newly created TRX from that invocation", -) -TMUX_37_TRANSITION_PROOF_CONTRACT = ( - "Build one transition tmux 3.7 binary with eng/tmux/build-version.sh and require tmux -V = tmux 3.7.", - "The tmux 3.7 transition proof runs only for the explicit capability cohort 0001; directory names never select behavior, and that cohort rejects the advisory master lane.", - 'The cohort-bound environment records capabilityCohort="0001" and excludes only its exact evidence output root from source-state and source-fingerprint calculations.', - 'run-matrix.sh sets LIBTMUX_TRANSITION_TMUX_3_7 to the verified 3.7 binary and writes its full source commit as transitionTmuxSourceCommits["3.7"] in environment.json.', - "VersionParityTests.BreakPane37Workaround proves net8.0 and net10.0 behavior for exact 3.7 and 3.7a, applying the workaround only to 3.7.", - "The redacted break-pane transition transcript has exactly four records: net8.0/3.7, net8.0/3.7a, net10.0/3.7, and net10.0/3.7a; each records observed tmux version, workaround state, and behavioral outcome.", - "reconcile_versions.py validates the raw break-pane transcript and its 3.7 transition commit against the required 3.7a matrix source commit without attaching wrapper-policy evidence to the break-pane row.", - "Capability cohort 0001 verifies exactly the five upstream protocol observations; all 34 command-policy rows remain evidenceStatus=pending with no evidence field until their policyOwnerComponents implement wrapper-level proofs.", - "The two hook flag rows retain introducedIn=3.2 source history, declare supportRange=baseline across the supported tmux range beginning at 3.2a, and set unsupportedBehavior=not_applicable_below_supported_floor.", - "Component 3 may independently mark its 50 parity-ledger rows implemented and verified; version-delta policy status does not gate or inherit those ledger transitions.", - "results.ndjson remains exactly the seven required tmux versions crossed with net8.0 and net10.0; 3.7 is not a matrix row.", -) -C1_FAILURE_CORPUS_CONTRACT = ( - "A missing configured tmux binary throws TmuxCommandNotFoundException whose TmuxBinaryPath is the configured executable path", - "Pre-start cancellation throws OperationCanceledException carrying the caller token and starts no process", - "Post-start cancellation throws TmuxOperationCanceledException with CommandMayHaveExecuted=true and the direct client PID", - "Post-start cancellation reaps only the direct client while the TestChild descendant-survival mode proves the descendant remains alive", - "Cleanup failure throws TmuxCleanupException with the original cancellation, client PID, and cleanup failure", - "Invalid UTF-8 projection escapes each invalid byte independently as lowercase \\xNN while StandardOutput and StandardError remain byte-exact", -) -REQUIRED_PROJECT_FILES = { - 1: ("tests/LibTmux.UnitTests/Entities/EntityShellTests.cs",), - 3: ( - "src/LibTmux/Internal/CommandFlagCatalog.cs", - "src/LibTmux/Internal/FormatCatalog.cs", - "src/LibTmux/Internal/FormatFieldDescriptor.cs", - ), - 4: ( - "src/LibTmux/Materialization/SeparatedRowFramer.cs", - "src/LibTmux/Materialization/MaterializationContext.cs", - ), - 8: ( - "src/LibTmux.Generators/LibTmux.Generators.csproj", - "src/LibTmux.Generators/FieldCatalogGenerator.cs", - "src/LibTmux.Generators/packages.lock.json", - ), - 9: ( - "src/LibTmux.Query.Json/LibTmux.Query.Json.csproj", - "src/LibTmux.Query.Json/QueryJsonSerializerContext.cs", - "src/LibTmux.Query.Json/QueryDocumentJsonConverter.cs", - "src/LibTmux.Query.Json/libtmux-query-v1.schema.json", - "src/LibTmux.Query.Json/packages.lock.json", - ), - 10: ("src/LibTmux/Requests/AttachSessionRequest.cs",), - 11: ("src/LibTmux/Requests/DisplayMessageRequest.cs",), - 12: ("src/LibTmux/Requests/DisplayPopupRequest.cs",), - 18: ( - ".github/workflows/dotnet.yml", - ".github/workflows/dotnet-tmux.yml", - "README.md", - "src/LibTmux/PublicAPI.Shipped.txt", - "src/LibTmux/PublicAPI.Unshipped.txt", - "src/LibTmux.Query.Json/PublicAPI.Shipped.txt", - "src/LibTmux.Query.Json/PublicAPI.Unshipped.txt", - "eng/parity/verify_workflows.py", - "eng/parity/tests/test_workflows.py", - "eng/parity/inspect_packages.py", - "eng/parity/tests/test_packages.py", - "eng/evidence/verify_source_binding.py", - "eng/evidence/tests/test_source_binding.py", - "src/LibTmux.Query.Json/packages.packed.lock.json", - "tests/LibTmux.AotSmoke/LibTmux.AotSmoke.csproj", - "tests/LibTmux.AotSmoke/packages.lock.json", - "tests/LibTmux.AotSmoke/Program.cs", - "tests/LibTmux.PackageConsumer/LibTmux.PackageConsumer.csproj", - "tests/LibTmux.PackageConsumer/packages.lock.json", - "tests/LibTmux.PackageConsumer/Program.cs", - "tests/LibTmux.PackageConsumer/NuGet.config", - "tests/LibTmux.IntegrationTests/Packaging/PackageClosureTests.cs", - "tests/LibTmux.UnitTests/Packaging/PublicApiContractTests.cs", - "tests/LibTmux.UnitTests/Packaging/WorkflowContractTests.cs", - "tests/LibTmux.IntegrationTests/Testing/TestingHelpersTests.cs", - "examples/LibTmux.Examples/LibTmux.Examples.csproj", - "examples/LibTmux.Examples/packages.lock.json", - "examples/LibTmux.Examples/Program.cs", - ), -} -PUBLIC_API_FILE_BINDINGS = { - "T:LibTmux.Internal.CommandFlagCatalog": ( - 3, - "src/LibTmux/Internal/CommandFlagCatalog.cs", - ), - "T:LibTmux.Internal.FormatCatalog": ( - 3, - "src/LibTmux/Internal/FormatCatalog.cs", - ), - "T:LibTmux.Internal.FormatFieldDescriptor": ( - 3, - "src/LibTmux/Internal/FormatFieldDescriptor.cs", - ), - "T:LibTmux.OptionScope": ( - 3, - "src/LibTmux/Constants/TmuxEnums.cs", - ), - "T:LibTmux.PaneDirection": ( - 3, - "src/LibTmux/Constants/TmuxEnums.cs", - ), - "T:LibTmux.ResizeDirection": ( - 3, - "src/LibTmux/Constants/TmuxEnums.cs", - ), - "T:LibTmux.WindowDirection": ( - 3, - "src/LibTmux/Constants/TmuxEnums.cs", - ), - "T:LibTmux.Internal.SeparatedRowFramer": ( - 4, - "src/LibTmux/Materialization/SeparatedRowFramer.cs", - ), - "T:LibTmux.Internal.MaterializationContext": ( - 4, - "src/LibTmux/Materialization/MaterializationContext.cs", - ), - "T:LibTmux.TmuxColorMode": ( - 2, - "src/LibTmux/TmuxColorMode.cs", - ), - "T:LibTmux.AttachSessionRequest": ( - 10, - "src/LibTmux/Requests/AttachSessionRequest.cs", - ), - "T:LibTmux.DisplayMessageRequest": ( - 11, - "src/LibTmux/Requests/DisplayMessageRequest.cs", - ), - "T:LibTmux.DisplayPopupRequest": ( - 12, - "src/LibTmux/Requests/DisplayPopupRequest.cs", - ), -} -PUBLIC_API_MEMBER_FILE_BINDINGS = { - "P:LibTmux.Server.Version": ( - 3, - "src/LibTmux/Server.Version.cs", - ), -} -FORMAT_SEPARATOR_CONTRACT = { - "componentId": 4, - "destinationStatus": "excluded", - "csharpDestination": None, - "replacement": ("M:LibTmux.Internal.SeparatedRowFramer.Decode(ReadOnlySpan)"), - "exclusionReason": ( - "Delimiter-based row framing is replaced by the raw-byte protocol approved " - "in ADR 0001." - ), - "testPath": ( - "tests/LibTmux.IntegrationTests/Parity/Component04ParityTests.cs" - ), -} -FORBIDDEN_PRODUCTION_FILES = ( - "src/LibTmux/Materialization/FieldCatalog.cs", - "src/LibTmux/Requests/AttachClientRequest.cs", - "src/LibTmux/Requests/DisplayOverlayRequest.cs", - "src/LibTmux/Internal/XunitTmuxHarness.cs", -) -CORE_RESTORE_PAIR = ( - "mise exec -- dotnet restore src/LibTmux/LibTmux.csproj", - "mise exec -- dotnet restore src/LibTmux/LibTmux.csproj --locked-mode", -) -JSON_DEFAULT_RESTORE_PAIR = ( - "mise exec -- dotnet restore src/LibTmux.Query.Json/LibTmux.Query.Json.csproj", - "mise exec -- dotnet restore src/LibTmux.Query.Json/LibTmux.Query.Json.csproj --locked-mode", -) -JSON_PACKED_RESTORE_PAIR = ( - "mise exec -- dotnet restore src/LibTmux.Query.Json/LibTmux.Query.Json.csproj --source artifacts/packages --source https://api.nuget.org/v3/index.json -p:UsePackedLibTmux=true", - "mise exec -- dotnet restore src/LibTmux.Query.Json/LibTmux.Query.Json.csproj --locked-mode --source artifacts/packages --source https://api.nuget.org/v3/index.json -p:UsePackedLibTmux=true", -) -LOCAL_FEED_SOLUTION_RESTORE_PAIR = ( - "mise exec -- dotnet restore LibTmux.slnx --source artifacts/packages --source https://api.nuget.org/v3/index.json", - "mise exec -- dotnet restore LibTmux.slnx --locked-mode --source artifacts/packages --source https://api.nuget.org/v3/index.json", -) -PACKAGE_COMMANDS = ( - *CORE_RESTORE_PAIR, - *JSON_DEFAULT_RESTORE_PAIR, - "mise exec -- dotnet pack src/LibTmux/LibTmux.csproj --configuration Release --no-restore --output artifacts/packages -p:PackageVersion=0.1.0-local", - *JSON_PACKED_RESTORE_PAIR, - "mise exec -- dotnet pack src/LibTmux.Query.Json/LibTmux.Query.Json.csproj --configuration Release --no-restore --output artifacts/packages -p:PackageVersion=0.1.0-local -p:UsePackedLibTmux=true", - *LOCAL_FEED_SOLUTION_RESTORE_PAIR, - "unzip -l artifacts/packages/LibTmux.0.1.0-local.nupkg", - "unzip -l artifacts/packages/LibTmux.0.1.0-local.snupkg", - "unzip -l artifacts/packages/LibTmux.Query.Json.0.1.0-local.nupkg", - "unzip -l artifacts/packages/LibTmux.Query.Json.0.1.0-local.snupkg", - "unzip -p artifacts/packages/LibTmux.0.1.0-local.nupkg LibTmux.nuspec", - "unzip -p artifacts/packages/LibTmux.Query.Json.0.1.0-local.nupkg LibTmux.Query.Json.nuspec", - "uv run python eng/parity/inspect_packages.py --artifacts artifacts/packages --repository .", -) -PUBLIC_API_BUILD_COMMAND = "mise exec -- dotnet build LibTmux.slnx --configuration Release --no-restore --warnaserror" -PACKED_CONSUMER_COMMANDS = ( - "mise exec -- dotnet run --project tests/LibTmux.PackageConsumer/LibTmux.PackageConsumer.csproj --configuration Release --framework net8.0 --no-build", - "mise exec -- dotnet run --project tests/LibTmux.PackageConsumer/LibTmux.PackageConsumer.csproj --configuration Release --framework net10.0 --no-build", -) -EXAMPLE_COMMANDS = ( - "mise exec -- dotnet run --project examples/LibTmux.Examples/LibTmux.Examples.csproj --configuration Release --framework net8.0 --no-build", - "mise exec -- dotnet run --project examples/LibTmux.Examples/LibTmux.Examples.csproj --configuration Release --framework net10.0 --no-build", -) -AOT_RID_RESTORE_PAIR = ( - "mise exec -- dotnet restore tests/LibTmux.AotSmoke/LibTmux.AotSmoke.csproj --runtime linux-x64 --source artifacts/packages --source https://api.nuget.org/v3/index.json", - "mise exec -- dotnet restore tests/LibTmux.AotSmoke/LibTmux.AotSmoke.csproj --locked-mode --runtime linux-x64 --source artifacts/packages --source https://api.nuget.org/v3/index.json", -) -AOT_COMMANDS = ( - *AOT_RID_RESTORE_PAIR, - "mise exec -- dotnet publish tests/LibTmux.AotSmoke/LibTmux.AotSmoke.csproj --configuration Release --framework net8.0 --runtime linux-x64 --self-contained --no-restore -p:PublishAot=true -p:PublishTrimmed=true --output artifacts/aot/net8.0", - "mise exec -- dotnet publish tests/LibTmux.AotSmoke/LibTmux.AotSmoke.csproj --configuration Release --framework net10.0 --runtime linux-x64 --self-contained --no-restore -p:PublishAot=true -p:PublishTrimmed=true --output artifacts/aot/net10.0", - "artifacts/aot/net8.0/LibTmux.AotSmoke", - "artifacts/aot/net10.0/LibTmux.AotSmoke", -) -C18_RESTORE_PAIRS = { - "core default": CORE_RESTORE_PAIR, - "Query.Json default": JSON_DEFAULT_RESTORE_PAIR, - "Query.Json packed": JSON_PACKED_RESTORE_PAIR, - "local-feed solution": LOCAL_FEED_SOLUTION_RESTORE_PAIR, - "NativeAOT linux-x64 RID": AOT_RID_RESTORE_PAIR, -} -WORKFLOW_CONFIGURATION_COMMANDS = ( - "uv run python eng/parity/verify_workflows.py --lane platform .github/workflows/dotnet.yml", - "uv run python eng/parity/verify_workflows.py --lane macos-tmux .github/workflows/dotnet-tmux.yml", -) -FINAL_EVIDENCE_ROOT = "docs/parity/evidence/final" -C3_EVIDENCE_ROOT = "docs/parity/evidence/0001" -VERSION_DELTA_PATH = "docs/parity/version-deltas.json" -NON_RETAINED_MATRIX_COMMAND = "eng/tmux/run-matrix.sh tests/LibTmux.IntegrationTests/LibTmux.IntegrationTests.csproj" -RETAINED_MATRIX_COMMAND = "eng/tmux/run-matrix.sh --capability-cohort closure --evidence-dir docs/parity/evidence/final tests/LibTmux.IntegrationTests/LibTmux.IntegrationTests.csproj" -VALIDATE_FINAL_MATRIX_COMMAND = "uv run python eng/evidence/validate.py --phase matrix docs/parity/evidence/final" -PRECOMMIT_SOURCE_BINDING_COMMAND = "uv run python eng/evidence/verify_source_binding.py --evidence docs/parity/evidence/final --repository . --require-evaluated-commit HEAD --allow-dirty-root docs/parity/evidence/final --fingerprint-mode evaluated-commit-tree" -POSTCOMMIT_SOURCE_BINDING_COMMAND = "uv run python eng/evidence/verify_source_binding.py --evidence docs/parity/evidence/final --repository . --require-evaluated-commit HEAD^ --require-descendant-root docs/parity/evidence/final --require-descendant-path docs/parity/version-deltas.json --fingerprint-mode evaluated-commit-tree" -FINAL_RECONCILE_COMMAND = "uv run python eng/parity/reconcile_versions.py --evidence docs/parity/evidence/final/results.ndjson --write" -PERSISTED_RECONCILE_COMMAND = "uv run python eng/parity/reconcile_versions.py" -EVIDENCE_STAGE_COMMAND = "git add -- docs/parity/evidence/final docs/parity/version-deltas.json" -EVIDENCE_SCOPE_COMMAND = "uv run python eng/parity/verify_production_plan.py --phase closure --verify-final-evidence-staged-scope docs/superpowers/plans/2026-08-09-libtmux-csharp-production.md" -EVIDENCE_COMMIT_COMMAND = "printf '%s\\n' 'Evidence(docs[closure]): Close policy proof' '' 'why: Bind retained compatibility evidence and reconciled policy status to the source commit.' '' what: '- Record the clean source commit and source fingerprint.' '- Retain the required tmux and framework lanes.' '- Reconcile wrapper-policy evidence.' | git commit --file -" -SOURCE_WORKTREE_CLEAN_COMMAND = 'test -z "$(git status --porcelain)"' -FINAL_MATRIX_COMMANDS = ( - RETAINED_MATRIX_COMMAND, - VALIDATE_FINAL_MATRIX_COMMAND, - PRECOMMIT_SOURCE_BINDING_COMMAND, - FINAL_RECONCILE_COMMAND, - PERSISTED_RECONCILE_COMMAND, - EVIDENCE_STAGE_COMMAND, - EVIDENCE_SCOPE_COMMAND, - "git diff --cached --check", - EVIDENCE_COMMIT_COMMAND, - POSTCOMMIT_SOURCE_BINDING_COMMAND, - SOURCE_WORKTREE_CLEAN_COMMAND, -) -C3_RETAINED_MATRIX_COMMAND = "eng/tmux/run-matrix.sh --capability-cohort 0001 --evidence-dir docs/parity/evidence/0001 tests/LibTmux.IntegrationTests/LibTmux.IntegrationTests.csproj" -C3_VALIDATE_MATRIX_COMMAND = "uv run python eng/evidence/validate.py --phase matrix docs/parity/evidence/0001" -C3_PRECOMMIT_SOURCE_BINDING_COMMAND = "uv run python eng/evidence/verify_source_binding.py --evidence docs/parity/evidence/0001 --repository . --require-evaluated-commit HEAD --allow-dirty-root docs/parity/evidence/0001 --fingerprint-mode evaluated-commit-tree" -C3_RECONCILE_COMMAND = "uv run python eng/parity/reconcile_versions.py --evidence docs/parity/evidence/0001/results.ndjson --write" -C3_EVIDENCE_STAGE_COMMAND = ( - "git add -- docs/parity/evidence/0001 docs/parity/version-deltas.json" -) -C3_EVIDENCE_SCOPE_COMMAND = "uv run python eng/parity/verify_production_plan.py --phase component --component 3 --verify-retained-evidence-staged-scope docs/superpowers/plans/2026-08-09-libtmux-csharp-production.md" -C3_EVIDENCE_COMMIT_COMMAND = "printf '%s\\n' 'Evidence(docs[versioning]): Retain cohort 0001' '' 'why: Bind protocol evidence and reconciled observations to the Component 3 source commit.' '' what: '- Retain the exact protocol cohort.' '- Reconcile the five protocol observations.' | git commit --file -" -C3_POSTCOMMIT_SOURCE_BINDING_COMMAND = "uv run python eng/evidence/verify_source_binding.py --evidence docs/parity/evidence/0001 --repository . --require-evaluated-commit HEAD^ --require-descendant-root docs/parity/evidence/0001 --require-descendant-path docs/parity/version-deltas.json --fingerprint-mode evaluated-commit-tree" -C3_EVIDENCE_COMMANDS = ( - C3_RETAINED_MATRIX_COMMAND, - C3_VALIDATE_MATRIX_COMMAND, - C3_PRECOMMIT_SOURCE_BINDING_COMMAND, - C3_RECONCILE_COMMAND, - PERSISTED_RECONCILE_COMMAND, - C3_EVIDENCE_STAGE_COMMAND, - C3_EVIDENCE_SCOPE_COMMAND, - "git diff --cached --check", - C3_EVIDENCE_COMMIT_COMMAND, - C3_POSTCOMMIT_SOURCE_BINDING_COMMAND, - SOURCE_WORKTREE_CLEAN_COMMAND, -) -ROOT_QUALITY_COMMANDS = ( - "uv run ruff format --check .", - "uv run ruff check .", - "uv run mypy", - "uv run mypy eng/parity", - "uv run mypy eng/evidence", - "uv run pytest --doctest-modules", - "just build-docs", -) -PUBLICATION_PROVENANCE_COMMANDS = ( - "git branch --show-current", - "git rev-parse HEAD", - "git tag --points-at HEAD", - "git status --short --branch", -) -REQUIRED_GATE_COMMANDS = { - 3: ( - NON_RETAINED_MATRIX_COMMAND, - *C3_EVIDENCE_COMMANDS[:-1], - ), - 18: ( - *PACKAGE_COMMANDS, - PUBLIC_API_BUILD_COMMAND, - *PACKED_CONSUMER_COMMANDS, - *EXAMPLE_COMMANDS, - *AOT_COMMANDS, - *WORKFLOW_CONFIGURATION_COMMANDS, - NON_RETAINED_MATRIX_COMMAND, - *( - command - for command in FINAL_MATRIX_COMMANDS - if command - not in {"git diff --cached --check", SOURCE_WORKTREE_CLEAN_COMMAND} - ), - ), -} -REQUIRED_CLOSURE_COMMANDS = { - "Package": PACKAGE_COMMANDS, - "Public API": ( - PUBLIC_API_BUILD_COMMAND, - "uv run python eng/parity/verify_production_plan.py --phase closure docs/superpowers/plans/2026-08-09-libtmux-csharp-production.md", - ), - "Repository quality": ROOT_QUALITY_COMMANDS, - "Diff integrity": ("git diff --check",), - "Clean worktree": ('test -z "$(git status --porcelain)"',), - "Publication boundary": PUBLICATION_PROVENANCE_COMMANDS, - "Platform workflow configuration": (WORKFLOW_CONFIGURATION_COMMANDS[0],), - "macOS tmux workflow configuration": (WORKFLOW_CONFIGURATION_COMMANDS[1],), - "Packed consumers": PACKED_CONSUMER_COMMANDS, - "Executable examples": EXAMPLE_COMMANDS, - "NativeAOT": AOT_COMMANDS, - "Final matrix evidence": ( - VALIDATE_FINAL_MATRIX_COMMAND, - POSTCOMMIT_SOURCE_BINDING_COMMAND, - ), -} -REQUIRED_RED_TESTS = { - 1: ( - "EntityShellTests.Canonical_entities_are_public_sealed_partial_before_members_are_added", - "TmuxProcessTransportTests.Preserves_raw_bytes_and_projects_universal_newlines", - "TmuxProcessTransportTests.Treats_public_semicolon_as_data_and_internal_typed_separator_as_structure", - "TmuxProcessTransportTests.Defensively_copies_logical_arguments_and_uses_deep_record_equality", - "TmuxProcessTransportTests.Enforces_transport_limits_and_bounded_cleanup", - "TmuxProcessTransportTests.ThrowIfFailed_observes_projected_stderr_without_mutating_raw_bytes", - "TmuxProcessTransportTests.Injects_launcher_clock_and_limits_without_wall_clock_sleeps", - "TmuxProcessTransportTests.Missing_binary_throws_TmuxCommandNotFoundException_with_configured_path", - "TmuxProcessTransportTests.Pre_start_cancellation_throws_OperationCanceledException_with_caller_token_without_starting_process", - "TmuxProcessTransportTests.Post_start_cancellation_throws_TmuxOperationCanceledException_with_true_execution_risk_and_client_pid", - "TmuxProcessTransportTests.Cleanup_failure_throws_TmuxCleanupException_with_original_context", - "TmuxProcessTransportTests.Invalid_utf8_projects_each_bad_byte_as_lowercase_hex_escape", - "ProcessTransportTests.Pty_attached_client_scope_uses_real_pty", - "ProcessTransportTests.Test_child_preserves_concurrent_raw_stdout_and_stderr", - "ProcessTransportTests.Test_child_preserves_invalid_bytes", - "ProcessTransportTests.Test_child_projects_partial_final_output", - "ProcessTransportTests.Test_child_returns_nonzero_exit", - "ProcessTransportTests.Test_child_bounds_a_held_pump", - "ProcessTransportTests.Post_start_cancellation_reaps_client_but_leaves_descendant_alive", - "ProcessTransportTests.Test_child_reports_cleanup_faults", - "RequireRedTests.Accepts_only_nonzero_run_with_exact_failed_selected_test", - "RequireRedTests.Rejects_build_or_discovery_failure", - "RequireRedTests.Rejects_zero_tests_and_all_skipped_tests", - "RequireRedTests.Rejects_aborted_or_canceled_runs", - "RequireRedTests.Rejects_malformed_or_missing_trx", - "RequireRedTests.Rejects_unexpected_test_identity", - "RequireRedTests.Rejects_successful_test_run", - "RequireRedTests.Rejects_stale_exact_failed_trx_after_build_or_discovery_failure", - ), - 3: ( - "TmuxCapabilitiesTests.Comparisons_cover_equal_older_and_newer_versions", - "VersionParityTests.AttachmentAccounting", - "VersionParityTests.BreakPane37Workaround", - "VersionParityTests.ByteLengthFraming", - "VersionParityTests.CapturePane37Metadata", - "VersionParityTests.CapturePaneModeScreen", - "VersionParityTests.CapturePaneTrimTrailing", - "VersionParityTests.ChooseTreeSortTime", - "VersionParityTests.ClearHistoryHyperlinks", - "VersionParityTests.ClearPromptHistoryCommand", - "VersionParityTests.CommandPrompt37Behavior", - "VersionParityTests.CommandPromptBackground", - "VersionParityTests.CommandPromptLiteral", - "VersionParityTests.ConfirmBeforeAcceptance", - "VersionParityTests.ConfirmBeforeBackground", - "VersionParityTests.ControlNotifications", - "VersionParityTests.CopyModePageDown", - "VersionParityTests.DisplayMenuMouse", - "VersionParityTests.DisplayMenuStyles", - "VersionParityTests.DisplayMessageClient", - "VersionParityTests.DisplayMessageLiteral", - "VersionParityTests.DisplayMessageUpdatePane", - "VersionParityTests.DisplayPopup33Options", - "VersionParityTests.DisplayPopup36KeyPolicy", - "VersionParityTests.FormatFieldsAndOperators", - "VersionParityTests.HookScopePaneWindowSet", - "VersionParityTests.HookScopePaneWindowShow", - "VersionParityTests.KillSessionGroup", - "VersionParityTests.ListKeysFormat", - "VersionParityTests.NewPaneCommand", - "VersionParityTests.OptionDollarDoubleEscape", - "VersionParityTests.PasteBufferNoVis", - "VersionParityTests.RefreshClientClipboardQuery", - "VersionParityTests.RunShellArguments", - "VersionParityTests.RunShellShowStderr", - "VersionParityTests.RunShellWorkingDirectory", - "VersionParityTests.SemicolonGrouping", - "VersionParityTests.SendKeysClientKeys", - "VersionParityTests.ServerAccessCommand", - "VersionParityTests.ShowPromptHistoryCommand", - "VersionParityTests.SplitWindowAppearance", - "VersionParityTests.SplitWindowEmpty", - "VersionParityTests.CommandFlags", - ), - 4: ( - "MaterializationTests.Format_separator_exclusion_uses_single_expansion_decode", - "MaterializationTests.Materializer_uses_server_context_and_returns_typed_raw_fields", - "MaterializationTests.Generated_projection_round_trips_multiple_hostile_rows", - "MaterializationTests.Version_gates_emit_only_supported_fields", - "MaterializationTests.Window_and_pane_lookup_use_tmux_canonical_session", - "MaterializationTests.Missing_target_is_distinct_from_unreachable_server", - ), - 7: ( - "SnapshotCollectionTests.List_accessors_are_lenient_on_tmux_errors", - "SnapshotCollectionTests.Explicit_liveness_checks_preserve_failures", - ), - 8: ( - "QuerySemanticsTests.And_and_or_nodes_use_ordered_structural_equality_and_hashing", - ), - 10: ( - "ServerSessionLifecycleTests.New_session_flags_emit_exact_argv", - "ServerSessionLifecycleTests.Session_selection_and_attachment_flags_emit_exact_argv", - "ServerSessionLifecycleTests.Refresh_after_external_selection_captures_active_window_and_pane_relations", - ), - 11: ( - "WindowTopologyTests.New_split_move_link_swap_resize_rotate_and_respawn_flags_emit_exact_argv", - "WindowTopologyTests.Killed_window_is_a_raising_tombstone", - ), - 12: ( - "PaneOperationsTests.Capture_flags_emit_exact_argv_and_preserve_positions", - "PaneOperationsTests.Send_keys_flags_distinguish_literal_and_key_modes", - "PaneOperationsTests.Select_direction_last_keep_zoom_mark_and_input_flags_emit_exact_argv", - "PaneOperationsTests.New_split_move_join_paste_display_clear_and_break_flags_emit_exact_argv", - "PaneOperationsTests.Copy_find_pipe_swap_resize_and_respawn_flags_emit_exact_argv", - "PaneOperationsTests.Popup_menu_and_display_flags_emit_exact_argv", - ), - 13: ( - "ClientAdministrationTests.Attach_switch_detach_lock_and_suspend_flags_emit_exact_argv", - ), - 14: ( - "TmuxOptionsTests.Global_inherited_and_unset_scopes_emit_exact_flags", - "TmuxOptionsTests.Sparse_arrays_and_raw_values_round_trip", - "TmuxOptionsTests.Invalid_ambiguous_and_unknown_options_map_to_typed_failures", - "TmuxOptionsTests.Window_option_aliases_resolve_to_the_window_scope", - ), - 15: ( - "HookOperationsTests.Set_show_unset_and_run_flags_emit_exact_argv", - "EnvironmentOperationsTests.Set_show_unset_and_remove_flags_emit_exact_argv", - ), - 16: ( - "ServerUtilitiesTests.Bind_unbind_and_list_key_flags_emit_exact_argv", - "ServerUtilitiesTests.Prompt_menu_confirm_and_display_flags_emit_exact_argv", - "ServerUtilitiesTests.Buffer_flags_emit_exact_argv", - "ServerUtilitiesTests.Shell_if_source_wait_and_access_flags_emit_exact_argv", - ), - 17: ( - "ExceptionContractTests.Command_specific_errors_preserve_typed_context", - "ExceptionContractTests.Cancellation_and_cleanup_failures_preserve_distinct_state", - "ExceptionContractTests.Excluded_python_exceptions_have_exact_replacements", - ), - 18: ( - "PackageClosureTests.Packed_metadata_dependencies_and_assets_are_exact", - "PackageClosureTests.SourceLink_repository_revision_and_privacy_are_exact", - "PackageClosureTests.Packed_consumers_execute_on_both_frameworks", - "PackageClosureTests.Documented_examples_execute_against_real_tmux", - "PackageClosureTests.Trimmed_native_aot_executes_on_both_frameworks", - "WorkflowContractTests.Platform_and_macos_tmux_configurations_are_exact", - "PublicApiContractTests.Shipped_baselines_match_both_packages", - "SourceBindingTests.Final_matrix_matches_the_closing_source_tree", - ), -} -RED_CASES: dict[int, tuple[str, str]] = { - 1: ( - "tests/LibTmux.UnitTests/LibTmux.UnitTests.csproj", - "TmuxProcessTransportTests.Preserves_raw_bytes_and_projects_universal_newlines", - ), - 2: ( - "tests/LibTmux.IntegrationTests/LibTmux.IntegrationTests.csproj", - "ServerGenerationTests.Stale_entity_cannot_target_a_reused_id", - ), - 3: ( - "tests/LibTmux.UnitTests/LibTmux.UnitTests.csproj", - "TmuxCapabilitiesTests.Comparisons_cover_equal_older_and_newer_versions", - ), - 4: ( - "tests/LibTmux.IntegrationTests/LibTmux.IntegrationTests.csproj", - "MaterializationTests.Materializes_embedded_newlines_and_invalid_utf8", - ), - 5: ( - "tests/LibTmux.IntegrationTests/LibTmux.IntegrationTests.csproj", - "HierarchySnapshotTests.Linked_windows_preserve_edges_without_losing_entity_identity", - ), - 6: ( - "tests/LibTmux.IntegrationTests/LibTmux.IntegrationTests.csproj", - "ChildEnvironmentTests.Starting_server_removes_inherited_tmux_without_mutating_process_environment", - ), - 7: ( - "tests/LibTmux.UnitTests/LibTmux.UnitTests.csproj", - "SnapshotCollectionTests.Enumeration_is_local_and_uses_BCL_cardinality", - ), - 8: ( - "tests/LibTmux.UnitTests/LibTmux.UnitTests.csproj", - "QuerySemanticsTests.Matching_translates_and_interprets_the_canonical_AST", - ), - 9: ( - "tests/LibTmux.UnitTests/LibTmux.UnitTests.csproj", - "QueryJsonTests.Round_trips_every_version_one_golden_byte_for_byte", - ), - 10: ( - "tests/LibTmux.IntegrationTests/LibTmux.IntegrationTests.csproj", - "ServerSessionLifecycleTests.Refresh_returns_replacement_and_owned_scope_cleans_up", - ), - 11: ( - "tests/LibTmux.IntegrationTests/LibTmux.IntegrationTests.csproj", - "WindowTopologyTests.Linked_window_moves_preserve_session_scoped_indexes", - ), - 12: ( - "tests/LibTmux.IntegrationTests/LibTmux.IntegrationTests.csproj", - "PaneOperationsTests.Send_keys_and_capture_preserve_literal_payloads", - ), - 13: ( - "tests/LibTmux.IntegrationTests/LibTmux.IntegrationTests.csproj", - "ClientAdministrationTests.Detached_client_resolves_nullable_attachment", - ), - 14: ( - "tests/LibTmux.IntegrationTests/LibTmux.IntegrationTests.csproj", - "TmuxOptionsTests.Preserves_global_inherited_sparse_and_raw_values", - ), - 15: ( - "tests/LibTmux.IntegrationTests/LibTmux.IntegrationTests.csproj", - "HookOperationsTests.Server_and_session_hooks_round_trip_without_global_state", - ), - 16: ( - "tests/LibTmux.IntegrationTests/LibTmux.IntegrationTests.csproj", - "ServerUtilitiesTests.Keys_prompts_menus_buffers_and_shell_commands_use_exact_argv", - ), - 17: ( - "tests/LibTmux.IntegrationTests/LibTmux.IntegrationTests.csproj", - "StructuredLoggingTests.Records_stable_scalar_context_without_payload_leakage", - ), - 18: ( - "tests/LibTmux.IntegrationTests/LibTmux.IntegrationTests.csproj", - "TestingHelpersTests.Temporary_hierarchy_is_xunit_independent_and_cleans_up", - ), -} -RED_EVIDENCE = { - component: f"artifacts/tdd/component-{component:02d}.trx" - for component in COMPONENT_IDS -} -RED_TEST_NAMESPACES = { - 1: "LibTmux.UnitTests.Transport", - 2: "LibTmux.IntegrationTests.Connection", - 3: "LibTmux.UnitTests.Versioning", - 4: "LibTmux.IntegrationTests.Materialization", - 5: "LibTmux.IntegrationTests.Snapshots", - 6: "LibTmux.IntegrationTests.Environment", - 7: "LibTmux.UnitTests.Collections", - 8: "LibTmux.UnitTests.Query", - 9: "LibTmux.UnitTests.Query", - 10: "LibTmux.IntegrationTests.Hierarchy", - 11: "LibTmux.IntegrationTests.Hierarchy", - 12: "LibTmux.IntegrationTests.Hierarchy", - 13: "LibTmux.IntegrationTests.Clients", - 14: "LibTmux.IntegrationTests.Options", - 15: "LibTmux.IntegrationTests.Hooks", - 16: "LibTmux.IntegrationTests.Utilities", - 17: "LibTmux.IntegrationTests.Diagnostics", - 18: "LibTmux.IntegrationTests.Testing", -} -RED_TEST_IDENTITIES = { - component: f"{RED_TEST_NAMESPACES[component]}.{test_name}" - for component, (_, test_name) in RED_CASES.items() -} -RED_COMMANDS = { - component: ( - "uv run python eng/parity/require_red.py " - f"--project {project} --configuration Release --framework net8.0 " - f"--no-restore --test {RED_TEST_IDENTITIES[component]} " - f"--evidence {RED_EVIDENCE[component]}" - ) - for component, (project, _) in RED_CASES.items() -} - - -def parity_test_path(component: int) -> str: - """Return the component's frozen parity-evidence path. - - Examples - -------- - >>> parity_test_path(3) - 'tests/LibTmux.IntegrationTests/Parity/Component03ParityTests.cs' - """ - return ( - "tests/LibTmux.IntegrationTests/Parity/" - f"Component{component:02d}ParityTests.cs" - ) - - -def validator_path() -> pathlib.Path: - """Return the production-plan validator path. - - Examples - -------- - >>> validator_path().name - 'verify_production_plan.py' - """ - return pathlib.Path(__file__).parents[1] / "verify_production_plan.py" - - -def validator() -> t.Callable[..., list[str]]: - """Load the production-plan validator without importing a package.""" - namespace = runpy.run_path(str(validator_path())) - return t.cast(t.Callable[..., list[str]], namespace["validate"]) - - -def production_plan_path() -> pathlib.Path: - """Return the production plan document, or skip when it is not here. - - The plan lived beside this project in the monorepo it was imported out of - and is not part of the library, so a checkout that does not have it fails - these tests for a reason that says nothing about the code. - - Returns - ------- - pathlib.Path - The plan document to parse. - """ - configured = os.environ.get("LIBTMUX_PRODUCTION_PLAN") - plan_path = ( - pathlib.Path(configured).expanduser() - if configured - else pathlib.Path(__file__).parents[3] - / "docs/superpowers/plans/2026-08-09-libtmux-csharp-production.md" - ) - if not plan_path.is_file(): - pytest.skip( - f"{plan_path} is not here. Point LIBTMUX_PRODUCTION_PLAN at the " - "production plan to run the checks that read it.", - ) - return plan_path - - -def validator_namespace() -> dict[str, t.Any]: - """Load every production-plan validator entry point. - - Examples - -------- - >>> "validate" in validator_namespace() - True - """ - return runpy.run_path(str(validator_path())) - - -def ledger() -> dict[str, t.Any]: - """Return a minimal ledger with one row per production component. - - Examples - -------- - >>> len(ledger()["rows"]) - 18 - """ - return { - "rows": [ - { - "pythonSymbolId": f"libtmux.component{component}:symbol", - "componentId": component, - "testPath": parity_test_path(component), - "implementationStatus": "not_started", - "evidenceStatus": "none", - } - for component in COMPONENT_IDS - ] - } - - -def completed_ledger(component: int) -> dict[str, t.Any]: - """Return ledger state after one component gate. - - Examples - -------- - >>> completed_ledger(2)["rows"][0]["implementationStatus"] - 'implemented' - >>> completed_ledger(2)["rows"][2]["implementationStatus"] - 'not_started' - """ - document = copy.deepcopy(ledger()) - for row in document["rows"]: - if row["componentId"] <= component: - row["implementationStatus"] = "implemented" - row["evidenceStatus"] = "verified" - return document - - -def public_api() -> dict[str, t.Any]: - """Return the public types with frozen production-file bindings. - - Examples - -------- - >>> len(public_api()["types"]) - 169 - """ - return { - "types": [ - {"id": type_id} - for component in COMPONENT_IDS - for type_id in COMPONENT_API_TYPES[component] - if type_id != "not applicable" - ], - "members": [{"id": member_id} for member_id in PUBLIC_API_MEMBER_FILE_BINDINGS], - } - - -def atomic_commit_command(component: int) -> str: - """Return the exact one-commit checkpoint for a component. - - Examples - -------- - >>> atomic_commit_command(1).endswith("| git commit --file -") - True - """ - lines = ( - f"Component{component}(feat): Implement slice", - "", - "why: Preserve approved behavior in one reviewable component.", - "", - "what:", - "- Implement the owned production and test files.", - "- Verify every owned parity row.", - ) - return ( - "printf '%s\\n' " - + " ".join(shlex.quote(line) for line in lines) - + " | git commit --file -" - ) - - -def component_section(component: int) -> str: - """Return one structurally complete component task. - - Examples - -------- - >>> "## Component 1:" in component_section(1) - True - """ - row_id = f"libtmux.component{component}:symbol" - lanes = "\n".join(f"- `{lane}`" for lane in TMUX_LANES) - files = list(COMPONENT_FILES[component]) - file_lines = "\n".join(f"- `{path}`" for path in files) - api_lines = "\n".join( - f"- {'``' if '`' in type_id else '`'}{type_id}{'``' if '`' in type_id else '`'}" - for type_id in COMPONENT_API_TYPES[component] - ) - dependencies = "\n".join( - f"- `{dependency}`" - for dependency in COMPONENT_DEPENDENCIES.get(component, ("none",)) - ) - shared_lines = "\n".join( - f"- `{path}`" - for path in COMPONENT_SHARED_FILES.get( - component, - ("docs/parity/parity-ledger.json",), - ) - ) - wiring = "\n".join( - f"- `{entry}`" for entry in PROJECT_WIRING.get(component, ("not applicable",)) - ) - transport_contract = "" - if component == 1: - transport_contract = "\n\n### Transport contract\n\n" + "\n".join( - f"- `{entry}`" for entry in COMPONENT_ONE_TRANSPORT_CONTRACT - ) - red_runner_contract = "" - if component == 1: - red_runner_contract = "\n\n### RED runner contract\n\n" + "\n".join( - f"- `{entry}`" for entry in RED_RUNNER_CONTRACT - ) - red_evidence_freshness = "" - if component == 1: - red_evidence_freshness = "\n\n### RED evidence freshness\n\n" + "\n".join( - f"- `{entry}`" for entry in RED_EVIDENCE_FRESHNESS_CONTRACT - ) - tmux_37_transition_proof = "" - if component == 3: - tmux_37_transition_proof = "\n\n### tmux 3.7 transition proof\n\n" + "\n".join( - f"- `{entry}`" for entry in TMUX_37_TRANSITION_PROOF_CONTRACT - ) - version_policy_proofs = "" - if component in VERSION_POLICY_PROOFS_BY_COMPONENT: - version_policy_proofs = "\n\n### Version policy proofs\n\n" + "\n".join( - f"- `{entry}`" for entry in VERSION_POLICY_PROOFS_BY_COMPONENT[component] - ) - materialization_contract = "" - if component == 4: - materialization_contract = "\n\n### Materialization contract\n\n" + "\n".join( - f"- `{entry}`" for entry in C4_MATERIALIZATION_CONTRACT - ) - failure_corpus_contract = "" - if component == 1: - failure_corpus_contract = "\n\n### Failure corpus contract\n\n" + "\n".join( - f"- `{entry}`" for entry in C1_FAILURE_CORPUS_CONTRACT - ) - red_bootstrap = "" - if component in RED_BOOTSTRAP: - red_bootstrap = "\n\n### RED bootstrap\n\n" + "\n".join( - f"- `{entry}`" for entry in RED_BOOTSTRAP[component] - ) - red_tests = list( - dict.fromkeys((RED_CASES[component][1], *REQUIRED_RED_TESTS.get(component, ()))) - ) - red_lines = "\n".join( - f"- `{test}` must fail before production code is added." for test in red_tests - ) - red_command = RED_COMMANDS[component] - red_evidence = RED_EVIDENCE[component] - unit_project = "tests/LibTmux.UnitTests/LibTmux.UnitTests.csproj" - integration_project = ( - "tests/LibTmux.IntegrationTests/LibTmux.IntegrationTests.csproj" - ) - common_commands = [ - "dotnet format --verify-no-changes --no-restore", - PUBLIC_API_BUILD_COMMAND, - ( - f"dotnet test --project {unit_project} " - "--configuration Release --framework net8.0 --no-build" - ), - ( - f"dotnet test --project {unit_project} " - "--configuration Release --framework net10.0 --no-build" - ), - ] - if component == 3: - behavioral_commands = [ - "dotnet restore LibTmux.slnx --locked-mode", - *common_commands, - NON_RETAINED_MATRIX_COMMAND, - ] - elif component == 18: - behavioral_commands = [ - *PACKAGE_COMMANDS, - *common_commands, - *PACKED_CONSUMER_COMMANDS, - *EXAMPLE_COMMANDS, - *AOT_COMMANDS, - *WORKFLOW_CONFIGURATION_COMMANDS, - NON_RETAINED_MATRIX_COMMAND, - ] - else: - behavioral_commands = [ - "dotnet restore LibTmux.slnx --locked-mode", - *common_commands, - f"eng/tmux/run-matrix.sh {integration_project}", - ] - phase_command = ( - "uv run python eng/parity/verify_production_plan.py " - f"--phase component --component {component} " - "docs/superpowers/plans/2026-08-09-libtmux-csharp-production.md" - ) - stage_command = ( - "uv run python eng/parity/verify_production_plan.py " - f"--phase component --component {component} --print-stage-paths " - "docs/superpowers/plans/2026-08-09-libtmux-csharp-production.md " - "| xargs git add --" - ) - verify_stage_command = ( - "uv run python eng/parity/verify_production_plan.py " - f"--phase component --component {component} --verify-staged-scope " - "docs/superpowers/plans/2026-08-09-libtmux-csharp-production.md" - ) - gate_commands = [ - *behavioral_commands, - phase_command, - "git diff --check", - stage_command, - verify_stage_command, - "git diff --cached --name-only", - "git diff --cached --check", - atomic_commit_command(component), - 'test -z "$(git diff --cached --name-only)"', - ] - if component == 3: - gate_commands.extend((SOURCE_WORKTREE_CLEAN_COMMAND, *C3_EVIDENCE_COMMANDS)) - if component == 18: - gate_commands.extend((SOURCE_WORKTREE_CLEAN_COMMAND, *FINAL_MATRIX_COMMANDS)) - gate_lines = "\n".join(f"- `{command}`" for command in gate_commands) - return f"""## Component {component}: Production slice {component} - -### Files - -{file_lines} - -### API owners - -{api_lines} - -### Shared files - -{shared_lines} - -### Depends on - -{dependencies} - -### Project wiring - -{wiring} -{transport_contract} -{red_runner_contract} -{red_evidence_freshness} -{tmux_37_transition_proof} -{version_policy_proofs} -{materialization_contract} -{failure_corpus_contract} -{red_bootstrap} - -### Ledger rows - -- `{row_id}` - -### Red behavioral test - -{red_lines} - -### RED command - -- `{red_command}` - -### RED evidence - -- `{red_evidence}` - -### Frameworks - -- `net8.0` -- `net10.0` - -### tmux lanes - -{lanes} - -### Ledger updates - -- Set `implementationStatus=implemented` for every owned row. -- Set `evidenceStatus=verified` after behavioral commands pass and before the phase-aware validator runs. - -### Atomic commit - -`Component{component}(feat): Implement slice` - -why: Preserve approved behavior in one reviewable component. - -what: -- Implement the owned production and test files. -- Verify every owned parity row. - -### Full gate - -{gate_lines} -""" - - -def complete_plan() -> str: - """Return a minimal structurally valid 18-component plan. - - Examples - -------- - >>> complete_plan().count("## Component ") - 18 - """ - closure = "\n\n".join( - "\n".join( - ( - f"### {gate} gate\n", - f"- {CLOSURE_DETAILS[gate]}", - *( - f"- `{command}`" - for command in REQUIRED_CLOSURE_COMMANDS.get(gate, ()) - ), - ) - ) - for gate in CLOSURE_GATES - ) - components = "\n".join(component_section(component) for component in COMPONENT_IDS) - return f"""# LibTmux C# production implementation - -{components} -## Closure - -{closure} -""" - - -def test_minimal_plan_reports_missing_components_and_rows() -> None: - """Reject a plan that does not own the complete implementation.""" - violations = validator()( - "# LibTmux C# production implementation\n", - {"rows": [{"pythonSymbolId": "libtmux:Server"}]}, - ) - assert "missing component IDs" in violations - assert "missing ledger row IDs" in violations - - -def test_complete_plan_passes_structural_validation() -> None: - """Accept complete component ownership and closure gates.""" - assert validator()(complete_plan(), ledger()) == [] - - -@pytest.mark.parametrize("component", [1, 9, 18]) -def test_component_ids_must_appear_exactly_once(component: int) -> None: - """Reject missing and duplicate component task ownership.""" - section = component_section(component) - missing = complete_plan().replace(section, "", 1) - duplicate = complete_plan().replace(section, section + section, 1) - - assert "missing component IDs" in validator()(missing, ledger()) - assert "duplicate component IDs" in validator()(duplicate, ledger()) - - -def test_unknown_component_ids_are_rejected() -> None: - """Reject tasks outside the approved 18-component design.""" - unknown = component_section(18).replace( - "## Component 18:", - "## Component 19:", - 1, - ) - plan = complete_plan().replace("## Closure", unknown + "## Closure", 1) - assert "unknown component IDs" in validator()(plan, ledger()) - - -def test_ledger_rows_must_be_owned_exactly_once() -> None: - """Reject missing, duplicate, and unknown ledger-row ownership.""" - plan = complete_plan() - row = "- `libtmux.component8:symbol`\n" - missing = plan.replace(row, "", 1) - duplicate = plan.replace(row, row + row, 1) - unknown = plan.replace(row, row + "- `libtmux:unknown`\n", 1) - - assert "missing ledger row IDs" in validator()(missing, ledger()) - assert "duplicate ledger row IDs" in validator()(duplicate, ledger()) - assert "unknown ledger row IDs" in validator()(unknown, ledger()) - - -def test_ledger_rows_must_match_their_frozen_component() -> None: - """Reject ownership that conflicts with the ledger component ID.""" - plan = ( - complete_plan() - .replace( - "- `libtmux.component1:symbol`", - "- `temporary:row`", - 1, - ) - .replace( - "- `libtmux.component2:symbol`", - "- `libtmux.component1:symbol`", - 1, - ) - .replace( - "- `temporary:row`", - "- `libtmux.component2:symbol`", - 1, - ) - ) - assert "ledger row assigned to wrong component" in validator()(plan, ledger()) - - -@pytest.mark.parametrize(("field", "expected"), FORMAT_SEPARATOR_CONTRACT.items()) -def test_format_separator_keeps_exact_exclusion_contract( - field: str, - expected: t.Any, -) -> None: - """Keep the delimiter tombstone bound to ADR 0001 byte framing.""" - validate_contract = t.cast( - t.Callable[[dict[str, t.Any]], list[str]], - validator_namespace()["validate_format_separator_contract"], - ) - row = { - "pythonSymbolId": "libtmux.formats:FORMAT_SEPARATOR", - **FORMAT_SEPARATOR_CONTRACT, - } - assert validate_contract({"rows": [row]}) == [] - - invalid = copy.deepcopy(row) - invalid[field] = "drifted" if expected is not None else "must-be-null" - assert validate_contract({"rows": [invalid]}) == [ - "FORMAT_SEPARATOR exclusion contract drifted" - ] - - -@pytest.mark.parametrize( - "heading", - [ - "Files", - "API owners", - "Shared files", - "Depends on", - "Project wiring", - "Ledger rows", - "Red behavioral test", - "RED command", - "RED evidence", - "Frameworks", - "tmux lanes", - "Ledger updates", - "Atomic commit", - "Full gate", - ], -) -def test_every_component_requires_each_structural_field(heading: str) -> None: - """Reject tasks that omit a required implementation field.""" - plan = complete_plan().replace(f"### {heading}\n", f"### Missing {heading}\n", 1) - assert f"component 1 missing {heading}" in validator()(plan, ledger()) - - -def test_files_must_be_exact_repository_paths() -> None: - """Reject vague directories and wildcard task ownership.""" - plan = complete_plan().replace( - "- `src/LibTmux/Transport/TmuxCommandRequest.cs`", - "- `src/LibTmux/**`", - 1, - ) - assert "component 1 has non-exact Files" in validator()(plan, ledger()) - - -@pytest.mark.parametrize( - ("valid_line", "invalid_line", "message"), - [ - ( - "- `src/LibTmux/Transport/TmuxCommandRequest.cs`", - "src/LibTmux/Transport/TmuxCommandRequest.cs", - "component 1 has non-exact Files", - ), - ( - "- `libtmux.component1:symbol`", - "libtmux.component1:symbol", - "component 1 has invalid Ledger rows", - ), - ("- `net8.0`", "net8.0", "component 1 has invalid Frameworks"), - ("- `3.2a`", "3.2a", "component 1 has invalid tmux lanes"), - ( - "- `dotnet format --verify-no-changes --no-restore`", - "dotnet format --verify-no-changes --no-restore", - "component 1 has invalid Full gate", - ), - ], -) -def test_structured_fields_reject_unlisted_prose( - valid_line: str, - invalid_line: str, - message: str, -) -> None: - """Reject field content outside the required Markdown list shape.""" - plan = complete_plan().replace(valid_line, invalid_line, 1) - assert message in validator()(plan, ledger()) - - -def test_frameworks_require_both_supported_targets_only() -> None: - """Require the complete net8.0 and net10.0 target pair.""" - plan = complete_plan().replace("- `net10.0`\n", "", 1) - assert "component 1 has invalid Frameworks" in validator()(plan, ledger()) - - -def test_tmux_lanes_must_be_explicit() -> None: - """Reject vague tmux matrix declarations.""" - plan = complete_plan().replace("- `3.2a`\n", "- `supported versions`\n", 1) - assert "component 1 has invalid tmux lanes" in validator()(plan, ledger()) - - -def test_ledger_updates_require_implementation_and_evidence_states() -> None: - """Require both production ledger transitions in every component.""" - plan = complete_plan().replace( - "- Set `evidenceStatus=verified` after behavioral commands pass and before the phase-aware validator runs.\n", - "", - 1, - ) - assert "component 1 has invalid Ledger updates" in validator()(plan, ledger()) - - -def test_ledger_updates_must_precede_the_phase_validator() -> None: - """Reject a status transition that makes the component gate circular.""" - plan = complete_plan().replace( - "before the phase-aware validator runs", - "after the full gate passes", - 1, - ) - assert "component 1 has invalid Ledger updates" in validator()(plan, ledger()) - - -def test_atomic_commit_is_exactly_one_subject() -> None: - """Reject multiple commits inside one atomic component task.""" - subject = "`Component1(feat): Implement slice`\n" - plan = complete_plan().replace(subject, subject + "`Second commit`\n", 1) - assert "component 1 has invalid Atomic commit subject" in validator()( - plan, ledger() - ) - - -def test_full_gate_must_cover_both_frameworks_and_tmux() -> None: - """Reject a component gate that omits its declared matrix.""" - command = ( - "- `eng/tmux/run-matrix.sh " - "tests/LibTmux.IntegrationTests/LibTmux.IntegrationTests.csproj`\n" - ) - plan = complete_plan().replace(command, "", 1) - assert "component 1 has invalid Full gate" in validator()(plan, ledger()) - - -@pytest.mark.parametrize("path", BOOTSTRAP_FILES) -def test_component_one_must_bootstrap_the_build_graph(path: str) -> None: - """Reject a first slice that uses projects before creating them.""" - plan = complete_plan().replace(f"- `{path}`\n", "", 1) - assert "component 1 missing build bootstrap Files" in validator()(plan, ledger()) - - -def test_component_one_owns_the_transport_limits_seam() -> None: - """Reject a transport slice without its bounded-resource seam.""" - path = "src/LibTmux/Transport/TmuxTransportLimits.cs" - plan = complete_plan().replace(f"- `{path}`\n", "", 1) - assert "component 1 has invalid Files inventory" in validator()(plan, ledger()) - - -@pytest.mark.parametrize("contract", COMPONENT_ONE_TRANSPORT_CONTRACT) -def test_component_one_transport_contract_is_frozen(contract: str) -> None: - """Reject ambiguity in the approved C1 command and transport semantics.""" - plan = complete_plan().replace(f"- `{contract}`\n", "", 1) - assert "component 1 missing frozen transport contract" in validator()( - plan, ledger() - ) - - -@pytest.mark.parametrize( - ("component", "entry"), - [ - (component, entry) - for component, entries in RED_BOOTSTRAP.items() - for entry in entries - ], -) -def test_changed_graphs_are_generated_and_locked_before_red( - component: int, - entry: str, -) -> None: - """Reject missing stubs or restore steps before C1, C8, and C9 RED.""" - section = component_section(component) - invalid = section.replace(f"- `{entry}`\n", "", 1) - plan = complete_plan().replace(section, invalid, 1) - assert f"component {component} has invalid RED bootstrap" in validator()( - plan, ledger() - ) - - -@pytest.mark.parametrize("component", (1, 8, 9)) -def test_pre_red_restore_pair_is_immediate(component: int) -> None: - """Reject work inserted between unlocked generation and locked consumption.""" - section = component_section(component) - locked = f"- `{SOLUTION_RESTORE_PAIR[1]}`\n" - invalid = section.replace(locked, "- `dotnet --info`\n" + locked, 1) - plan = complete_plan().replace(section, invalid, 1) - assert f"component {component} has invalid RED bootstrap" in validator()( - plan, ledger() - ) - - -@pytest.mark.parametrize("component", (1, 8, 9)) -def test_full_gate_consumes_locks_without_regeneration(component: int) -> None: - """Reject unlocked restore after the retained RED checkpoint.""" - section = component_section(component) - prefix, full_gate = section.split("### Full gate\n\n", 1) - full_unlocked = "dotnet restore LibTmux.slnx" - full_locked = f"{full_unlocked} --locked-mode" - locked = f"- `{full_locked}`\n" - invalid_gate = full_gate.replace( - locked, - f"- `{full_unlocked}`\n" + locked, - 1, - ) - invalid = prefix + "### Full gate\n\n" + invalid_gate - plan = complete_plan().replace(section, invalid, 1) - assert f"component {component} regenerates locks during Full gate" in validator()( - plan, ledger() - ) - - -@pytest.mark.parametrize("contract", RED_RUNNER_CONTRACT) -def test_red_runner_semantics_are_frozen(contract: str) -> None: - """Reject a RED helper that can confuse infrastructure failure with behavior.""" - plan = complete_plan().replace(f"- `{contract}`\n", "", 1) - assert "component 1 missing frozen RED runner contract" in validator()( - plan, ledger() - ) - - -@pytest.mark.parametrize("contract", RED_EVIDENCE_FRESHNESS_CONTRACT) -def test_red_runner_requires_fresh_evidence(contract: str) -> None: - """Reject stale failed TRX reuse after build or discovery failure.""" - plan = complete_plan().replace(f"- `{contract}`\n", "", 1) - assert "component 1 missing fresh RED evidence contract" in validator()( - plan, ledger() - ) - - -@pytest.mark.parametrize("contract", C1_FAILURE_CORPUS_CONTRACT) -def test_component_one_failure_corpus_is_frozen(contract: str) -> None: - """Reject ambiguous exception, cancellation, cleanup, or byte projection behavior.""" - plan = complete_plan().replace(f"- `{contract}`\n", "", 1) - assert "component 1 missing frozen failure corpus" in validator()(plan, ledger()) - - -def test_component_one_red_command_executes_transport_behavior() -> None: - """Keep the retained RED receipt bound to behavior, not structure alone.""" - structural = ( - "EntityShellTests." - "Canonical_entities_are_public_sealed_partial_before_members_are_added" - ) - plan = complete_plan().replace( - RED_TEST_IDENTITIES[1], - f"LibTmux.UnitTests.Entities.{structural}", - 1, - ) - assert "component 1 missing executable RED command" in validator()(plan, ledger()) - - -def test_all_mtp_commands_require_the_project_option() -> None: - """Reject positional projects under Microsoft Testing Platform.""" - plan = complete_plan().replace( - "dotnet test --project tests/LibTmux.UnitTests", - "dotnet test tests/LibTmux.UnitTests", - 1, - ) - assert "component 1 has positional dotnet test project" in validator()( - plan, ledger() - ) - - -def test_component_dependencies_are_exact_and_acyclic() -> None: - """Reject a slice that consumes a foundation it does not declare.""" - section = component_section(4) - invalid = section.replace("- `component 3`\n", "", 1) - plan = complete_plan().replace(section, invalid, 1) - assert "component 4 has invalid Depends on" in validator()(plan, ledger()) - - -def test_files_have_one_component_owner() -> None: - """Reject source ownership shared by two atomic components.""" - section = component_section(2) - duplicate = "- `src/LibTmux/Transport/TmuxCommandRequest.cs`\n" - invalid = section.replace("### Files\n\n", f"### Files\n\n{duplicate}", 1) - plan = complete_plan().replace(section, invalid, 1) - assert "Files path has multiple component owners" in validator()(plan, ledger()) - - -@pytest.mark.parametrize("component", COMPONENT_IDS) -def test_shared_file_allow_lists_are_exact(component: int) -> None: - """Reject undeclared staging paths hidden behind shared ownership.""" - section = component_section(component) - first = COMPONENT_SHARED_FILES[component][0] - invalid = section.replace( - f"- `{first}`\n", - f"- `{first}`\n- `unowned-{component}.txt`\n", - 1, - ) - plan = complete_plan().replace(section, invalid, 1) - assert f"component {component} has invalid Shared files" in validator()( - plan, ledger() - ) - - -@pytest.mark.parametrize("path", FOUNDATIONAL_FILES) -def test_component_one_owns_transport_exceptions_and_raw_harness(path: str) -> None: - """Reject consumers scheduled before their concrete foundation.""" - plan = complete_plan().replace(f"- `{path}`\n", "", 1) - assert "component 1 missing foundational Files" in validator()(plan, ledger()) - - -@pytest.mark.parametrize( - ("component", "entry"), - [ - (component, entry) - for component, entries in PROJECT_WIRING.items() - for entry in entries - ], -) -def test_project_wiring_is_explicit(component: int, entry: str) -> None: - """Reject a project that is not connected to its solution or dependency.""" - section = component_section(component) - invalid = section.replace(f"- `{entry}`\n", "", 1) - plan = complete_plan().replace(section, invalid, 1) - assert f"component {component} has invalid Project wiring" in validator()( - plan, ledger() - ) - - -@pytest.mark.parametrize( - ("component", "path"), - [ - (component, path) - for component, paths in REQUIRED_PROJECT_FILES.items() - for path in paths - ], -) -def test_project_sources_locks_baselines_and_workflows_are_owned( - component: int, - path: str, -) -> None: - """Reject generated, executable, or workflow projects with missing files.""" - section = component_section(component) - invalid = section.replace(f"- `{path}`\n", "", 1) - plan = complete_plan().replace(section, invalid, 1) - assert f"component {component} missing required project Files" in validator()( - plan, ledger() - ) - - -@pytest.mark.parametrize( - ("component", "command"), - [ - (component, command) - for component, commands in REQUIRED_GATE_COMMANDS.items() - for command in commands - ], -) -def test_version_and_closure_commands_are_exact(component: int, command: str) -> None: - """Reject a gate that cannot emit or execute its promised artifact.""" - section = component_section(component) - invalid = section.replace(f"- `{command}`\n", "", 1) - plan = complete_plan().replace(section, invalid, 1) - assert f"component {component} missing required Full gate commands" in validator()( - plan, ledger() - ) - - -def test_stage_allow_list_precedes_cached_scope_checks() -> None: - """Reject inspecting staged scope before staging declared paths.""" - section = component_section(1) - stage = next(line for line in section.splitlines() if "xargs git add --" in line) - inspect = "- `git diff --cached --name-only`" - invalid = section.replace(stage, "stage-marker", 1) - invalid = invalid.replace(inspect, stage, 1).replace("stage-marker", inspect, 1) - plan = complete_plan().replace(section, invalid, 1) - assert "component 1 stages after cached scope inspection" in validator()( - plan, ledger() - ) - - -def test_component_gates_cannot_call_approval_validators_directly() -> None: - """Route progressive ledger states through the phase-aware validator.""" - section = component_section(1) - phase_command = ( - "uv run python eng/parity/verify_production_plan.py " - "--phase component --component 1 " - "docs/superpowers/plans/2026-08-09-libtmux-csharp-production.md" - ) - invalid = section.replace( - phase_command, - "uv run python eng/parity/verify_public_api.py", - 1, - ) - plan = complete_plan().replace(section, invalid, 1) - assert "component 1 bypasses phase-aware approval validation" in validator()( - plan, ledger() - ) - - -def test_stage_paths_are_exactly_declared_files() -> None: - """Derive staging from owned and explicitly shared paths only.""" - stage_paths = t.cast( - t.Callable[[str, int], list[str]], - validator_namespace()["stage_paths"], - ) - paths = stage_paths(complete_plan(), 8) - assert paths == sorted({*COMPONENT_FILES[8], *COMPONENT_SHARED_FILES[8]}) - - -def test_ledger_test_path_must_be_listed_in_owning_component_files() -> None: - """Reject evidence paths that the owning component never creates.""" - path = parity_test_path(8) - plan = complete_plan().replace(f"- `{path}`\n", "", 1) - assert "ledger row testPath missing from owning component Files" in validator()( - plan, ledger() - ) - - -def test_ledger_test_path_must_be_an_exact_repository_path() -> None: - """Reject missing or wildcard evidence destinations in ledger rows.""" - invalid_ledger = ledger() - invalid_ledger["rows"][0]["testPath"] = "tests/**/ParityTests.cs" - assert "ledger row has invalid testPath" in validator()( - complete_plan(), invalid_ledger - ) - - -@pytest.mark.parametrize( - ("component", "test_name"), - [ - (component, test_name) - for component, test_names in REQUIRED_RED_TESTS.items() - for test_name in test_names - ], -) -def test_required_behavior_families_need_named_red_tests( - component: int, - test_name: str, -) -> None: - """Reject broad happy paths that omit one approved behavior family.""" - plan = complete_plan().replace( - f"- `{test_name}` must fail", "- `omitted` must fail", 1 - ) - assert ( - f"component {component} missing required Red behavioral tests" - in validator()(plan, ledger()) - ) - - -def test_approval_phase_rejects_production_claims() -> None: - """Keep the committed approval snapshot strictly unimplemented.""" - violations = validator()( - complete_plan(), - completed_ledger(1), - phase="approval", - ) - assert "approval phase has production status claims" in violations - - -def test_component_phase_accepts_only_the_completed_prefix() -> None: - """Accept exact monotonic progress through the selected component.""" - assert ( - validator()( - complete_plan(), - completed_ledger(8), - phase="component", - component=8, - ) - == [] - ) - - -def test_component_phase_rejects_incomplete_and_future_rows() -> None: - """Reject gaps and work claimed beyond the selected component.""" - incomplete = completed_ledger(8) - incomplete["rows"][0]["evidenceStatus"] = "none" - future = completed_ledger(9) - assert "component phase status mismatch" in validator()( - complete_plan(), incomplete, phase="component", component=8 - ) - assert "component phase status mismatch" in validator()( - complete_plan(), future, phase="component", component=8 - ) - - -def test_closure_phase_requires_every_row_verified() -> None: - """Reject closure while any component remains incomplete.""" - assert ( - validator()( - complete_plan(), - completed_ledger(18), - phase="closure", - ) - == [] - ) - assert "closure phase has incomplete statuses" in validator()( - complete_plan(), completed_ledger(17), phase="closure" - ) - - -def test_approval_validation_uses_a_normalized_copy() -> None: - """Run frozen approval validators without erasing production progress.""" - normalize = t.cast( - t.Callable[[dict[str, t.Any]], dict[str, t.Any]], - validator_namespace()["approval_ledger"], - ) - progressed = completed_ledger(4) - normalized = normalize(progressed) - assert {row["implementationStatus"] for row in normalized["rows"]} == { - "not_started" - } - assert {row["evidenceStatus"] for row in normalized["rows"]} == {"none"} - assert progressed["rows"][0]["implementationStatus"] == "implemented" - - -def test_strict_approval_contracts_accept_normalized_production_progress() -> None: - """Preserve strict approval tools while the production ledger advances.""" - namespace = validator_namespace() - progressed = namespace["load_ledger"]() - progressed["rows"][0]["implementationStatus"] = "implemented" - progressed["rows"][0]["evidenceStatus"] = "verified" - - assert namespace["validate_approval_contracts"](progressed) == [] - assert progressed["rows"][0]["implementationStatus"] == "implemented" - - -def test_cli_component_phase_prints_the_exact_stage_allow_list( - tmp_path: pathlib.Path, - monkeypatch: pytest.MonkeyPatch, - capsys: pytest.CaptureFixture[str], -) -> None: - """Exercise progressive validation through the production CLI.""" - namespace = validator_namespace() - main = t.cast(t.Any, namespace["main"]) - plan_path = tmp_path / "production.md" - plan_path.write_text(complete_plan(), encoding="utf-8") - progressed = completed_ledger(8) - monkeypatch.setitem(main.__globals__, "load_ledger", lambda: progressed) - monkeypatch.setitem( - main.__globals__, - "validate_approval_contracts", - lambda current: [] if current is progressed else ["wrong ledger"], - ) - - result = main( - [ - "--phase", - "component", - "--component", - "8", - "--print-stage-paths", - str(plan_path), - ] - ) - - expected = namespace["stage_paths"](complete_plan(), 8) - assert result == 0 - assert capsys.readouterr().out.splitlines() == expected - - -def test_cli_component_phase_verifies_the_staged_allow_list( - tmp_path: pathlib.Path, - monkeypatch: pytest.MonkeyPatch, - capsys: pytest.CaptureFixture[str], -) -> None: - """Exercise exact staged-scope comparison through the production CLI.""" - namespace = validator_namespace() - main = t.cast(t.Any, namespace["main"]) - plan_path = tmp_path / "production.md" - plan_path.write_text(complete_plan(), encoding="utf-8") - progressed = completed_ledger(8) - expected = namespace["stage_paths"](complete_plan(), 8) - monkeypatch.setitem(main.__globals__, "load_ledger", lambda: progressed) - monkeypatch.setitem(main.__globals__, "validate_approval_contracts", lambda _: []) - monkeypatch.setitem(main.__globals__, "read_staged_paths", lambda: expected) - - arguments = [ - "--phase", - "component", - "--component", - "8", - "--verify-staged-scope", - str(plan_path), - ] - assert main(arguments) == 0 - assert capsys.readouterr().err == "" - - monkeypatch.setitem( - main.__globals__, - "read_staged_paths", - lambda: [*expected, "outside.txt"], - ) - assert main(arguments) == 1 - assert "staged paths do not exactly match" in capsys.readouterr().err - - -def test_cli_closure_verifies_only_final_evidence_is_staged( - tmp_path: pathlib.Path, - monkeypatch: pytest.MonkeyPatch, - capsys: pytest.CaptureFixture[str], -) -> None: - """Allow exactly retained evidence plus the reconciled policy document.""" - namespace = validator_namespace() - main = t.cast(t.Any, namespace["main"]) - plan_path = tmp_path / "production.md" - plan_path.write_text(complete_plan(), encoding="utf-8") - monkeypatch.setitem(main.__globals__, "load_ledger", lambda: completed_ledger(18)) - monkeypatch.setitem(main.__globals__, "validate_approval_contracts", lambda _: []) - monkeypatch.setitem( - main.__globals__, - "read_staged_paths", - lambda: [ - f"{FINAL_EVIDENCE_ROOT}/results.ndjson", - VERSION_DELTA_PATH, - ], - ) - arguments = [ - "--phase", - "closure", - "--verify-final-evidence-staged-scope", - str(plan_path), - ] - - assert main(arguments) == 0 - assert capsys.readouterr().err == "" - - monkeypatch.setitem( - main.__globals__, - "read_staged_paths", - lambda: [ - f"{FINAL_EVIDENCE_ROOT}/results.ndjson", - "src/LibTmux/Server.cs", - ], - ) - assert main(arguments) == 1 - assert "staged paths do not exactly match" in capsys.readouterr().err - - -def test_cli_component_three_verifies_two_root_evidence_scope( - tmp_path: pathlib.Path, - monkeypatch: pytest.MonkeyPatch, - capsys: pytest.CaptureFixture[str], -) -> None: - """Allow only cohort 0001 and its reconciled version-delta metadata.""" - namespace = validator_namespace() - main = t.cast(t.Any, namespace["main"]) - plan_path = tmp_path / "production.md" - plan_path.write_text(complete_plan(), encoding="utf-8") - monkeypatch.setitem(main.__globals__, "load_ledger", lambda: completed_ledger(3)) - monkeypatch.setitem(main.__globals__, "validate_approval_contracts", lambda _: []) - monkeypatch.setitem( - main.__globals__, - "read_staged_paths", - lambda: [f"{C3_EVIDENCE_ROOT}/results.ndjson", VERSION_DELTA_PATH], - ) - arguments = [ - "--phase", - "component", - "--component", - "3", - "--verify-retained-evidence-staged-scope", - str(plan_path), - ] - - assert main(arguments) == 0 - assert capsys.readouterr().err == "" - - monkeypatch.setitem( - main.__globals__, - "read_staged_paths", - lambda: [ - f"{C3_EVIDENCE_ROOT}/results.ndjson", - VERSION_DELTA_PATH, - "src/LibTmux/Server.cs", - ], - ) - assert main(arguments) == 1 - assert "staged paths do not exactly match" in capsys.readouterr().err - - -def test_cli_approval_phase_remains_strict( - tmp_path: pathlib.Path, - monkeypatch: pytest.MonkeyPatch, - capsys: pytest.CaptureFixture[str], -) -> None: - """Keep the default approval invocation strict after adding phase support.""" - namespace = validator_namespace() - main = t.cast(t.Any, namespace["main"]) - plan_path = tmp_path / "production.md" - plan_path.write_text(complete_plan(), encoding="utf-8") - monkeypatch.setitem(main.__globals__, "load_ledger", lambda: completed_ledger(1)) - monkeypatch.setitem(main.__globals__, "validate_approval_contracts", lambda _: []) - - assert main([str(plan_path)]) == 1 - assert "approval phase has production status claims" in capsys.readouterr().err - - -def test_atomic_commit_requires_short_subject_and_why_what_body() -> None: - """Reject an unreviewable or malformed planned commit message.""" - section = component_section(1) - long_subject = "X" * 51 - invalid_subject = section.replace( - "`Component1(feat): Implement slice`", - f"`{long_subject}`", - 1, - ) - missing_why = section.replace( - "why: Preserve approved behavior in one reviewable component.\n", - "", - 1, - ) - missing_what = section.replace( - "what:\n- Implement the owned production and test files.\n", - "", - 1, - ) - plan = complete_plan() - assert "component 1 has invalid Atomic commit subject" in validator()( - plan.replace(section, invalid_subject, 1), ledger() - ) - assert "component 1 has invalid Atomic commit why" in validator()( - plan.replace(section, missing_why, 1), ledger() - ) - assert "component 1 has invalid Atomic commit what" in validator()( - plan.replace(section, missing_what, 1), ledger() - ) - - -@pytest.mark.parametrize("gate", CLOSURE_GATES) -def test_closure_requires_every_completion_gate(gate: str) -> None: - """Reject closure that omits a required completion proof.""" - marker = f"### {gate} gate\n" - plan = complete_plan().replace(marker, f"### Missing {gate} gate\n", 1) - assert f"closure missing {gate} gate" in validator()(plan, ledger()) - - -@pytest.mark.parametrize("gate", CLOSURE_GATES) -def test_closure_gates_require_their_exact_completion_proofs(gate: str) -> None: - """Reject named closure gates that omit their required proof.""" - plan = complete_plan().replace(CLOSURE_DETAILS[gate], "Proof omitted.", 1) - assert f"closure has invalid {gate} gate" in validator()(plan, ledger()) - - -@pytest.mark.parametrize( - ("gate", "command"), - [ - (gate, command) - for gate, commands in REQUIRED_CLOSURE_COMMANDS.items() - for command in commands - ], -) -def test_closure_commands_are_exact_and_executable(gate: str, command: str) -> None: - """Reject closure prose that lacks the exact artifact-producing command.""" - marker = f"### {gate} gate\n" - prefix, closure = complete_plan().split(marker, 1) - plan = prefix + marker + closure.replace(f"- `{command}`\n", "", 1) - assert f"closure missing required {gate} commands" in validator()(plan, ledger()) - - -def test_repository_quality_rejects_duplicate_module_mypy_discovery() -> None: - """Reject the repository-wide path form that discovers modules twice.""" - valid = "- `uv run mypy`\n" - invalid = valid + "- `uv run mypy .`\n" - plan = complete_plan().replace(valid, invalid, 1) - assert "closure has invalid Repository quality commands" in validator()( - plan, ledger() - ) - - -def test_closure_proofs_must_be_markdown_list_items() -> None: - """Reject closure prose outside the required gate list structure.""" - detail = CLOSURE_DETAILS["Package"] - plan = complete_plan().replace(f"- {detail}", detail, 1) - assert "closure has invalid Package gate" in validator()(plan, ledger()) - - -def test_publication_boundary_is_an_action_limit_not_a_fake_proof() -> None: - """Reject a local-log command presented as proof of remote publication state.""" - detail = CLOSURE_DETAILS["Publication boundary"] - plan = complete_plan().replace( - detail, - "Use a local branch log to prove that no commit or tag was pushed.", - 1, - ) - assert "closure has invalid Publication boundary gate" in validator()( - plan, ledger() - ) - - -@pytest.mark.parametrize("path", ENTITY_SHELL_FILES) -def test_canonical_entity_shells_precede_every_member_owner(path: str) -> None: - """Reject a declaring type scheduled after members or return sites use it.""" - plan = complete_plan().replace(f"- `{path}`\n", "", 1) - assert "declaring type unavailable before member ownership" in validator()( - plan, ledger() - ) - - -@pytest.mark.parametrize( - ("component", "path"), - [ - (component, path) - for component, paths in ENTITY_FRAGMENT_FILES.items() - for path in paths - ], -) -def test_entity_members_have_distinct_owned_partial_fragments( - component: int, - path: str, -) -> None: - """Reject member slices that silently edit another component's entity file.""" - section = component_section(component) - invalid = section.replace(f"- `{path}`\n", "", 1) - plan = complete_plan().replace(section, invalid, 1) - assert f"component {component} missing entity partial Files" in validator()( - plan, ledger() - ) - - -@pytest.mark.parametrize("path", DIAGNOSTIC_SHARED_FILES) -def test_diagnostics_declares_every_instrumented_path(path: str) -> None: - """Reject logging work that cannot stage every instrumented production file.""" - section = component_section(17) - invalid = section.replace(f"- `{path}`\n", "", 1) - plan = complete_plan().replace(section, invalid, 1) - assert "component 17 has invalid Shared files" in validator()(plan, ledger()) - - -def test_no_build_commands_are_explicitly_release_configuration() -> None: - """Reject tests or examples that run stale Debug output after a Release build.""" - plan = complete_plan().replace( - "--configuration Release --framework net8.0 --no-build", - "--framework net8.0 --no-build", - 1, - ) - assert "component 1 has non-Release --no-build command" in validator()( - plan, ledger() - ) - - -def test_json_unit_tests_reference_the_adapter_and_share_the_lock_graph() -> None: - """Reject C9 wiring that leaves QueryJsonTests unable to compile or restore.""" - section = component_section(9) - reference = PROJECT_WIRING[9][-1] - invalid = section.replace(f"- `{reference}`\n", "", 1) - plan = complete_plan().replace(section, invalid, 1) - assert "component 9 has invalid Project wiring" in validator()(plan, ledger()) - - lock_path = "tests/LibTmux.UnitTests/packages.lock.json" - invalid = section.replace(f"- `{lock_path}`\n", "", 1) - plan = complete_plan().replace(section, invalid, 1) - assert "component 9 has invalid Shared files" in validator()(plan, ledger()) - - -def test_local_package_wiring_is_cpm_correct_and_exact() -> None: - """Reject inline versions, loose ranges, or an inexact JSON-to-core dependency.""" - section = component_section(18) - central = PROJECT_WIRING[18][2] - inline = PROJECT_WIRING[18][3] - dependency = PROJECT_WIRING[18][5] - mutations = ( - section.replace("[0.1.0-local]", "0.1.0-local", 1), - section.replace(inline, inline.replace("versionless ", ""), 1), - section.replace(dependency, dependency.replace("exactly ", "at least "), 1), - ) - assert central in section - for invalid in mutations: - plan = complete_plan().replace(section, invalid, 1) - assert "component 18 has invalid Project wiring" in validator()(plan, ledger()) - - -@pytest.mark.parametrize( - "path", - ( - "Directory.Packages.props", - "src/LibTmux/packages.lock.json", - "src/LibTmux.Query.Json/packages.lock.json", - ), -) -def test_packaging_shares_central_versions_and_shipping_locks(path: str) -> None: - """Reject package closure that cannot stage its central or locked restore edits.""" - section = component_section(18) - invalid = section.replace(f"- `{path}`\n", "", 1) - plan = complete_plan().replace(section, invalid, 1) - assert "component 18 has invalid Shared files" in validator()(plan, ledger()) - - -def test_locked_linux_restore_precedes_every_aot_no_restore_publish() -> None: - """Reject a runtime publish that consumes no runtime-specific locked assets.""" - section = component_section(18) - restore = f"- `{AOT_COMMANDS[0]}`" - publish = f"- `{AOT_COMMANDS[1]}`" - invalid = section.replace(restore, "restore-marker", 1) - invalid = invalid.replace(publish, restore, 1).replace("restore-marker", publish, 1) - plan = complete_plan().replace(section, invalid, 1) - assert "component 18 has invalid AOT restore ordering" in validator()( - plan, ledger() - ) - - -def test_workflow_checks_claim_configuration_not_runtime_execution() -> None: - """Reject closure language that upgrades YAML validation into runtime proof.""" - detail = CLOSURE_DETAILS["Platform workflow configuration"] - invalid_detail = detail.replace( - "this local check does not execute those runtime jobs", - "this local check proves those runtime jobs passed", - ) - plan = complete_plan().replace(detail, invalid_detail, 1) - assert "closure has invalid Platform workflow configuration gate" in validator()( - plan, ledger() - ) - - -def test_external_workflow_runtime_evidence_is_an_explicit_handoff() -> None: - """Reject closure that implies local workflow inspection produced CI evidence.""" - detail = CLOSURE_DETAILS["External workflow evidence"] - plan = complete_plan().replace( - detail, - "Local workflow configuration is complete runtime evidence.", - 1, - ) - assert "closure has invalid External workflow evidence gate" in validator()( - plan, ledger() - ) - - -def test_staged_scope_comparison_requires_exact_coverage() -> None: - """Compare staged paths with every declared file and directory allow-root.""" - compare = t.cast( - t.Callable[[t.Iterable[str], t.Iterable[str]], list[str]], - validator_namespace()["compare_staged_scope"], - ) - allowed = ["a.cs", "evidence/final"] - staged = ["a.cs", "evidence/final/environment.json"] - assert compare(allowed, staged) == [] - assert compare(allowed, [*staged, "outside.txt"]) == [ - "staged paths do not exactly match component allow-list" - ] - assert compare(allowed, ["a.cs"]) == [ - "staged paths do not exactly match component allow-list" - ] - - -def test_stage_compare_commit_and_clean_index_are_ordered_checkpoints() -> None: - """Reject a component that commits before scope proof or leaves staged residue.""" - section = component_section(1) - verify_scope = next( - line for line in section.splitlines() if "--verify-staged-scope" in line - ) - commit = f"- `{atomic_commit_command(1)}`" - clean = '- `test -z "$(git diff --cached --name-only)"`' - invalid = section.replace(verify_scope, "scope-marker", 1) - invalid = invalid.replace(commit, verify_scope, 1).replace( - "scope-marker", commit, 1 - ) - plan = complete_plan().replace(section, invalid, 1) - assert "component 1 has invalid commit checkpoint order" in validator()( - plan, ledger() - ) - - invalid = section.replace(clean, "", 1) - plan = complete_plan().replace(section, invalid, 1) - assert "component 1 missing clean-index checkpoint" in validator()(plan, ledger()) - - -def test_atomic_commit_command_matches_the_declared_message() -> None: - """Reject a commit command that can produce a message other than the plan.""" - section = component_section(1) - invalid = section.replace( - atomic_commit_command(1), - atomic_commit_command(1).replace("approved behavior", "different behavior"), - 1, - ) - plan = complete_plan().replace(section, invalid, 1) - assert "component 1 missing exact Atomic commit command" in validator()( - plan, ledger() - ) - - -def test_package_inspection_covers_sourcelink_revision_and_privacy() -> None: - """Reject archive listing without semantic metadata and privacy inspection.""" - section = component_section(18) - command = PACKAGE_COMMANDS[-1] - invalid = section.replace(command, command.replace(" --repository .", ""), 1) - plan = complete_plan().replace(section, invalid, 1) - assert "component 18 missing required Full gate commands" in validator()( - plan, ledger() - ) - - -@pytest.mark.parametrize( - ("graph", "pair"), - C18_RESTORE_PAIRS.items(), -) -def test_component_eighteen_generates_each_lock_graph_before_locked_restore( - graph: str, - pair: tuple[str, str], -) -> None: - """Reject locked consumption without generation for a changed C18 graph.""" - del graph - section = component_section(18) - invalid = section.replace(f"- `{pair[0]}`\n", "", 1) - plan = complete_plan().replace(section, invalid, 1) - assert "component 18 has invalid NuGet lock generation" in validator()( - plan, ledger() - ) - - -@pytest.mark.parametrize( - ("graph", "pair"), - C18_RESTORE_PAIRS.items(), -) -def test_component_eighteen_lock_pairs_are_immediate_and_identical( - graph: str, - pair: tuple[str, str], -) -> None: - """Reject interleaving or graph drift between generation and consumption.""" - del graph - section = component_section(18) - locked = f"- `{pair[1]}`\n" - invalid = section.replace(locked, "- `dotnet --info`\n" + locked, 1) - plan = complete_plan().replace(section, invalid, 1) - assert "component 18 has invalid NuGet lock generation" in validator()( - plan, ledger() - ) - - -@pytest.mark.parametrize( - ("path", "message"), - ( - ( - "src/LibTmux/packages.lock.json", - "component 18 has invalid Shared files", - ), - ( - "src/LibTmux.Query.Json/packages.lock.json", - "component 18 has invalid Shared files", - ), - ( - "src/LibTmux.Query.Json/packages.packed.lock.json", - "component 18 has invalid Files inventory", - ), - ( - "tests/LibTmux.PackageConsumer/packages.lock.json", - "component 18 has invalid Files inventory", - ), - ( - "examples/LibTmux.Examples/packages.lock.json", - "component 18 has invalid Files inventory", - ), - ( - "tests/LibTmux.AotSmoke/packages.lock.json", - "component 18 has invalid Files inventory", - ), - ), -) -def test_component_eighteen_owns_every_generated_lock(path: str, message: str) -> None: - """Reject a generated graph whose lock cannot enter the atomic source commit.""" - section = component_section(18) - invalid = section.replace(f"- `{path}`\n", "", 1) - plan = complete_plan().replace(section, invalid, 1) - assert message in validator()(plan, ledger()) - - -def test_final_matrix_evidence_is_source_bound_before_component_closure() -> None: - """Reject retained evidence generated after the phase and staging checkpoints.""" - section = component_section(18) - phase = next( - line - for line in section.splitlines() - if "--phase component --component 18 " in line - and "--print-stage-paths" not in line - and "--verify-staged-scope" not in line - ) - matrix = f"- `{FINAL_MATRIX_COMMANDS[0]}`" - invalid = section.replace(matrix, "matrix-marker", 1) - invalid = invalid.replace(phase, matrix, 1).replace("matrix-marker", phase, 1) - plan = complete_plan().replace(section, invalid, 1) - assert "component 18 has invalid final evidence ordering" in validator()( - plan, ledger() - ) - - -def test_public_api_types_bind_to_exact_owned_production_files() -> None: - """Cross-check renamed request contracts against their planned source owners.""" - cross_check = t.cast( - t.Callable[[str, dict[str, t.Any]], list[str]], - validator_namespace()["validate_public_api_files"], - ) - assert cross_check(complete_plan(), public_api()) == [] - - path = PUBLIC_API_FILE_BINDINGS["T:LibTmux.DisplayPopupRequest"][1] - invalid_plan = complete_plan().replace(f"- `{path}`\n", "", 1) - assert "public API production file missing or misowned" in cross_check( - invalid_plan, public_api() - ) - - invalid_api = public_api() - invalid_api["types"] = invalid_api["types"][:-1] - assert "planned production type missing from public API" in cross_check( - complete_plan(), invalid_api - ) - - -def test_component_three_owns_tmux_enums_and_all_version_matrix_tests() -> None: - """Keep enum ownership and version evidence aligned with their source contracts.""" - namespace = validator_namespace() - reconciler = runpy.run_path( - str(pathlib.Path(__file__).parents[1] / "reconcile_versions.py") - ) - version_methods = tuple( - f"VersionParityTests.{method}" - for method in t.cast( - dict[str, str], reconciler["VERSION_PARITY_METHODS"] - ).values() - ) - enum_types = ( - "T:LibTmux.OptionScope", - "T:LibTmux.PaneDirection", - "T:LibTmux.ResizeDirection", - "T:LibTmux.WindowDirection", - ) - expected_red_tests = ( - "TmuxCapabilitiesTests.Comparisons_cover_equal_older_and_newer_versions", - *version_methods, - "VersionParityTests.CommandFlags", - ) - - assert len(version_methods) == 41 - assert namespace["REQUIRED_RED_TESTS"][3] == expected_red_tests - assert all(type_id in namespace["COMPONENT_API_TYPES"][3] for type_id in enum_types) - assert all( - type_id not in namespace["COMPONENT_API_TYPES"][11] for type_id in enum_types - ) - assert all( - type_id not in namespace["COMPONENT_API_TYPES"][12] for type_id in enum_types - ) - assert all( - type_id not in namespace["COMPONENT_API_TYPES"][14] for type_id in enum_types - ) - assert all( - namespace["PUBLIC_API_FILE_BINDINGS"][type_id] - == (3, "src/LibTmux/Constants/TmuxEnums.cs") - for type_id in enum_types - ) - assert namespace["COMPONENT_DEPENDENCIES"][11] == ( - "component 3", - "component 10", - ) - assert namespace["COMPONENT_DEPENDENCIES"][12] == ( - "component 3", - "component 10", - "component 11", - ) - - plan_path = production_plan_path() - components, _ = t.cast( - t.Callable[[str], tuple[list[dict[str, t.Any]], dict[str, t.Any]]], - namespace["parse_markdown"], - )(plan_path.read_text(encoding="utf-8")) - component = next(component for component in components if component["id"] == 3) - red_lines = t.cast(dict[str, list[list[str]]], component["fields"])[ - "Red behavioral test" - ][0] - listed_version_methods = tuple( - line.split("`", 2)[1] for line in red_lines if "`VersionParityTests." in line - ) - - assert listed_version_methods == ( - *version_methods, - "VersionParityTests.CommandFlags", - ) - - -def test_component_four_governance_is_exact() -> None: - """Bind C4 files, shared seams, APIs, rows, and named behavioral tests.""" - namespace = validator_namespace() - assert namespace["COMPONENT_FILES"][4] == COMPONENT_FILES[4] - assert namespace["COMPONENT_SHARED_FILES"][4] == COMPONENT_SHARED_FILES[4] - assert namespace["COMPONENT_API_TYPES"][4] == COMPONENT_API_TYPES[4] - assert namespace["C4_MATERIALIZATION_CONTRACT"] == C4_MATERIALIZATION_CONTRACT - assert namespace["REQUIRED_RED_TESTS"][4] == REQUIRED_RED_TESTS[4] - assert { - type_id: namespace["PUBLIC_API_FILE_BINDINGS"][type_id] - for type_id in COMPONENT_API_TYPES[4] - } == { - "T:LibTmux.Internal.FormatProjection": ( - 4, - "src/LibTmux/Materialization/FormatProjection.cs", - ), - "T:LibTmux.Internal.SeparatedRowFramer": ( - 4, - "src/LibTmux/Materialization/SeparatedRowFramer.cs", - ), - "T:LibTmux.Internal.MaterializationContext": ( - 4, - "src/LibTmux/Materialization/MaterializationContext.cs", - ), - "T:LibTmux.Internal.MaterializationQuery": ( - 4, - "src/LibTmux/Materialization/TmuxMaterializationQuery.cs", - ), - "T:LibTmux.Internal.Materializer": ( - 4, - "src/LibTmux/Materialization/TmuxMaterializer.cs", - ), - "T:LibTmux.Internal.ServerProjection": ( - 4, - "src/LibTmux/Materialization/FormatProjection.cs", - ), - "T:LibTmux.Internal.ServerProjectionDescriptor": ( - 4, - "src/LibTmux/Materialization/FormatProjection.cs", - ), - } - - plan_path = production_plan_path() - components, _ = t.cast( - t.Callable[[str], tuple[list[dict[str, t.Any]], dict[str, t.Any]]], - namespace["parse_markdown"], - )(plan_path.read_text(encoding="utf-8")) - by_id = {component["id"]: component for component in components} - c4_fields = t.cast(dict[str, list[list[str]]], by_id[4]["fields"]) - list_tokens = t.cast( - t.Callable[[list[str]], list[str] | None], namespace["list_tokens"] - ) - assert tuple(list_tokens(c4_fields["Files"][0]) or ()) == COMPONENT_FILES[4] - assert ( - tuple(list_tokens(c4_fields["Shared files"][0]) or ()) - == (COMPONENT_SHARED_FILES[4]) - ) - assert ( - tuple(list_tokens(c4_fields["API owners"][0]) or ()) == (COMPONENT_API_TYPES[4]) - ) - assert tuple(list_tokens(c4_fields["Materialization contract"][0]) or ()) == ( - C4_MATERIALIZATION_CONTRACT - ) - - current_ledger = t.cast(dict[str, t.Any], namespace["load_ledger"]()) - expected_rows = { - row["pythonSymbolId"] - for row in current_ledger["rows"] - if row["componentId"] == 4 - } - planned_c4_rows = set(list_tokens(c4_fields["Ledger rows"][0]) or ()) - planned_c2_rows = set( - list_tokens( - t.cast(dict[str, list[list[str]]], by_id[2]["fields"])["Ledger rows"][0] - ) - or () - ) - moved = { - "libtmux.pane:Pane.from_pane_id", - "libtmux.window:Window.from_window_id", - } - assert len(planned_c4_rows) == 197 - assert planned_c4_rows == expected_rows - assert len(planned_c2_rows) == 9 - assert moved <= planned_c4_rows - assert moved.isdisjoint(planned_c2_rows) - - named_tests = tuple( - line.split("`", 2)[1] - for line in c4_fields["Red behavioral test"][0] - if "`MaterializationTests." in line - ) - assert named_tests == ( - RED_CASES[4][1], - *REQUIRED_RED_TESTS[4], - ) - - -@pytest.mark.parametrize("contract", C4_MATERIALIZATION_CONTRACT) -def test_component_four_materialization_contract_is_frozen(contract: str) -> None: - """Reject framing, identity, or Component 5 handoff contract drift.""" - section = component_section(4) - invalid = section.replace(f"- `{contract}`\n", "", 1) - plan = complete_plan().replace(section, invalid, 1) - assert "component 4 has invalid materialization contract" in validator()( - plan, - ledger(), - ) - - -def test_component_four_ledger_validator_rejects_ownership_drift() -> None: - """Reject count, ownership, and parity-test drift in C4 lookup rows.""" - namespace = validator_namespace() - validate_c4 = t.cast( - t.Callable[[dict[str, t.Any]], list[str]], - namespace["validate_c4_ledger_ownership"], - ) - current = t.cast(dict[str, t.Any], namespace["load_ledger"]()) - assert validate_c4(current) == [] - - invalid = copy.deepcopy(current) - lookup = next( - row - for row in invalid["rows"] - if row["pythonSymbolId"] == "libtmux.pane:Pane.from_pane_id" - ) - lookup["testPath"] = "tests/LibTmux.IntegrationTests/Parity/Other.cs" - assert validate_c4(invalid) == ["C4 lookup ledger ownership drifted"] - - reassigned = copy.deepcopy(current) - next( - row - for row in reassigned["rows"] - if row["pythonSymbolId"] == "libtmux.window:Window.from_window_id" - )["componentId"] = 5 - assert validate_c4(reassigned) == ["C4 lookup ledger ownership drifted"] - - -def test_component_three_owns_the_server_version_fragment() -> None: - """Keep the approved Server.Version member buildable in its owning slice.""" - namespace = validator_namespace() - expected_path = "src/LibTmux/Server.Version.cs" - member_id = "P:LibTmux.Server.Version" - - assert expected_path in namespace["COMPONENT_FILES"][3] - assert namespace["ENTITY_FRAGMENT_FILES"][3] == (expected_path,) - assert namespace["COMPONENT_DEPENDENCIES"][3] == ( - "component 1", - "component 2", - ) - assert namespace["PUBLIC_API_MEMBER_FILE_BINDINGS"][member_id] == ( - 3, - expected_path, - ) - - cross_check = t.cast( - t.Callable[[str, dict[str, t.Any]], list[str]], - namespace["validate_public_api_files"], - ) - assert cross_check(complete_plan(), public_api()) == [] - - invalid_plan = complete_plan().replace(f"- `{expected_path}`\n", "", 1) - assert "public API member production file missing or misowned" in cross_check( - invalid_plan, - public_api(), - ) - - invalid_api = public_api() - invalid_api["members"] = [ - member for member in invalid_api["members"] if member["id"] != member_id - ] - assert "planned public member missing from public API" in cross_check( - complete_plan(), - invalid_api, - ) - - -def test_component_three_requires_exact_tmux_37_transition_evidence() -> None: - """Reject a transition proof that loses a build, record, or reconciliation gate.""" - expected_shared_files = ( - "eng/tmux/build-version.sh", - "eng/tmux/run-matrix.sh", - "eng/evidence/assemble_bundle.py", - "eng/evidence/tests/test_transactions.py", - "eng/parity/reconcile_versions.py", - "eng/parity/tests/test_reconcile_versions.py", - "eng/evidence/validate.py", - "eng/evidence/tests/test_validate.py", - "tests/LibTmux.IntegrationTests/Infrastructure/PtyAttachedClientScope.cs", - ) - assert validator()(complete_plan(), ledger()) == [] - - section = component_section(3) - for path in expected_shared_files: - invalid = section.replace(f"- `{path}`\n", "", 1) - plan = complete_plan().replace(section, invalid, 1) - assert "component 3 has invalid Shared files" in validator()(plan, ledger()) - - for contract in TMUX_37_TRANSITION_PROOF_CONTRACT: - invalid = section.replace(f"- `{contract}`\n", "", 1) - plan = complete_plan().replace(section, invalid, 1) - assert "component 3 has invalid tmux 3.7 transition proof" in validator()( - plan, - ledger(), - ) - - for command in REQUIRED_GATE_COMMANDS[3]: - invalid = section.replace(f"- `{command}`\n", "", 1) - plan = complete_plan().replace(section, invalid, 1) - assert "component 3 missing required Full gate commands" in validator()( - plan, - ledger(), - ) - - -def test_future_operation_components_own_version_policy_evidence() -> None: - """Require wrapper owners to carry the proof, not the policy documents.""" - namespace = validator_namespace() - - assert namespace["VERSION_POLICY_OWNER_COMPONENTS"] == ( - 10, - 11, - 12, - 13, - 15, - 16, - ) - assert namespace["VERSION_POLICY_SHARED_FILES"] == VERSION_POLICY_SHARED_FILES - for component_id in VERSION_POLICY_OWNER_COMPONENTS: - assert not any( - path in namespace["COMPONENT_SHARED_FILES"][component_id] - for path in VERSION_POLICY_SHARED_FILES - ) - assert component_id in namespace["VERSION_POLICY_PROOFS_BY_COMPONENT"] - - -def test_future_operation_components_freeze_each_wrapper_policy_proof() -> None: - """Reject an owner section that drops an exact test, behavior, or pending status.""" - namespace = validator_namespace() - assert namespace["VERSION_POLICY_PROOFS_BY_COMPONENT"] == ( - VERSION_POLICY_PROOFS_BY_COMPONENT - ) - - for component_id, proofs in VERSION_POLICY_PROOFS_BY_COMPONENT.items(): - section = component_section(component_id) - for proof in proofs: - invalid = section.replace(f"- `{proof}`\n", "", 1) - plan = complete_plan().replace(section, invalid, 1) - assert ( - f"component {component_id} has invalid version policy proofs" - in validator()(plan, ledger()) - ) - - -def test_component_three_rejects_each_missing_version_matrix_method() -> None: - """Reject removal or addition of one Component 3 version-evidence method.""" - reconciler = runpy.run_path( - str(pathlib.Path(__file__).parents[1] / "reconcile_versions.py") - ) - version_methods = tuple( - f"VersionParityTests.{method}" - for method in t.cast( - dict[str, str], reconciler["VERSION_PARITY_METHODS"] - ).values() - ) - section = component_section(3) - - for method in version_methods: - assert f"`{method}`" in section - invalid = section.replace(f"- `{method}` must fail", "- `omitted` must fail", 1) - plan = complete_plan().replace(section, invalid, 1) - assert "component 3 has invalid Red behavioral tests" in validator()( - plan, ledger() - ) - - invalid = section.replace( - "### RED command\n", - "- `VersionParityTests.Unrelated` must fail before production code is added.\n\n" - "### RED command\n", - 1, - ) - plan = complete_plan().replace(section, invalid, 1) - assert "component 3 has invalid Red behavioral tests" in validator()(plan, ledger()) - - -def test_component_fifteen_requires_option_scope_owner_dependency() -> None: - """Reject hooks work that omits its directly exposed enum owner.""" - expected_dependencies = ( - "component 1", - "component 2", - "component 3", - "component 14", - ) - assert COMPONENT_DEPENDENCIES[15] == expected_dependencies - - section = component_section(15) - assert "- `component 3`\n" in section - invalid = section.replace("- `component 3`\n", "", 1) - plan = complete_plan().replace(section, invalid, 1) - - assert "component 15 has invalid Depends on" in validator()(plan, ledger()) - - -def test_component_two_owns_its_color_mode_dependency() -> None: - """Keep ServerConnectionOptions independently buildable in Component 2.""" - namespace = validator_namespace() - plan_path = production_plan_path() - components, _ = t.cast( - t.Callable[[str], tuple[list[dict[str, t.Any]], dict[str, t.Any]]], - namespace["parse_markdown"], - )(plan_path.read_text(encoding="utf-8")) - by_id = {component["id"]: component for component in components} - list_tokens = t.cast( - t.Callable[[list[str]], list[str] | None], namespace["list_tokens"] - ) - - component_two = t.cast(dict[str, list[list[str]]], by_id[2]["fields"]) - component_three = t.cast(dict[str, list[list[str]]], by_id[3]["fields"]) - component_two_files = list_tokens(component_two["Files"][0]) - component_three_files = list_tokens(component_three["Files"][0]) - component_two_apis = list_tokens(component_two["API owners"][0]) - component_three_apis = list_tokens(component_three["API owners"][0]) - - assert component_two_files is not None - assert component_three_files is not None - assert component_two_apis is not None - assert component_three_apis is not None - assert "src/LibTmux/TmuxColorMode.cs" in component_two_files - assert "src/LibTmux/TmuxColorMode.cs" not in component_three_files - assert "T:LibTmux.TmuxColorMode" in component_two_apis - assert "T:LibTmux.TmuxColorMode" not in component_three_apis - - -def test_server_projection_ledger_rows_are_owned_by_component_four() -> None: - """Keep projection-only parity evidence with its materializer owner.""" - namespace = validator_namespace() - plan_path = production_plan_path() - components, _ = t.cast( - t.Callable[[str], tuple[list[dict[str, t.Any]], dict[str, t.Any]]], - namespace["parse_markdown"], - )(plan_path.read_text(encoding="utf-8")) - by_id = {component["id"]: component for component in components} - list_tokens = t.cast( - t.Callable[[list[str]], list[str] | None], namespace["list_tokens"] - ) - load_ledger = t.cast(t.Callable[[], dict[str, t.Any]], namespace["load_ledger"]) - row_ids = ( - "libtmux.server:Server.child_id_attribute", - "libtmux.server:Server.formatter_prefix", - ) - expected_test_path = ( - "tests/LibTmux.IntegrationTests/Parity/Component04ParityTests.cs" - ) - rows = { - row["pythonSymbolId"]: row - for row in load_ledger()["rows"] - if row.get("pythonSymbolId") in row_ids - } - component_two_rows = list_tokens( - t.cast(dict[str, list[list[str]]], by_id[2]["fields"])["Ledger rows"][0] - ) - component_four_rows = list_tokens( - t.cast(dict[str, list[list[str]]], by_id[4]["fields"])["Ledger rows"][0] - ) - - assert component_two_rows is not None - assert component_four_rows is not None - assert set(rows) == set(row_ids) - for row_id in row_ids: - assert row_id not in component_two_rows - assert row_id in component_four_rows - assert rows[row_id]["componentId"] == 4 - assert rows[row_id]["testPath"] == expected_test_path - - -@pytest.mark.parametrize("path", FORBIDDEN_PRODUCTION_FILES) -def test_stale_or_unapproved_production_files_are_rejected(path: str) -> None: - """Reject superseded request and internal harness destinations.""" - section = component_section(13) - invalid = section.replace("### Files\n\n", f"### Files\n\n- `{path}`\n", 1) - plan = complete_plan().replace(section, invalid, 1) - assert "plan contains stale or unapproved production Files" in validator()( - plan, ledger() - ) - - -def test_components_are_frozen_in_execution_order() -> None: - """Reject a valid set of components presented out of dependency order.""" - first = component_section(1) - second = component_section(2) - plan = complete_plan().replace( - first + "\n" + second, - second + "\n" + first, - 1, - ) - - assert "component sections are out of frozen order" in validator()(plan, ledger()) - - -@pytest.mark.parametrize("component", COMPONENT_IDS) -def test_every_component_has_an_exact_complete_files_inventory(component: int) -> None: - """Reject deletion of an otherwise non-special owned file.""" - section = component_section(component) - path = COMPONENT_FILES[component][0] - invalid = section.replace(f"- `{path}`\n", "", 1) - plan = complete_plan().replace(section, invalid, 1) - - assert f"component {component} has invalid Files inventory" in validator()( - plan, ledger() - ) - - -@pytest.mark.parametrize( - "command", - ( - "git push origin csharp", - "git tag v0.1.0", - "git tag -a v0.1.0 -m release", - ), -) -def test_publication_mutations_are_rejected_globally(command: str) -> None: - """Reject push and tag creation regardless of the section containing them.""" - marker = "- `git diff --check`\n" - plan = complete_plan().replace(marker, marker + f"- `{command}`\n", 1) - - assert "plan contains forbidden publication command" in validator()(plan, ledger()) - - -def test_behavioral_gates_precede_phase_validation() -> None: - """Reject validating component state before its build has passed.""" - section = component_section(1) - build = f"- `{PUBLIC_API_BUILD_COMMAND}`" - phase = next( - line - for line in section.splitlines() - if "--phase component --component 1 " in line - and "--print-stage-paths" not in line - and "--verify-staged-scope" not in line - ) - invalid = section.replace(build, "build-marker", 1) - invalid = invalid.replace(phase, build, 1).replace("build-marker", phase, 1) - plan = complete_plan().replace(section, invalid, 1) - - assert "component 1 validates phase before behavioral gates" in validator()( - plan, ledger() - ) - - -def test_all_dotnet_build_commands_are_release_configuration() -> None: - """Reject an executable build that silently falls back to Debug.""" - invalid_build = PUBLIC_API_BUILD_COMMAND.replace(" --configuration Release", "") - plan = complete_plan().replace(PUBLIC_API_BUILD_COMMAND, invalid_build, 1) - - assert "component 1 has non-Release dotnet build command" in validator()( - plan, ledger() - ) - - -def test_each_component_requires_an_executable_red_command_and_trx_evidence() -> None: - """Reject prose-only RED declarations that cannot prove a selected test failed.""" - section = component_section(1) - without_command = section.replace(f"- `{RED_COMMANDS[1]}`\n", "", 1) - without_evidence = section.replace(f"- `{RED_EVIDENCE[1]}`\n", "", 1) - plan = complete_plan() - - assert "component 1 missing executable RED command" in validator()( - plan.replace(section, without_command, 1), ledger() - ) - assert "component 1 missing RED evidence" in validator()( - plan.replace(section, without_evidence, 1), ledger() - ) - - -def test_query_json_requires_distinct_default_and_packed_lock_graphs() -> None: - """Reject reusing one NuGet lock for conditional project/package graphs.""" - section = component_section(18) - invalid = section.replace( - "- `src/LibTmux.Query.Json/packages.packed.lock.json`\n", - "", - 1, - ) - plan = complete_plan().replace(section, invalid, 1) - - assert "component 18 missing packed Query.Json lock graph" in validator()( - plan, ledger() - ) - - -def test_final_evidence_follows_a_clean_source_commit_and_gets_its_own_commit() -> None: - """Reject retained evidence captured from mutable pre-commit source state.""" - section = component_section(18) - source_commit = f"- `{atomic_commit_command(18)}`" - retained_matrix = f"- `{RETAINED_MATRIX_COMMAND}`" - invalid = section.replace(source_commit, "source-marker", 1) - invalid = invalid.replace(retained_matrix, source_commit, 1).replace( - "source-marker", retained_matrix, 1 - ) - plan = complete_plan().replace(section, invalid, 1) - - assert "component 18 has invalid source/evidence closure ordering" in validator()( - plan, ledger() - ) - - -def test_component_three_evidence_follows_its_clean_source_commit() -> None: - """Reject retained cohort 0001 captured from mutable Component 3 source.""" - section = component_section(3) - source_commit = f"- `{atomic_commit_command(3)}`" - retained_matrix = f"- `{C3_RETAINED_MATRIX_COMMAND}`" - invalid = section.replace(source_commit, "source-marker", 1) - invalid = invalid.replace(retained_matrix, source_commit, 1).replace( - "source-marker", retained_matrix, 1 - ) - plan = complete_plan().replace(section, invalid, 1) - - assert "component 3 has invalid source/evidence closure ordering" in validator()( - plan, ledger() - ) - - -@pytest.mark.parametrize("command", C3_EVIDENCE_COMMANDS) -def test_component_three_requires_complete_two_root_evidence_closure( - command: str, -) -> None: - """Reject loss of one retained, reconciliation, scope, or binding checkpoint.""" - section = component_section(3) - invalid = section.replace(f"- `{command}`\n", "", 1) - plan = complete_plan().replace(section, invalid, 1) - - assert "component 3 has invalid source/evidence closure ordering" in validator()( - plan, ledger() - ) - - -def test_source_binding_constrains_the_descendant_diff_to_final_evidence() -> None: - """Require parent fingerprint verification and one allowed evidence-only diff.""" - section = component_section(18) - invalid = section.replace( - POSTCOMMIT_SOURCE_BINDING_COMMAND, - POSTCOMMIT_SOURCE_BINDING_COMMAND.replace( - "--require-descendant-root", "--allow-descendant-root" - ), - 1, - ) - plan = complete_plan().replace(section, invalid, 1) - - assert "component 18 has incomplete source-binding closure" in validator()( - plan, ledger() - ) - - -def test_every_public_api_type_has_one_frozen_component_owner() -> None: - """Reject a plan whose API ownership covers only selected special types.""" - cross_check = t.cast( - t.Callable[[str, dict[str, t.Any]], list[str]], - validator_namespace()["validate_public_api_files"], - ) - - section = component_section(12) - type_id = COMPONENT_API_TYPES[12][0] - invalid = section.replace(f"- `{type_id}`\n", "", 1) - plan = complete_plan().replace(section, invalid, 1) - - assert "public API type ownership incomplete or drifted" in cross_check( - plan, public_api() - ) - - -def test_a_declared_red_test_the_repository_defines_is_accepted() -> None: - """A proof that exists satisfies the check, in either language.""" - namespace = validator_namespace() - - assert namespace["defines_test"]("RequireRedTests.Rejects_successful_test_run"), ( - "a Python proof written in snake_case should count" - ) - assert namespace["defines_test"]( - "SnapshotCollectionTests.Enumeration_is_local_and_uses_BCL_cardinality" - ), "a C# proof should count" - - -def test_a_declared_red_test_nothing_defines_is_reported() -> None: - """A plan promising a proof nobody wrote is what this check exists for.""" - namespace = validator_namespace() - - assert not namespace["defines_test"]("NoSuchTests.Nothing_defines_this_one") - - -def test_the_two_languages_compare_equal() -> None: - """One proof written in each language's convention is one proof.""" - namespace = validator_namespace() - comparable = namespace["comparable_test_name"] - - assert comparable("Rejects_stale_trx") == comparable("test_rejects_stale_trx") diff --git a/eng/parity/tests/test_public_api.py b/eng/parity/tests/test_public_api.py index 8f739fa..75b39aa 100644 --- a/eng/parity/tests/test_public_api.py +++ b/eng/parity/tests/test_public_api.py @@ -291,13 +291,13 @@ def test_approval_does_not_claim_implementation() -> None: """Keep destination approval separate from production evidence.""" ledger = load_json(csharp_docs_root() / "parity" / "parity-ledger.json") namespace = runpy.run_path( - str(pathlib.Path(__file__).parents[1] / "verify_production_plan.py") + str(pathlib.Path(__file__).parents[1] / "verify_ledger.py") ) - approval_ledger = t.cast( + approval_snapshot = t.cast( t.Callable[[dict[str, t.Any]], dict[str, t.Any]], - namespace["approval_ledger"], + namespace["approval_snapshot"], ) - approved_ledger = approval_ledger(ledger) + approved_ledger = approval_snapshot(ledger) assert all(row["destinationStatus"] != "planned" for row in approved_ledger["rows"]) production_rows = [ row diff --git a/eng/parity/verify_production_plan.py b/eng/parity/verify_production_plan.py deleted file mode 100644 index fb6dbf3..0000000 --- a/eng/parity/verify_production_plan.py +++ /dev/null @@ -1,3260 +0,0 @@ -"""Validate the ignored C# production implementation plan.""" - -# ruff: noqa: E501 - -from __future__ import annotations - -import argparse -import collections -import copy -import functools -import json -import pathlib -import re -import runpy -import shlex -import subprocess -import sys -import typing as t - -LEDGER_PATH = ( - pathlib.Path(__file__).parents[2] / "docs" / "parity" / "parity-ledger.json" -) -CSHARP_ROOT = pathlib.Path(__file__).parents[2] -PUBLIC_API_PATH = CSHARP_ROOT / "docs" / "public-api.json" -INVENTORY_PATH = CSHARP_ROOT / "docs" / "parity" / "python-public-api.json" -ERROR_POLICIES_PATH = CSHARP_ROOT / "docs" / "parity" / "error-policies.json" -PUBLIC_API_VALIDATOR_PATH = pathlib.Path(__file__).with_name("verify_public_api.py") -LEDGER_VALIDATOR_PATH = pathlib.Path(__file__).with_name("verify_ledger.py") -COMPONENT_IDS = frozenset(range(1, 19)) -COMPONENT_FILES: dict[int, tuple[str, ...]] = { - 1: ( - "LibTmux.slnx", - "src/LibTmux/LibTmux.csproj", - "src/LibTmux/packages.lock.json", - "tests/LibTmux.UnitTests/LibTmux.UnitTests.csproj", - "tests/LibTmux.UnitTests/packages.lock.json", - "tests/LibTmux.IntegrationTests/LibTmux.IntegrationTests.csproj", - "tests/LibTmux.IntegrationTests/packages.lock.json", - "src/LibTmux/Transport/TmuxCommandRequest.cs", - "src/LibTmux/Transport/TmuxCommandResult.cs", - "src/LibTmux/Transport/TmuxProcessTransport.cs", - "src/LibTmux/Transport/TmuxCommandDispatcher.cs", - "src/LibTmux/Transport/TmuxCommandFailure.cs", - "src/LibTmux/Transport/TmuxTransportLimits.cs", - "src/LibTmux/Transport/Utf8BackslashDecoder.cs", - "src/LibTmux/Server.cs", - "src/LibTmux/Session.cs", - "src/LibTmux/Window.cs", - "src/LibTmux/Pane.cs", - "src/LibTmux/Client.cs", - "src/LibTmux/Server.Command.cs", - "src/LibTmux/Session.Command.cs", - "src/LibTmux/Window.Command.cs", - "src/LibTmux/Pane.Command.cs", - "tests/LibTmux.UnitTests/Entities/EntityShellTests.cs", - "tests/LibTmux.UnitTests/Transport/TmuxProcessTransportTests.cs", - "tests/LibTmux.IntegrationTests/Transport/ProcessTransportTests.cs", - "tests/LibTmux.IntegrationTests/Parity/Component01ParityTests.cs", - "src/LibTmux/Exceptions/LibTmuxException.cs", - "src/LibTmux/Exceptions/TmuxCommandException.cs", - "src/LibTmux/Exceptions/TmuxCommandNotFoundException.cs", - "src/LibTmux/Exceptions/TmuxTransportException.cs", - "src/LibTmux/Exceptions/TmuxOperationCanceledException.cs", - "src/LibTmux/Exceptions/TmuxCleanupException.cs", - "src/LibTmux/Exceptions/TmuxWaitTimeoutException.cs", - "tests/LibTmux.IntegrationTests/Infrastructure/RawTmuxTestContext.cs", - "tests/LibTmux.IntegrationTests/Infrastructure/ControlModeClientScope.cs", - "tests/LibTmux.IntegrationTests/Infrastructure/PtyAttachedClientScope.cs", - "eng/parity/require_red.py", - "eng/parity/tests/test_require_red.py", - "tests/LibTmux.TestChild/LibTmux.TestChild.csproj", - "tests/LibTmux.TestChild/packages.lock.json", - "tests/LibTmux.TestChild/Program.cs", - ), - 2: ( - "src/LibTmux/Connection/TmuxConnection.cs", - "src/LibTmux/Connection/TmuxConnectionOptions.cs", - "src/LibTmux/Connection/ServerGeneration.cs", - "src/LibTmux/Server.Identity.cs", - "src/LibTmux/Session.Identity.cs", - "src/LibTmux/Window.Identity.cs", - "src/LibTmux/Pane.Identity.cs", - "src/LibTmux/Targets/TmuxTarget.cs", - "src/LibTmux/SessionId.cs", - "src/LibTmux/WindowId.cs", - "src/LibTmux/PaneId.cs", - "src/LibTmux/TmuxColorMode.cs", - "tests/LibTmux.UnitTests/Connection/TmuxConnectionTests.cs", - "tests/LibTmux.IntegrationTests/Connection/ServerGenerationTests.cs", - "tests/LibTmux.IntegrationTests/Parity/Component02ParityTests.cs", - "src/LibTmux/Exceptions/StaleServerGenerationException.cs", - "src/LibTmux/Exceptions/TmuxObjectNotFoundException.cs", - ), - 3: ( - "src/LibTmux/Constants/TmuxConstants.cs", - "src/LibTmux/Constants/TmuxEnums.cs", - "src/LibTmux/Formats/TmuxFormats.cs", - "src/LibTmux/Versioning/TmuxVersion.cs", - "src/LibTmux/Server.Version.cs", - "src/LibTmux/Versioning/TmuxCapabilities.cs", - "src/LibTmux/Internal/CommandFlagCatalog.cs", - "src/LibTmux/Internal/FormatCatalog.cs", - "src/LibTmux/Internal/FormatFieldDescriptor.cs", - "tests/LibTmux.UnitTests/Versioning/TmuxCapabilitiesTests.cs", - "tests/LibTmux.IntegrationTests/Versioning/VersionParityTests.cs", - "tests/LibTmux.IntegrationTests/Parity/Component03ParityTests.cs", - "src/LibTmux/Exceptions/TmuxVersionTooLowException.cs", - "docs/parity/version-deltas.json", - ), - 4: ( - "src/LibTmux/Materialization/FormatProjection.cs", - "src/LibTmux/Materialization/SeparatedRowFramer.cs", - "src/LibTmux/Materialization/TmuxMaterializer.cs", - "src/LibTmux/Materialization/TmuxMaterializationQuery.cs", - "src/LibTmux/Materialization/MaterializationContext.cs", - "src/LibTmux/Materialization/EntityMaterializationState.cs", - "tests/LibTmux.UnitTests/Materialization/SeparatedRowFramerTests.cs", - "tests/LibTmux.UnitTests/Materialization/FormatProjectionTests.cs", - "tests/LibTmux.UnitTests/Materialization/TmuxMaterializerTests.cs", - "tests/LibTmux.IntegrationTests/Materialization/MaterializationTests.cs", - "tests/LibTmux.IntegrationTests/Parity/Component04ParityTests.cs", - ), - 5: ( - "src/LibTmux/Snapshots/CapturedRelation.cs", - "src/LibTmux/Snapshots/SnapshotDepth.cs", - "src/LibTmux/Snapshots/ServerSnapshot.cs", - "src/LibTmux/Snapshots/WindowEntityKey.cs", - "src/LibTmux/Snapshots/SessionWindowEdge.cs", - "src/LibTmux/Session.Relations.cs", - "src/LibTmux/Window.Relations.cs", - "src/LibTmux/Pane.Relations.cs", - "tests/LibTmux.UnitTests/Snapshots/CapturedRelationTests.cs", - "tests/LibTmux.IntegrationTests/Snapshots/HierarchySnapshotTests.cs", - "tests/LibTmux.IntegrationTests/Parity/Component05ParityTests.cs", - "src/LibTmux/Exceptions/IncompleteSnapshotException.cs", - ), - 6: ( - "src/LibTmux/Environment/TmuxEnvironment.cs", - "src/LibTmux/Environment/ChildProcessEnvironment.cs", - "src/LibTmux/Server.Environment.cs", - "src/LibTmux/Session.Environment.cs", - "src/LibTmux/Window.Environment.cs", - "src/LibTmux/Pane.Environment.cs", - "tests/LibTmux.UnitTests/Environment/TmuxEnvironmentTests.cs", - "tests/LibTmux.IntegrationTests/Environment/ChildEnvironmentTests.cs", - "tests/LibTmux.IntegrationTests/Parity/Component06ParityTests.cs", - ), - 7: ( - "src/LibTmux/Collections/SnapshotCollectionExtensions.cs", - "src/LibTmux/Collections/SnapshotLookup.cs", - "src/LibTmux/Server.Collections.cs", - "tests/LibTmux.UnitTests/Collections/SnapshotCollectionTests.cs", - "tests/LibTmux.IntegrationTests/Collections/ScopedCollectionTests.cs", - "tests/LibTmux.IntegrationTests/Parity/Component07ParityTests.cs", - ), - 8: ( - "src/LibTmux/Query/QueryDocument.cs", - "src/LibTmux/Query/QueryNode.cs", - "src/LibTmux/Query/QueryTranslator.cs", - "src/LibTmux/Query/QueryInterpreter.cs", - "src/LibTmux/Query/NativeFilterSearch.cs", - "src/LibTmux/Query/QueryExtensions.cs", - "src/LibTmux/Query/NameContainsLookupParser.cs", - "src/LibTmux.Generators/LibTmux.Generators.csproj", - "src/LibTmux.Generators/FieldCatalogGenerator.cs", - "tests/LibTmux.UnitTests/Query/QuerySemanticsTests.cs", - "tests/LibTmux.IntegrationTests/Query/NativeFilterSearchTests.cs", - "tests/LibTmux.IntegrationTests/Parity/Component08ParityTests.cs", - "src/LibTmux/Exceptions/UnsupportedQueryExpressionException.cs", - "src/LibTmux.Generators/packages.lock.json", - ), - 9: ( - "src/LibTmux.Query.Json/LibTmux.Query.Json.csproj", - "src/LibTmux.Query.Json/QueryJsonSerializerContext.cs", - "src/LibTmux.Query.Json/QueryDocumentJsonConverter.cs", - "src/LibTmux.Query.Json/libtmux-query-v1.schema.json", - "tests/LibTmux.UnitTests/Query/QueryJsonTests.cs", - "tests/LibTmux.IntegrationTests/Parity/Component09ParityTests.cs", - "src/LibTmux.Query.Json/packages.lock.json", - ), - 10: ( - "src/LibTmux/Server.Lifecycle.cs", - "src/LibTmux/Session.Lifecycle.cs", - "src/LibTmux/Requests/NewSessionRequest.cs", - "src/LibTmux/Requests/AttachSessionRequest.cs", - "src/LibTmux/Testing/TemporaryServerScope.cs", - "src/LibTmux/Testing/TemporarySessionScope.cs", - "tests/LibTmux.IntegrationTests/Hierarchy/ServerSessionLifecycleTests.cs", - "tests/LibTmux.IntegrationTests/Parity/Component10ParityTests.cs", - "src/LibTmux/Exceptions/TmuxSessionExistsException.cs", - "src/LibTmux/Internal/SessionName.cs", - "src/LibTmux/Internal/StartDirectory.cs", - ), - 11: ( - "src/LibTmux/Session.WindowNavigation.cs", - "src/LibTmux/Window.Topology.cs", - "src/LibTmux/Requests/NewWindowRequest.cs", - "src/LibTmux/Requests/MoveWindowRequest.cs", - "src/LibTmux/Requests/LinkWindowRequest.cs", - "src/LibTmux/Requests/ResizeWindowRequest.cs", - "src/LibTmux/Requests/SelectLayoutRequest.cs", - "src/LibTmux/Requests/SplitPaneRequest.cs", - "src/LibTmux/Requests/DisplayMessageRequest.cs", - "src/LibTmux/Requests/NewPaneRequest.cs", - "src/LibTmux/Requests/RespawnRequest.cs", - "src/LibTmux/Testing/TemporaryWindowScope.cs", - "tests/LibTmux.IntegrationTests/Hierarchy/WindowTopologyTests.cs", - "tests/LibTmux.IntegrationTests/Parity/Component11ParityTests.cs", - "src/LibTmux/Exceptions/TmuxWindowException.cs", - ), - 12: ( - "src/LibTmux/Window.PaneNavigation.cs", - "src/LibTmux/Pane.Operations.cs", - "src/LibTmux/Requests/CapturePaneRequest.cs", - "src/LibTmux/Requests/DisplayPopupRequest.cs", - "src/LibTmux/Requests/SendKeysRequest.cs", - "src/LibTmux/Requests/ResizePaneRequest.cs", - "src/LibTmux/Requests/MovePaneRequest.cs", - "src/LibTmux/Requests/SwapPaneRequest.cs", - "src/LibTmux/Requests/SelectPaneRequest.cs", - "src/LibTmux/Requests/CopyModeRequest.cs", - "src/LibTmux/Requests/PasteBufferRequest.cs", - "src/LibTmux/Requests/PipePaneRequest.cs", - "src/LibTmux/Requests/ChooseTreeRequest.cs", - "src/LibTmux/Requests/FindWindowRequest.cs", - "tests/LibTmux.IntegrationTests/Hierarchy/PaneOperationsTests.cs", - "tests/LibTmux.IntegrationTests/Parity/Component12ParityTests.cs", - "src/LibTmux/Exceptions/TmuxPaneException.cs", - ), - 13: ( - "src/LibTmux/Server.Clients.cs", - "src/LibTmux/Client.Administration.cs", - "src/LibTmux/ClientAttachment.cs", - "tests/LibTmux.IntegrationTests/Clients/ClientAdministrationTests.cs", - "tests/LibTmux.IntegrationTests/Parity/Component13ParityTests.cs", - ), - 14: ( - "src/LibTmux/Options/TmuxOptionValue.cs", - "src/LibTmux/Options/TmuxOptions.cs", - "src/LibTmux/Internal/OptionParser.cs", - "src/LibTmux/Internal/OptionFailure.cs", - "src/LibTmux/Requests/GetOptionRequest.cs", - "src/LibTmux/Requests/GetOptionsRequest.cs", - "src/LibTmux/Requests/SetOptionRequest.cs", - "src/LibTmux/Requests/UnsetOptionRequest.cs", - "src/LibTmux/Server.Options.cs", - "src/LibTmux/Session.Options.cs", - "src/LibTmux/Window.Options.cs", - "src/LibTmux/Pane.Options.cs", - "tests/LibTmux.UnitTests/Options/TmuxOptionValueTests.cs", - "tests/LibTmux.IntegrationTests/Options/TmuxOptionsTests.cs", - "tests/LibTmux.IntegrationTests/Parity/Component14ParityTests.cs", - "src/LibTmux/Exceptions/TmuxOptionException.cs", - ), - 15: ( - "src/LibTmux/Hooks/TmuxHooks.cs", - "src/LibTmux/Environment/TmuxEnvironmentOperations.cs", - "src/LibTmux/Requests/HookRequest.cs", - "src/LibTmux/Requests/ListHooksRequest.cs", - "src/LibTmux/Requests/SetHookRequest.cs", - "src/LibTmux/Requests/SetHooksRequest.cs", - "src/LibTmux/Server.Hooks.cs", - "src/LibTmux/Session.Hooks.cs", - "src/LibTmux/Window.Hooks.cs", - "src/LibTmux/Pane.Hooks.cs", - "src/LibTmux/Server.EnvironmentOperations.cs", - "src/LibTmux/Session.EnvironmentOperations.cs", - "tests/LibTmux.IntegrationTests/Hooks/HookOperationsTests.cs", - "tests/LibTmux.IntegrationTests/Environment/EnvironmentOperationsTests.cs", - "tests/LibTmux.IntegrationTests/Parity/Component15ParityTests.cs", - ), - 16: ( - "src/LibTmux/Utilities/ServerUtilities.cs", - "src/LibTmux/Server.Utilities.cs", - "src/LibTmux/Utilities/TmuxBuffer.cs", - "src/LibTmux/Utilities/TmuxMenuItem.cs", - "src/LibTmux/Requests/BindKeyRequest.cs", - "src/LibTmux/Requests/CommandPromptRequest.cs", - "src/LibTmux/Requests/ConfirmBeforeRequest.cs", - "src/LibTmux/Requests/DisplayMenuRequest.cs", - "src/LibTmux/Requests/IfShellRequest.cs", - "src/LibTmux/Requests/ListBuffersRequest.cs", - "src/LibTmux/Requests/RunShellRequest.cs", - "src/LibTmux/Requests/ServerAccessRequest.cs", - "src/LibTmux/Requests/UnbindKeyRequest.cs", - "src/LibTmux/Requests/WaitForRequest.cs", - "tests/LibTmux.IntegrationTests/Utilities/ServerUtilitiesTests.cs", - "tests/LibTmux.IntegrationTests/Parity/Component16ParityTests.cs", - ), - 17: ( - "src/LibTmux/Diagnostics/TmuxLog.cs", - "src/LibTmux/Compatibility/SupportedAliases.cs", - "tests/LibTmux.UnitTests/Diagnostics/ExceptionContractTests.cs", - "tests/LibTmux.IntegrationTests/Diagnostics/StructuredLoggingTests.cs", - "tests/LibTmux.IntegrationTests/Parity/Component17ParityTests.cs", - ), - 18: ( - "src/LibTmux/Testing/TmuxWait.cs", - "src/LibTmux/Testing/TmuxNameGenerator.cs", - "src/LibTmux/Testing/TestEnvironment.cs", - "src/LibTmux/Testing/TmuxTestOptions.cs", - "src/LibTmux/Testing/TmuxTestFactory.cs", - "src/LibTmux/Testing/TmuxTestContext.cs", - "src/LibTmux/Testing/TemporaryHierarchyScope.cs", - "tests/LibTmux.PackageConsumer/LibTmux.PackageConsumer.csproj", - "examples/LibTmux.Examples/LibTmux.Examples.csproj", - "examples/LibTmux.Examples/Program.cs", - "tests/LibTmux.IntegrationTests/Parity/Component18ParityTests.cs", - "tests/LibTmux.IntegrationTests/Testing/TestingHelpersTests.cs", - ".github/workflows/dotnet.yml", - ".github/workflows/dotnet-tmux.yml", - "README.md", - "src/LibTmux/PublicAPI.Shipped.txt", - "src/LibTmux/PublicAPI.Unshipped.txt", - "src/LibTmux.Query.Json/PublicAPI.Shipped.txt", - "src/LibTmux.Query.Json/PublicAPI.Unshipped.txt", - "eng/parity/verify_workflows.py", - "eng/parity/tests/test_workflows.py", - "eng/parity/inspect_packages.py", - "eng/parity/tests/test_packages.py", - "eng/evidence/verify_source_binding.py", - "eng/evidence/tests/test_source_binding.py", - "tests/LibTmux.AotSmoke/LibTmux.AotSmoke.csproj", - "tests/LibTmux.AotSmoke/packages.lock.json", - "tests/LibTmux.AotSmoke/Program.cs", - "tests/LibTmux.PackageConsumer/packages.lock.json", - "tests/LibTmux.PackageConsumer/Program.cs", - "tests/LibTmux.PackageConsumer/NuGet.config", - "tests/LibTmux.IntegrationTests/Packaging/PackageClosureTests.cs", - "tests/LibTmux.UnitTests/Packaging/PublicApiContractTests.cs", - "tests/LibTmux.UnitTests/Packaging/WorkflowContractTests.cs", - "examples/LibTmux.Examples/packages.lock.json", - "src/LibTmux.Query.Json/packages.packed.lock.json", - ), -} -COMPONENT_API_TYPES: dict[int, tuple[str, ...]] = { - 1: ( - "T:LibTmux.Client", - "T:LibTmux.ControlModeCommandException", - "T:LibTmux.IControlModeSession", - "T:LibTmux.Internal.TmuxCommandDispatcher", - "T:LibTmux.Internal.TmuxCommandFailure", - "T:LibTmux.Internal.TmuxProcessTransport", - "T:LibTmux.LibTmuxException", - "T:LibTmux.Pane", - "T:LibTmux.Server", - "T:LibTmux.Session", - "T:LibTmux.TmuxCleanupException", - "T:LibTmux.TmuxCommandException", - "T:LibTmux.TmuxCommandNotFoundException", - "T:LibTmux.TmuxChain", - "T:LibTmux.TmuxChaining", - "T:LibTmux.TmuxCommand", - "T:LibTmux.TmuxEvent", - "T:LibTmux.TmuxEventsDroppedEvent", - "T:LibTmux.TmuxExitEvent", - "T:LibTmux.TmuxNotificationEvent", - "T:LibTmux.TmuxOutputEvent", - "T:LibTmux.TmuxCommandResult", - "T:LibTmux.TmuxOperationCanceledException", - "T:LibTmux.TmuxTransportException", - "T:LibTmux.TmuxWaitTimeoutException", - "T:LibTmux.Window", - ), - 2: ( - "T:LibTmux.PaneId", - "T:LibTmux.PsmuxCaptureOptions", - "T:LibTmux.PsmuxConnectionOptions", - "T:LibTmux.PsmuxPane", - "T:LibTmux.PsmuxServer", - "T:LibTmux.PsmuxSession", - "T:LibTmux.PsmuxWindow", - "T:LibTmux.ServerConnectionOptions", - "T:LibTmux.ServerGeneration", - "T:LibTmux.SessionId", - "T:LibTmux.StaleServerGenerationException", - "T:LibTmux.TmuxColorMode", - "T:LibTmux.TmuxObjectNotFoundException", - "T:LibTmux.WindowId", - ), - 3: ( - "T:LibTmux.Internal.CommandFlagCatalog", - "T:LibTmux.Internal.FormatCatalog", - "T:LibTmux.Internal.FormatFieldDescriptor", - "T:LibTmux.LibTmuxInfo", - "T:LibTmux.OptionScope", - "T:LibTmux.PaneDirection", - "T:LibTmux.ResizeDirection", - "T:LibTmux.TmuxDispatchState", - "T:LibTmux.TmuxVersion", - "T:LibTmux.TmuxVersionTooLowException", - "T:LibTmux.WindowDirection", - ), - 4: ( - "T:LibTmux.Internal.FormatProjection", - "T:LibTmux.Internal.SeparatedRowFramer", - "T:LibTmux.Internal.MaterializationContext", - "T:LibTmux.Internal.MaterializationQuery", - "T:LibTmux.Internal.Materializer", - "T:LibTmux.Internal.ServerProjection", - "T:LibTmux.Internal.ServerProjectionDescriptor", - ), - 5: ( - "T:LibTmux.CapturedRelation`1", - "T:LibTmux.IncompleteSnapshotException", - "T:LibTmux.SessionWindowEdge", - "T:LibTmux.SnapshotDepth", - "T:LibTmux.WindowEntityKey", - ), - 6: ("T:LibTmux.TmuxEnvironment", "T:LibTmux.TmuxEnvironmentEntry"), - 7: ("not applicable",), - 8: ( - "T:LibTmux.Query.AndNode", - "T:LibTmux.Query.BooleanConstant", - "T:LibTmux.Query.ComparisonNode", - "T:LibTmux.Query.ConstantNode", - "T:LibTmux.Query.EnumConstant", - "T:LibTmux.Query.FieldNode", - "T:LibTmux.Query.InstantConstant", - "T:LibTmux.Query.Int64Constant", - "T:LibTmux.Query.NotNode", - "T:LibTmux.Query.NullConstant", - "T:LibTmux.Query.OrNode", - "T:LibTmux.Query.QuantifierNode", - "T:LibTmux.Query.QueryComparison", - "T:LibTmux.Query.QueryConstant", - "T:LibTmux.Query.QueryDocument", - "T:LibTmux.Query.QueryEdgeParser", - "T:LibTmux.Query.QueryExtensions", - "T:LibTmux.Query.QueryNode", - "T:LibTmux.Query.QueryQuantifier", - "T:LibTmux.Query.QueryStringOperation", - "T:LibTmux.Query.QueryTarget", - "T:LibTmux.Query.RegexNode", - "T:LibTmux.Query.StringConstant", - "T:LibTmux.Query.StringNode", - "T:LibTmux.Query.TypedIdConstant", - "T:LibTmux.UnsafeTmuxFilter", - "T:LibTmux.UnsupportedQueryExpressionException", - ), - 9: ("T:LibTmux.Query.Json.QueryJson", "T:LibTmux.Query.Json.QueryJsonLimits"), - 10: ( - "T:LibTmux.AttachSessionRequest", - "T:LibTmux.Internal.SessionName", - "T:LibTmux.NewSessionRequest", - "T:LibTmux.OwnedServerScope", - "T:LibTmux.OwnedSessionScope", - "T:LibTmux.Testing.TemporaryServerScope", - "T:LibTmux.Testing.TemporarySessionScope", - "T:LibTmux.TmuxSessionExistsException", - ), - 11: ( - "T:LibTmux.DisplayMessageRequest", - "T:LibTmux.LinkWindowRequest", - "T:LibTmux.MoveWindowRequest", - "T:LibTmux.NewPaneRequest", - "T:LibTmux.NewWindowRequest", - "T:LibTmux.OwnedWindowScope", - "T:LibTmux.ResizeWindowRequest", - "T:LibTmux.RespawnRequest", - "T:LibTmux.SelectLayoutMode", - "T:LibTmux.SelectLayoutRequest", - "T:LibTmux.SplitPaneRequest", - "T:LibTmux.Testing.TemporaryWindowScope", - "T:LibTmux.TmuxWindowException", - "T:LibTmux.WindowResizeMode", - "T:LibTmux.WindowRotationDirection", - ), - 12: ( - "T:LibTmux.CapturePanePosition", - "T:LibTmux.CapturePaneRequest", - "T:LibTmux.ChooseTreeRequest", - "T:LibTmux.ChooseTreeSort", - "T:LibTmux.CopyModeRequest", - "T:LibTmux.DisplayPopupRequest", - "T:LibTmux.FindWindowRequest", - "T:LibTmux.MovePaneRequest", - "T:LibTmux.PaneInputMode", - "T:LibTmux.PaneSelectDirection", - "T:LibTmux.PaneSwapDirection", - "T:LibTmux.PasteBufferRequest", - "T:LibTmux.PipePaneRequest", - "T:LibTmux.PopupCloseMode", - "T:LibTmux.ResizePaneRequest", - "T:LibTmux.SelectPaneRequest", - "T:LibTmux.SendKeysRequest", - "T:LibTmux.SwapPaneRequest", - "T:LibTmux.TmuxPaneException", - ), - 13: ("T:LibTmux.ClientAttachment",), - 14: ( - "T:LibTmux.GetOptionRequest", - "T:LibTmux.GetOptionsRequest", - "T:LibTmux.Internal.OptionFailure", - "T:LibTmux.Internal.OptionParser", - "T:LibTmux.SetOptionRequest", - "T:LibTmux.TmuxOption", - "T:LibTmux.TmuxOptionException", - "T:LibTmux.TmuxOptionState", - "T:LibTmux.TmuxOptionValue", - "T:LibTmux.TmuxOptions", - "T:LibTmux.UnsetOptionRequest", - ), - 15: ( - "T:LibTmux.HookRequest", - "T:LibTmux.ListHooksRequest", - "T:LibTmux.SetHookRequest", - "T:LibTmux.SetHooksRequest", - "T:LibTmux.TmuxHook", - "T:LibTmux.TmuxHookEntry", - "T:LibTmux.TmuxHooks", - ), - 16: ( - "T:LibTmux.BindKeyRequest", - "T:LibTmux.CommandPromptRequest", - "T:LibTmux.ConfirmBeforeRequest", - "T:LibTmux.DisplayMenuRequest", - "T:LibTmux.IfShellRequest", - "T:LibTmux.ListBuffersRequest", - "T:LibTmux.PromptType", - "T:LibTmux.RunShellRequest", - "T:LibTmux.ServerAccessRequest", - "T:LibTmux.ShowMessagesMode", - "T:LibTmux.TmuxBuffer", - "T:LibTmux.TmuxMenuItem", - "T:LibTmux.TmuxWaitMode", - "T:LibTmux.UnbindKeyRequest", - "T:LibTmux.WaitForRequest", - ), - 17: ("T:LibTmux.Internal.TmuxCommandContext",), - 18: ( - "T:LibTmux.Testing.TemporaryHierarchyScope", - "T:LibTmux.Testing.TestEnvironment", - "T:LibTmux.Testing.TmuxNameGenerator", - "T:LibTmux.Testing.TmuxTestContext", - "T:LibTmux.Testing.TmuxTestFactory", - "T:LibTmux.Testing.TmuxTestOptions", - "T:LibTmux.Testing.TmuxWait", - ), -} -REQUIRED_FIELDS = ( - "Files", - "API owners", - "Shared files", - "Depends on", - "Project wiring", - "Ledger rows", - "Red behavioral test", - "RED command", - "RED evidence", - "Frameworks", - "tmux lanes", - "Ledger updates", - "Atomic commit", - "Full gate", -) -TARGET_FRAMEWORKS = {"net8.0", "net10.0"} -TMUX_LANES = {"3.2a", "3.3a", "3.4", "3.5", "3.6", "3.7a", "3.7b"} -CLOSURE_GATES: dict[str, tuple[str, ...]] = { - "Package": ( - "package metadata", - ".nupkg", - ".snupkg", - "sourcelink json", - "repository revision", - "exact dependencies", - "privacy redaction", - ), - "Public API": ( - "public api analyzer", - "parity", - "implementation", - "evidence", - "gap", - ), - "Independent review": ( - "framework design guidelines", - "python-parity", - "tmux", - "resolv", - ), - "Repository quality": ("ruff", "mypy", "pytest doctests", "docs build"), - "Diff integrity": ("run", "git diff --check"), - "Staged scope": ("staged paths", "allow-list", "exactly"), - "Clean worktree": ("require empty", "git status --porcelain"), - "Publication boundary": ( - "local commits", - "never runs push", - "tag-creation", - "provenance", - "without claiming remote publication proof", - ), - "Platform workflow configuration": ( - "linux", - "macos", - "windows", - "workflow configuration only", - "does not execute", - "runtime jobs", - ), - "macOS tmux workflow configuration": ( - "current-stable", - "macos", - "tmux integration", - "workflow configuration only", - "does not execute", - ), - "External workflow evidence": ( - "user-owned push", - "run ids", - "urls", - "not runtime evidence", - ), - "Packed consumers": ("packed consumer", "net8.0", "net10.0"), - "Executable examples": ("execute", "real-tmux", "example"), - "NativeAOT": ("publish", "execute", "trimmed nativeaot", "net8.0", "net10.0"), - "Final matrix evidence": ( - "source-bound", - "final tmux matrix", - "evidence bundle", - "clean production commit", - "evidence-only closure commit", - "head^", - "evaluated-commit tree fingerprint", - "descendant diff", - "final evidence root", - ), -} -COMPONENT_RE = re.compile(r"^## Component ([0-9]+):\s+\S.*$") -FIELD_RE = re.compile(r"^### (.+?)\s*$") -LIST_TOKEN_RE = re.compile(r"^- (?P`{1,2})(?P.+?)(?P=fence)\s*$") -EXACT_PATH_RE = re.compile( - # A plan names a file by repository-relative path. - r"^(?:(?:benchmarks|docs|eng|examples|src|tests|\.github)/" - r"(?:[A-Za-z0-9_.-]+/)*[A-Za-z0-9_.-]+" - r"|[A-Za-z0-9_.-]+\.(?:slnx|json|props|md|sh))$" -) -BUILD_BOOTSTRAP_FILES = frozenset( - { - "LibTmux.slnx", - "src/LibTmux/LibTmux.csproj", - "src/LibTmux/packages.lock.json", - "tests/LibTmux.UnitTests/LibTmux.UnitTests.csproj", - "tests/LibTmux.UnitTests/packages.lock.json", - "tests/LibTmux.IntegrationTests/LibTmux.IntegrationTests.csproj", - "tests/LibTmux.IntegrationTests/packages.lock.json", - } -) -COMPONENT_DEPENDENCIES: dict[int, tuple[str, ...]] = { - 1: ("none",), - 2: ("component 1",), - 3: ("component 1", "component 2"), - 4: ("component 1", "component 2", "component 3"), - 5: ("component 2", "component 4"), - 6: ("component 1", "component 2"), - 7: ("component 1", "component 2", "component 4", "component 5"), - 8: ("component 3", "component 4", "component 5", "component 7"), - 9: ("component 8",), - 10: ( - "component 1", - "component 2", - "component 3", - "component 4", - "component 5", - "component 7", - ), - 11: ("component 3", "component 10"), - 12: ("component 3", "component 10", "component 11"), - 13: ("component 10", "component 11", "component 12"), - 14: ("component 1", "component 2", "component 3"), - 15: ("component 1", "component 2", "component 3", "component 14"), - 16: ("component 10", "component 12", "component 13"), - 17: tuple(f"component {component}" for component in range(1, 17)), - 18: tuple(f"component {component}" for component in range(1, 18)), -} -COMPONENT_SHARED_FILES: dict[int, tuple[str, ...]] = dict.fromkeys( - COMPONENT_IDS, - ("docs/parity/parity-ledger.json",), -) -COMPONENT_SHARED_FILES[3] += ( - "eng/tmux/build-version.sh", - "eng/tmux/run-matrix.sh", - "eng/evidence/assemble_bundle.py", - "eng/evidence/tests/test_transactions.py", - "eng/parity/reconcile_versions.py", - "eng/parity/tests/test_reconcile_versions.py", - "eng/evidence/validate.py", - "eng/evidence/tests/test_validate.py", - "tests/LibTmux.IntegrationTests/Infrastructure/PtyAttachedClientScope.cs", -) -COMPONENT_SHARED_FILES[4] += ( - "src/LibTmux/Server.Identity.cs", - "src/LibTmux/Session.Identity.cs", - "src/LibTmux/Window.Identity.cs", - "src/LibTmux/Pane.Identity.cs", - "src/LibTmux/Transport/TmuxTransportLimits.cs", - "src/LibTmux/Transport/Utf8BackslashDecoder.cs", - "src/LibTmux/Internal/FormatCatalog.cs", -) -COMPONENT_SHARED_FILES[5] += ( - "src/LibTmux/Materialization/EntityMaterializationState.cs", -) -COMPONENT_SHARED_FILES[8] += ( - "LibTmux.slnx", - "src/LibTmux/LibTmux.csproj", -) -COMPONENT_SHARED_FILES[9] += ("LibTmux.slnx",) -COMPONENT_SHARED_FILES[9] += ( - "tests/LibTmux.UnitTests/LibTmux.UnitTests.csproj", - "tests/LibTmux.UnitTests/packages.lock.json", -) -VERSION_POLICY_SHARED_FILES = ( - "docs/parity/version-deltas.json", - "tests/LibTmux.IntegrationTests/Versioning/VersionParityTests.cs", - "eng/parity/reconcile_versions.py", - "eng/parity/tests/test_reconcile_versions.py", -) -VERSION_POLICY_OWNER_COMPONENTS = (10, 11, 12, 13, 15, 16) -# Policy rows stay pending until cohort closure, so a policy-owning component -# never edits the version-policy documents; declaring them shared here would -# demand a change the component has no cause to make. -_VERSION_POLICY_NAMESPACE = runpy.run_path( - str(pathlib.Path(__file__).with_name("reconcile_versions.py")) -) -VERSION_POLICY_PROOFS_BY_COMPONENT: dict[int, tuple[str, ...]] = { - component_id: tuple( - ( - f"{capability} | {test} | supported=" - f"{_VERSION_POLICY_NAMESPACE['POLICY_PROOF_CONTRACTS'][capability]['supportedBoundary']}" - " | unsupported=" - f"{_VERSION_POLICY_NAMESPACE['POLICY_PROOF_CONTRACTS'][capability]['unsupportedBoundary']}" - " | evidenceStatus=pending until cohort closure" - ) - for capability, components in _VERSION_POLICY_NAMESPACE[ - "POLICY_OWNER_COMPONENTS" - ].items() - for owner, test in zip( - components, - _VERSION_POLICY_NAMESPACE["POLICY_WRAPPER_TESTS"][capability], - strict=True, - ) - if owner == component_id - ) - for component_id in VERSION_POLICY_OWNER_COMPONENTS -} -ENTITY_SHELL_FILES = ( - "src/LibTmux/Server.cs", - "src/LibTmux/Session.cs", - "src/LibTmux/Window.cs", - "src/LibTmux/Pane.cs", - "src/LibTmux/Client.cs", -) -ENTITY_FRAGMENT_FILES: dict[int, tuple[str, ...]] = { - 1: ( - "src/LibTmux/Server.Command.cs", - "src/LibTmux/Session.Command.cs", - "src/LibTmux/Window.Command.cs", - "src/LibTmux/Pane.Command.cs", - ), - 2: ( - "src/LibTmux/Server.Identity.cs", - "src/LibTmux/Session.Identity.cs", - "src/LibTmux/Window.Identity.cs", - "src/LibTmux/Pane.Identity.cs", - ), - 3: ("src/LibTmux/Server.Version.cs",), - 5: ( - "src/LibTmux/Session.Relations.cs", - "src/LibTmux/Window.Relations.cs", - "src/LibTmux/Pane.Relations.cs", - ), - 6: ( - "src/LibTmux/Server.Environment.cs", - "src/LibTmux/Session.Environment.cs", - "src/LibTmux/Window.Environment.cs", - "src/LibTmux/Pane.Environment.cs", - ), - 7: ("src/LibTmux/Server.Collections.cs",), - 10: ( - "src/LibTmux/Server.Lifecycle.cs", - "src/LibTmux/Session.Lifecycle.cs", - ), - 11: ( - "src/LibTmux/Session.WindowNavigation.cs", - "src/LibTmux/Window.Topology.cs", - ), - 12: ( - "src/LibTmux/Window.PaneNavigation.cs", - "src/LibTmux/Pane.Operations.cs", - ), - 13: ( - "src/LibTmux/Server.Clients.cs", - "src/LibTmux/Client.Administration.cs", - ), - 14: ( - "src/LibTmux/Server.Options.cs", - "src/LibTmux/Session.Options.cs", - "src/LibTmux/Window.Options.cs", - "src/LibTmux/Pane.Options.cs", - ), - 15: ( - "src/LibTmux/Server.Hooks.cs", - "src/LibTmux/Session.Hooks.cs", - "src/LibTmux/Window.Hooks.cs", - "src/LibTmux/Pane.Hooks.cs", - ), - 16: ("src/LibTmux/Server.Utilities.cs",), -} -EXCEPTION_FILES = ( - "src/LibTmux/Exceptions/LibTmuxException.cs", - "src/LibTmux/Exceptions/TmuxCommandException.cs", - "src/LibTmux/Exceptions/TmuxCommandNotFoundException.cs", - "src/LibTmux/Exceptions/TmuxTransportException.cs", - "src/LibTmux/Exceptions/TmuxOperationCanceledException.cs", - "src/LibTmux/Exceptions/TmuxCleanupException.cs", - "src/LibTmux/Exceptions/TmuxWaitTimeoutException.cs", - "src/LibTmux/Exceptions/StaleServerGenerationException.cs", - "src/LibTmux/Exceptions/TmuxObjectNotFoundException.cs", - "src/LibTmux/Exceptions/TmuxVersionTooLowException.cs", - "src/LibTmux/Exceptions/IncompleteSnapshotException.cs", - "src/LibTmux/Exceptions/UnsupportedQueryExpressionException.cs", - "src/LibTmux/Exceptions/TmuxSessionExistsException.cs", - "src/LibTmux/Exceptions/TmuxWindowException.cs", - "src/LibTmux/Exceptions/TmuxPaneException.cs", - "src/LibTmux/Exceptions/TmuxOptionException.cs", -) -# Every tmux command passes through one dispatcher, so the diagnostics it -# records belong there rather than repeated in each entity. -DIAGNOSTIC_SHARED_FILES = ( - "src/LibTmux/Transport/TmuxCommandDispatcher.cs", - "src/LibTmux/Connection/TmuxConnection.cs", -) -COMPONENT_SHARED_FILES[17] += DIAGNOSTIC_SHARED_FILES -COMPONENT_SHARED_FILES[18] += ( - "LibTmux.slnx", - "Directory.Packages.props", - "src/LibTmux/LibTmux.csproj", - "src/LibTmux/packages.lock.json", - "src/LibTmux.Query.Json/LibTmux.Query.Json.csproj", - "src/LibTmux.Query.Json/packages.lock.json", -) -FOUNDATIONAL_FILES = frozenset( - { - *ENTITY_SHELL_FILES, - *EXCEPTION_FILES[:7], - "src/LibTmux/Transport/TmuxCommandDispatcher.cs", - "src/LibTmux/Transport/TmuxCommandFailure.cs", - "src/LibTmux/Transport/TmuxTransportLimits.cs", - "tests/LibTmux.IntegrationTests/Infrastructure/RawTmuxTestContext.cs", - "tests/LibTmux.IntegrationTests/Infrastructure/ControlModeClientScope.cs", - "tests/LibTmux.IntegrationTests/Infrastructure/PtyAttachedClientScope.cs", - "tests/LibTmux.TestChild/LibTmux.TestChild.csproj", - "tests/LibTmux.TestChild/packages.lock.json", - "tests/LibTmux.TestChild/Program.cs", - } -) -PROJECT_WIRING: dict[int, tuple[str, ...]] = { - 1: ( - "mise exec -- dotnet sln LibTmux.slnx add src/LibTmux/LibTmux.csproj tests/LibTmux.UnitTests/LibTmux.UnitTests.csproj tests/LibTmux.IntegrationTests/LibTmux.IntegrationTests.csproj tests/LibTmux.TestChild/LibTmux.TestChild.csproj", - "mise exec -- dotnet add tests/LibTmux.UnitTests/LibTmux.UnitTests.csproj reference src/LibTmux/LibTmux.csproj", - "mise exec -- dotnet add tests/LibTmux.IntegrationTests/LibTmux.IntegrationTests.csproj reference src/LibTmux/LibTmux.csproj", - "src/LibTmux/LibTmux.csproj declares InternalsVisibleTo for LibTmux.UnitTests and LibTmux.IntegrationTests", - "tests/LibTmux.UnitTests/LibTmux.UnitTests.csproj references Microsoft.CodeAnalysis.CSharp so EntityShellTests parses source syntax instead of reflecting partial declarations", - "Server, Session, Window, and Pane shells expose an internal dispatcher plus default-target constructor seam used by their Component 1 command fragments before public Open and typed IDs arrive", - ), - 8: ( - "mise exec -- dotnet sln LibTmux.slnx add src/LibTmux.Generators/LibTmux.Generators.csproj", - "src/LibTmux/LibTmux.csproj references src/LibTmux.Generators/LibTmux.Generators.csproj with OutputItemType=Analyzer and ReferenceOutputAssembly=false", - ), - 9: ( - "mise exec -- dotnet sln LibTmux.slnx add src/LibTmux.Query.Json/LibTmux.Query.Json.csproj", - "mise exec -- dotnet add src/LibTmux.Query.Json/LibTmux.Query.Json.csproj reference src/LibTmux/LibTmux.csproj", - "mise exec -- dotnet add tests/LibTmux.UnitTests/LibTmux.UnitTests.csproj reference src/LibTmux.Query.Json/LibTmux.Query.Json.csproj", - ), - 18: ( - "mise exec -- dotnet sln LibTmux.slnx add tests/LibTmux.AotSmoke/LibTmux.AotSmoke.csproj tests/LibTmux.PackageConsumer/LibTmux.PackageConsumer.csproj examples/LibTmux.Examples/LibTmux.Examples.csproj", - "mise exec -- dotnet add examples/LibTmux.Examples/LibTmux.Examples.csproj reference src/LibTmux/LibTmux.csproj src/LibTmux.Query.Json/LibTmux.Query.Json.csproj", - "Directory.Packages.props declares exact central PackageVersion entries for LibTmux and LibTmux.Query.Json at [0.1.0-local]", - "tests/LibTmux.AotSmoke/LibTmux.AotSmoke.csproj and tests/LibTmux.PackageConsumer/LibTmux.PackageConsumer.csproj declare versionless PackageReference entries for LibTmux and LibTmux.Query.Json so Central Package Management supplies [0.1.0-local]", - "src/LibTmux.Query.Json/LibTmux.Query.Json.csproj declares its LibTmux ProjectReference only when UsePackedLibTmux is not true", - "src/LibTmux.Query.Json/LibTmux.Query.Json.csproj declares a versionless LibTmux PackageReference only when UsePackedLibTmux is true so Central Package Management supplies exactly [0.1.0-local]", - "src/LibTmux.Query.Json/LibTmux.Query.Json.csproj sets NuGetLockFilePath to $(MSBuildProjectDirectory)/packages.packed.lock.json only when UsePackedLibTmux is true and otherwise uses packages.lock.json", - "default and packed Query.Json locked restores use distinct owned lock files for their mutually exclusive dependency graphs", - "src/LibTmux/LibTmux.csproj and src/LibTmux.Query.Json/LibTmux.Query.Json.csproj declare PackageReference Include=Microsoft.CodeAnalysis.PublicApiAnalyzers with PrivateAssets=all", - ), -} -COMPONENT_ONE_TRANSPORT_CONTRACT = ( - "ExecuteCommandAsync accepts exactly one logical tmux command; a literal ; argument remains data and is never a structural separator", - "Only the internal TmuxCommandRequest and transport overload represent structural command groups with typed separators; no public grouping overload exists", - "TmuxCommandResult.Arguments is a defensively copied logical argument sequence that excludes the tmux binary, connection prefixes, and guard arguments, and record equality compares the sequence deeply", - "StandardOutput and StandardError preserve exact bytes while line projections normalize CRLF and lone CR to LF with Python universal-newline behavior", - "ThrowIfFailed throws for any nonempty projected stderr regardless of exit code; has-session stderr leniency is dispatcher policy and never mutates raw bytes", - "TmuxTransportLimits is an internal seam with MaxArguments=4096, MaxCapturedBytesPerStream=64 MiB, and CleanupTimeout=5s defaults; argument or stream overflow raises TmuxTransportException and every post-start failure performs bounded cleanup", - "PtyAttachedClientScope uses script-backed PTY execution on Linux and macOS, or a behaviorally equivalent PTY implementation, and has an executable smoke test", - "TmuxProcessTransport injects internal launcher and clock seams plus TmuxTransportLimits so tests control process start, deadlines, pumps, descendants, and cleanup faults without wall-clock sleeps", - "LibTmux.TestChild exposes deterministic modes for arbitrary concurrent raw stdout and stderr, invalid bytes, partial final output, nonzero exit, a held pump, descendant survival, and cleanup faults", - "Server.cs, Session.cs, Window.cs, Pane.cs, and Client.cs remain declaration-only; dispatcher fields and internal constructors for command-capable entities live only in Server.Command.cs, Session.Command.cs, Window.Command.cs, and Pane.Command.cs", -) -C4_MATERIALIZATION_CONTRACT = ( - "Each row carries exactly projection.Fields.Count values, each terminated by FormatProjection.RowSeparator; wire names are not sent and values are read positionally; every field is expanded exactly once, because a byte-count prefix would expand it twice and a field that moved in between would desynchronise the payload; copied value bytes remain undecoded until Utf8BackslashDecoder", - "tmux LF separates rows, CRLF is accepted, and a complete final row may end at EOF; embedded CR and LF remain value data", - "Empty values map to null with their key present; a row that ends before every field is read, a value that never closes, an oversized value, and a row not terminated by a newline each throws InvalidDataException; returned memories are copied", - "TmuxTransportLimits adds MaxFramedFieldBytes=64 MiB by default, requires a positive value no greater than MaxCapturedBytesPerStream, and SeparatedRowFramer enforces it per value", - "MaterializationQuery maps low-level InvalidDataException to TmuxTransportException carrying the logical tmux arguments", - "FormatCatalog.ObjProjection contains 178 Obj fields; the existing catalog union is 82 with overlap 72, adds 106 fields, and yields 188 combined fields", - "Format scopes contain universal=9, session=23, window=34, pane=70, client=25, buffer=3, event=9, and context=5 fields", - "client_uid, client_user, pane_dead_signal, and pane_dead_time require tmux 3.3; the eleven approved 3.7 fields require tmux 3.7; every other field requires tmux 3.2a", - "FormatProjection.Create emits 123/125/136 fields for sessions, windows, and panes at 3.2a/3.3a-3.6/3.7a+, and 146/150/161 fields for clients; FramedFieldCount is twice Fields.Count", - "MaterializationQuery.FetchAsync returns all decoded dictionaries; FetchOneAsync uses the canonical tmux session for window and pane lookup, returns one dictionary, distinguishes a missing target from an unreachable server, and accepts a final CancellationToken", - "Materializer dictionary overloads create Session, Window, and Pane handles with explicit MaterializationContext.Server ownership after Utf8BackslashDecoder projects copied raw values", - "Private EntityMaterializationState carries copied raw fields, the owning Server, parent SessionId and WindowId identities, a Window SessionWindowEdge, and default uncaptured relation slots; an internal replacement or factory path lets Component 5 assign edge ordinals and captured relations without editing Component 4 files", - "Every materialized row carries universal pid and start_time; MaterializationQuery rejects an unmaterialized MaterializationContext.Server.Generation before live acquisition, and both MaterializationQuery and Materializer reject parsed generation unequal to the owner with StaleServerGenerationException; MaterializationTests.Materializer_uses_server_context_and_returns_typed_raw_fields proves this owner and generation validation", -) -SOLUTION_RESTORE_PAIR = ( - "mise exec -- dotnet restore LibTmux.slnx", - "mise exec -- dotnet restore LibTmux.slnx --locked-mode", -) -RED_BOOTSTRAP: dict[int, tuple[str, ...]] = { - 1: ( - "Create compile-ready C1 production signature stubs, TestChild modes, the selected behavioral test, require_red.py, and require_red tests before restore; do not implement the behavior and never hand-author lock files", - *SOLUTION_RESTORE_PAIR, - "uv run pytest eng/parity/tests/test_require_red.py", - ), - 8: ( - "Create compile-ready generator, core, and test signature stubs plus the selected behavioral test before restore; do not implement the behavior and never hand-author lock files", - *SOLUTION_RESTORE_PAIR, - ), - 9: ( - "Create compile-ready Query.Json and unit-test signature stubs plus the selected behavioral test before restore; do not implement the behavior and never hand-author lock files", - *SOLUTION_RESTORE_PAIR, - ), -} -RED_RUNNER_CONTRACT = ( - "require_red.py invokes Microsoft Testing Platform with --no-restore, --filter-method for the exact declared --test identity, and the xUnit TRX reporter at the exact evidence path", - "require_red.py accepts only a nonzero test-process exit with well-formed TRX containing at least one executed test and the selected behavioral test exactly once with outcome Failed", - "require_red.py rejects build or discovery failures, zero tests, all skipped tests, aborted or canceled runs, malformed or missing TRX, unexpected test identities, and successful test runs", - "Every component RED command invokes require_red.py directly with Release, --no-restore, one exact --test identity, and its retained TRX path; no shell negation or failure-swallowing command may decide RED", -) -RED_EVIDENCE_FRESHNESS_CONTRACT = ( - "require_red.py removes any pre-existing evidence path before invoking dotnet test and accepts only a newly created TRX from that invocation", -) -TMUX_37_TRANSITION_PROOF_CONTRACT = ( - "Build one transition tmux 3.7 binary with eng/tmux/build-version.sh and require tmux -V = tmux 3.7.", - "The tmux 3.7 transition proof runs only for the explicit capability cohort 0001; directory names never select behavior, and that cohort rejects the advisory master lane.", - 'The cohort-bound environment records capabilityCohort="0001" and excludes only its exact evidence output root from source-state and source-fingerprint calculations.', - 'run-matrix.sh sets LIBTMUX_TRANSITION_TMUX_3_7 to the verified 3.7 binary and writes its full source commit as transitionTmuxSourceCommits["3.7"] in environment.json.', - "VersionParityTests.BreakPane37Workaround proves net8.0 and net10.0 behavior for exact 3.7 and 3.7a, applying the workaround only to 3.7.", - "The redacted break-pane transition transcript has exactly four records: net8.0/3.7, net8.0/3.7a, net10.0/3.7, and net10.0/3.7a; each records observed tmux version, workaround state, and behavioral outcome.", - "reconcile_versions.py validates the raw break-pane transcript and its 3.7 transition commit against the required 3.7a matrix source commit without attaching wrapper-policy evidence to the break-pane row.", - "Capability cohort 0001 verifies exactly the five upstream protocol observations; all 34 command-policy rows remain evidenceStatus=pending with no evidence field until their policyOwnerComponents implement wrapper-level proofs.", - "The two hook flag rows retain introducedIn=3.2 source history, declare supportRange=baseline across the supported tmux range beginning at 3.2a, and set unsupportedBehavior=not_applicable_below_supported_floor.", - "Component 3 may independently mark its 50 parity-ledger rows implemented and verified; version-delta policy status does not gate or inherit those ledger transitions.", - "results.ndjson remains exactly the seven required tmux versions crossed with net8.0 and net10.0; 3.7 is not a matrix row.", -) -C1_FAILURE_CORPUS_CONTRACT = ( - "A missing configured tmux binary throws TmuxCommandNotFoundException whose TmuxBinaryPath is the configured executable path", - "Pre-start cancellation throws OperationCanceledException carrying the caller token and starts no process", - "Post-start cancellation throws TmuxOperationCanceledException with CommandMayHaveExecuted=true and the direct client PID", - "Post-start cancellation reaps only the direct client while the TestChild descendant-survival mode proves the descendant remains alive", - "Cleanup failure throws TmuxCleanupException with the original cancellation, client PID, and cleanup failure", - "Invalid UTF-8 projection escapes each invalid byte independently as lowercase \\xNN while StandardOutput and StandardError remain byte-exact", -) -REQUIRED_PROJECT_FILES: dict[int, frozenset[str]] = { - 1: frozenset( - { - "tests/LibTmux.UnitTests/Entities/EntityShellTests.cs", - } - ), - 3: frozenset( - { - "src/LibTmux/Internal/CommandFlagCatalog.cs", - "src/LibTmux/Internal/FormatCatalog.cs", - "src/LibTmux/Internal/FormatFieldDescriptor.cs", - } - ), - 4: frozenset( - { - "src/LibTmux/Materialization/SeparatedRowFramer.cs", - "src/LibTmux/Materialization/MaterializationContext.cs", - } - ), - 8: frozenset( - { - "src/LibTmux.Generators/LibTmux.Generators.csproj", - "src/LibTmux.Generators/FieldCatalogGenerator.cs", - "src/LibTmux.Generators/packages.lock.json", - } - ), - 9: frozenset( - { - "src/LibTmux.Query.Json/LibTmux.Query.Json.csproj", - "src/LibTmux.Query.Json/QueryJsonSerializerContext.cs", - "src/LibTmux.Query.Json/QueryDocumentJsonConverter.cs", - "src/LibTmux.Query.Json/libtmux-query-v1.schema.json", - "src/LibTmux.Query.Json/packages.lock.json", - } - ), - 10: frozenset( - { - "src/LibTmux/Requests/AttachSessionRequest.cs", - } - ), - 11: frozenset( - { - "src/LibTmux/Requests/DisplayMessageRequest.cs", - } - ), - 12: frozenset( - { - "src/LibTmux/Requests/DisplayPopupRequest.cs", - } - ), - 18: frozenset( - { - ".github/workflows/dotnet.yml", - ".github/workflows/dotnet-tmux.yml", - "README.md", - "src/LibTmux/PublicAPI.Shipped.txt", - "src/LibTmux/PublicAPI.Unshipped.txt", - "src/LibTmux.Query.Json/PublicAPI.Shipped.txt", - "src/LibTmux.Query.Json/PublicAPI.Unshipped.txt", - "eng/parity/verify_workflows.py", - "eng/parity/tests/test_workflows.py", - "eng/parity/inspect_packages.py", - "eng/parity/tests/test_packages.py", - "eng/evidence/verify_source_binding.py", - "eng/evidence/tests/test_source_binding.py", - "src/LibTmux.Query.Json/packages.packed.lock.json", - "tests/LibTmux.AotSmoke/LibTmux.AotSmoke.csproj", - "tests/LibTmux.AotSmoke/packages.lock.json", - "tests/LibTmux.AotSmoke/Program.cs", - "tests/LibTmux.PackageConsumer/LibTmux.PackageConsumer.csproj", - "tests/LibTmux.PackageConsumer/packages.lock.json", - "tests/LibTmux.PackageConsumer/Program.cs", - "tests/LibTmux.PackageConsumer/NuGet.config", - "tests/LibTmux.IntegrationTests/Packaging/PackageClosureTests.cs", - "tests/LibTmux.UnitTests/Packaging/PublicApiContractTests.cs", - "tests/LibTmux.UnitTests/Packaging/WorkflowContractTests.cs", - "tests/LibTmux.IntegrationTests/Testing/TestingHelpersTests.cs", - "examples/LibTmux.Examples/LibTmux.Examples.csproj", - "examples/LibTmux.Examples/packages.lock.json", - "examples/LibTmux.Examples/Program.cs", - } - ), -} -PUBLIC_API_FILE_BINDINGS: dict[str, tuple[int, str]] = { - "T:LibTmux.Internal.CommandFlagCatalog": ( - 3, - "src/LibTmux/Internal/CommandFlagCatalog.cs", - ), - "T:LibTmux.Internal.FormatCatalog": ( - 3, - "src/LibTmux/Internal/FormatCatalog.cs", - ), - "T:LibTmux.Internal.FormatFieldDescriptor": ( - 3, - "src/LibTmux/Internal/FormatFieldDescriptor.cs", - ), - "T:LibTmux.OptionScope": ( - 3, - "src/LibTmux/Constants/TmuxEnums.cs", - ), - "T:LibTmux.PaneDirection": ( - 3, - "src/LibTmux/Constants/TmuxEnums.cs", - ), - "T:LibTmux.ResizeDirection": ( - 3, - "src/LibTmux/Constants/TmuxEnums.cs", - ), - "T:LibTmux.WindowDirection": ( - 3, - "src/LibTmux/Constants/TmuxEnums.cs", - ), - "T:LibTmux.Internal.SeparatedRowFramer": ( - 4, - "src/LibTmux/Materialization/SeparatedRowFramer.cs", - ), - "T:LibTmux.Internal.FormatProjection": ( - 4, - "src/LibTmux/Materialization/FormatProjection.cs", - ), - "T:LibTmux.Internal.MaterializationContext": ( - 4, - "src/LibTmux/Materialization/MaterializationContext.cs", - ), - "T:LibTmux.Internal.MaterializationQuery": ( - 4, - "src/LibTmux/Materialization/TmuxMaterializationQuery.cs", - ), - "T:LibTmux.Internal.Materializer": ( - 4, - "src/LibTmux/Materialization/TmuxMaterializer.cs", - ), - "T:LibTmux.Internal.ServerProjection": ( - 4, - "src/LibTmux/Materialization/FormatProjection.cs", - ), - "T:LibTmux.Internal.ServerProjectionDescriptor": ( - 4, - "src/LibTmux/Materialization/FormatProjection.cs", - ), - "T:LibTmux.TmuxColorMode": ( - 2, - "src/LibTmux/TmuxColorMode.cs", - ), - "T:LibTmux.AttachSessionRequest": ( - 10, - "src/LibTmux/Requests/AttachSessionRequest.cs", - ), - "T:LibTmux.DisplayMessageRequest": ( - 11, - "src/LibTmux/Requests/DisplayMessageRequest.cs", - ), - "T:LibTmux.DisplayPopupRequest": ( - 12, - "src/LibTmux/Requests/DisplayPopupRequest.cs", - ), -} -PUBLIC_API_MEMBER_FILE_BINDINGS: dict[str, tuple[int, str]] = { - "P:LibTmux.Server.Version": ( - 3, - "src/LibTmux/Server.Version.cs", - ), -} -FORMAT_SEPARATOR_CONTRACT: dict[str, t.Any] = { - "componentId": 4, - "destinationStatus": "excluded", - "csharpDestination": None, - "replacement": ("M:LibTmux.Internal.SeparatedRowFramer.Decode(ReadOnlySpan)"), - "exclusionReason": ( - "Delimiter-based row framing is replaced by the raw-byte protocol approved " - "in ADR 0001." - ), - "testPath": ( - "tests/LibTmux.IntegrationTests/Parity/Component04ParityTests.cs" - ), -} -FORBIDDEN_PRODUCTION_FILES = frozenset( - { - "src/LibTmux/Materialization/FieldCatalog.cs", - "src/LibTmux/Requests/AttachClientRequest.cs", - "src/LibTmux/Requests/DisplayOverlayRequest.cs", - "src/LibTmux/Internal/XunitTmuxHarness.cs", - } -) -CORE_RESTORE_PAIR = ( - "mise exec -- dotnet restore src/LibTmux/LibTmux.csproj", - "mise exec -- dotnet restore src/LibTmux/LibTmux.csproj --locked-mode", -) -JSON_DEFAULT_RESTORE_PAIR = ( - "mise exec -- dotnet restore src/LibTmux.Query.Json/LibTmux.Query.Json.csproj", - "mise exec -- dotnet restore src/LibTmux.Query.Json/LibTmux.Query.Json.csproj --locked-mode", -) -JSON_PACKED_RESTORE_PAIR = ( - "mise exec -- dotnet restore src/LibTmux.Query.Json/LibTmux.Query.Json.csproj --source artifacts/packages --source https://api.nuget.org/v3/index.json -p:UsePackedLibTmux=true", - "mise exec -- dotnet restore src/LibTmux.Query.Json/LibTmux.Query.Json.csproj --locked-mode --source artifacts/packages --source https://api.nuget.org/v3/index.json -p:UsePackedLibTmux=true", -) -LOCAL_FEED_SOLUTION_RESTORE_PAIR = ( - "mise exec -- dotnet restore LibTmux.slnx --source artifacts/packages --source https://api.nuget.org/v3/index.json", - "mise exec -- dotnet restore LibTmux.slnx --locked-mode --source artifacts/packages --source https://api.nuget.org/v3/index.json", -) -PACKAGE_COMMANDS = ( - *CORE_RESTORE_PAIR, - *JSON_DEFAULT_RESTORE_PAIR, - "mise exec -- dotnet pack src/LibTmux/LibTmux.csproj --configuration Release --no-restore --output artifacts/packages -p:PackageVersion=0.1.0-local", - *JSON_PACKED_RESTORE_PAIR, - "mise exec -- dotnet pack src/LibTmux.Query.Json/LibTmux.Query.Json.csproj --configuration Release --no-restore --output artifacts/packages -p:PackageVersion=0.1.0-local -p:UsePackedLibTmux=true", - *LOCAL_FEED_SOLUTION_RESTORE_PAIR, - "unzip -l artifacts/packages/LibTmux.0.1.0-local.nupkg", - "unzip -l artifacts/packages/LibTmux.0.1.0-local.snupkg", - "unzip -l artifacts/packages/LibTmux.Query.Json.0.1.0-local.nupkg", - "unzip -l artifacts/packages/LibTmux.Query.Json.0.1.0-local.snupkg", - "unzip -p artifacts/packages/LibTmux.0.1.0-local.nupkg LibTmux.nuspec", - "unzip -p artifacts/packages/LibTmux.Query.Json.0.1.0-local.nupkg LibTmux.Query.Json.nuspec", - "uv run python eng/parity/inspect_packages.py --artifacts artifacts/packages --repository .", -) -PUBLIC_API_BUILD_COMMAND = "mise exec -- dotnet build LibTmux.slnx --configuration Release --no-restore --warnaserror" -PACKED_CONSUMER_COMMANDS = ( - "mise exec -- dotnet run --project tests/LibTmux.PackageConsumer/LibTmux.PackageConsumer.csproj --configuration Release --framework net8.0 --no-build", - "mise exec -- dotnet run --project tests/LibTmux.PackageConsumer/LibTmux.PackageConsumer.csproj --configuration Release --framework net10.0 --no-build", -) -EXAMPLE_COMMANDS = ( - "mise exec -- dotnet run --project examples/LibTmux.Examples/LibTmux.Examples.csproj --configuration Release --framework net8.0 --no-build", - "mise exec -- dotnet run --project examples/LibTmux.Examples/LibTmux.Examples.csproj --configuration Release --framework net10.0 --no-build", -) -AOT_RID_RESTORE_PAIR = ( - "mise exec -- dotnet restore tests/LibTmux.AotSmoke/LibTmux.AotSmoke.csproj --runtime linux-x64 --source artifacts/packages --source https://api.nuget.org/v3/index.json", - "mise exec -- dotnet restore tests/LibTmux.AotSmoke/LibTmux.AotSmoke.csproj --locked-mode --runtime linux-x64 --source artifacts/packages --source https://api.nuget.org/v3/index.json", -) -AOT_COMMANDS = ( - *AOT_RID_RESTORE_PAIR, - "mise exec -- dotnet publish tests/LibTmux.AotSmoke/LibTmux.AotSmoke.csproj --configuration Release --framework net8.0 --runtime linux-x64 --self-contained --no-restore -p:PublishAot=true -p:PublishTrimmed=true --output artifacts/aot/net8.0", - "mise exec -- dotnet publish tests/LibTmux.AotSmoke/LibTmux.AotSmoke.csproj --configuration Release --framework net10.0 --runtime linux-x64 --self-contained --no-restore -p:PublishAot=true -p:PublishTrimmed=true --output artifacts/aot/net10.0", - "artifacts/aot/net8.0/LibTmux.AotSmoke", - "artifacts/aot/net10.0/LibTmux.AotSmoke", -) -C18_RESTORE_PAIRS = ( - CORE_RESTORE_PAIR, - JSON_DEFAULT_RESTORE_PAIR, - JSON_PACKED_RESTORE_PAIR, - LOCAL_FEED_SOLUTION_RESTORE_PAIR, - AOT_RID_RESTORE_PAIR, -) -WORKFLOW_CONFIGURATION_COMMANDS = ( - "uv run python eng/parity/verify_workflows.py --lane platform .github/workflows/dotnet.yml", - "uv run python eng/parity/verify_workflows.py --lane macos-tmux .github/workflows/dotnet-tmux.yml", -) -FINAL_EVIDENCE_ROOT = "docs/parity/evidence/final" -C3_EVIDENCE_ROOT = "docs/parity/evidence/0001" -VERSION_DELTA_PATH = "docs/parity/version-deltas.json" -NON_RETAINED_MATRIX_COMMAND = "eng/tmux/run-matrix.sh tests/LibTmux.IntegrationTests/LibTmux.IntegrationTests.csproj" -RETAINED_MATRIX_COMMAND = "eng/tmux/run-matrix.sh --capability-cohort closure --evidence-dir docs/parity/evidence/final tests/LibTmux.IntegrationTests/LibTmux.IntegrationTests.csproj" -VALIDATE_FINAL_MATRIX_COMMAND = "uv run python eng/evidence/validate.py --phase matrix docs/parity/evidence/final" -PRECOMMIT_SOURCE_BINDING_COMMAND = "uv run python eng/evidence/verify_source_binding.py --evidence docs/parity/evidence/final --repository . --require-evaluated-commit HEAD --allow-dirty-root docs/parity/evidence/final --fingerprint-mode evaluated-commit-tree" -POSTCOMMIT_SOURCE_BINDING_COMMAND = "uv run python eng/evidence/verify_source_binding.py --evidence docs/parity/evidence/final --repository . --require-evaluated-commit HEAD^ --require-descendant-root docs/parity/evidence/final --require-descendant-path docs/parity/version-deltas.json --fingerprint-mode evaluated-commit-tree" -FINAL_RECONCILE_COMMAND = "uv run python eng/parity/reconcile_versions.py --evidence docs/parity/evidence/final/results.ndjson --write" -PERSISTED_RECONCILE_COMMAND = "uv run python eng/parity/reconcile_versions.py" -EVIDENCE_STAGE_COMMAND = "git add -- docs/parity/evidence/final docs/parity/version-deltas.json" -EVIDENCE_SCOPE_COMMAND = "uv run python eng/parity/verify_production_plan.py --phase closure --verify-final-evidence-staged-scope docs/superpowers/plans/2026-08-09-libtmux-csharp-production.md" -EVIDENCE_COMMIT_COMMAND = "printf '%s\\n' 'Evidence(docs[closure]): Close policy proof' '' 'why: Bind retained compatibility evidence and reconciled policy status to the source commit.' '' what: '- Record the clean source commit and source fingerprint.' '- Retain the required tmux and framework lanes.' '- Reconcile wrapper-policy evidence.' | git commit --file -" -SOURCE_WORKTREE_CLEAN_COMMAND = 'test -z "$(git status --porcelain)"' -FINAL_MATRIX_COMMANDS = ( - RETAINED_MATRIX_COMMAND, - VALIDATE_FINAL_MATRIX_COMMAND, - PRECOMMIT_SOURCE_BINDING_COMMAND, - FINAL_RECONCILE_COMMAND, - PERSISTED_RECONCILE_COMMAND, - EVIDENCE_STAGE_COMMAND, - EVIDENCE_SCOPE_COMMAND, - "git diff --cached --check", - EVIDENCE_COMMIT_COMMAND, - POSTCOMMIT_SOURCE_BINDING_COMMAND, - SOURCE_WORKTREE_CLEAN_COMMAND, -) -C3_RETAINED_MATRIX_COMMAND = "eng/tmux/run-matrix.sh --capability-cohort 0001 --evidence-dir docs/parity/evidence/0001 tests/LibTmux.IntegrationTests/LibTmux.IntegrationTests.csproj" -C3_VALIDATE_MATRIX_COMMAND = "uv run python eng/evidence/validate.py --phase matrix docs/parity/evidence/0001" -C3_PRECOMMIT_SOURCE_BINDING_COMMAND = "uv run python eng/evidence/verify_source_binding.py --evidence docs/parity/evidence/0001 --repository . --require-evaluated-commit HEAD --allow-dirty-root docs/parity/evidence/0001 --fingerprint-mode evaluated-commit-tree" -C3_RECONCILE_COMMAND = "uv run python eng/parity/reconcile_versions.py --evidence docs/parity/evidence/0001/results.ndjson --write" -C3_EVIDENCE_STAGE_COMMAND = ( - "git add -- docs/parity/evidence/0001 docs/parity/version-deltas.json" -) -C3_EVIDENCE_SCOPE_COMMAND = "uv run python eng/parity/verify_production_plan.py --phase component --component 3 --verify-retained-evidence-staged-scope docs/superpowers/plans/2026-08-09-libtmux-csharp-production.md" -C3_EVIDENCE_COMMIT_COMMAND = "printf '%s\\n' 'Evidence(docs[versioning]): Retain cohort 0001' '' 'why: Bind protocol evidence and reconciled observations to the Component 3 source commit.' '' what: '- Retain the exact protocol cohort.' '- Reconcile the five protocol observations.' | git commit --file -" -C3_POSTCOMMIT_SOURCE_BINDING_COMMAND = "uv run python eng/evidence/verify_source_binding.py --evidence docs/parity/evidence/0001 --repository . --require-evaluated-commit HEAD^ --require-descendant-root docs/parity/evidence/0001 --require-descendant-path docs/parity/version-deltas.json --fingerprint-mode evaluated-commit-tree" -C3_EVIDENCE_COMMANDS = ( - C3_RETAINED_MATRIX_COMMAND, - C3_VALIDATE_MATRIX_COMMAND, - C3_PRECOMMIT_SOURCE_BINDING_COMMAND, - C3_RECONCILE_COMMAND, - PERSISTED_RECONCILE_COMMAND, - C3_EVIDENCE_STAGE_COMMAND, - C3_EVIDENCE_SCOPE_COMMAND, - "git diff --cached --check", - C3_EVIDENCE_COMMIT_COMMAND, - C3_POSTCOMMIT_SOURCE_BINDING_COMMAND, - SOURCE_WORKTREE_CLEAN_COMMAND, -) -EVIDENCE_CLOSURE_TAILS: dict[int, tuple[str, ...]] = { - 3: (SOURCE_WORKTREE_CLEAN_COMMAND, *C3_EVIDENCE_COMMANDS), - 18: (SOURCE_WORKTREE_CLEAN_COMMAND, *FINAL_MATRIX_COMMANDS), -} -RETAINED_EVIDENCE_SCOPES: dict[int, tuple[str, ...]] = { - 3: (C3_EVIDENCE_ROOT, VERSION_DELTA_PATH), -} -ROOT_QUALITY_COMMANDS = ( - "uv run ruff format --check .", - "uv run ruff check .", - "uv run mypy", - "uv run mypy eng/parity", - "uv run mypy eng/evidence", - "uv run pytest --doctest-modules", - "just build-docs", -) -FORBIDDEN_ROOT_QUALITY_COMMANDS = frozenset({"uv run mypy ."}) -PUBLICATION_PROVENANCE_COMMANDS = ( - "git branch --show-current", - "git rev-parse HEAD", - "git tag --points-at HEAD", - "git status --short --branch", -) -STAGED_PATH_ERROR = "staged paths cannot be inspected" -REQUIRED_GATE_COMMANDS: dict[int, tuple[str, ...]] = { - 3: ( - NON_RETAINED_MATRIX_COMMAND, - *C3_EVIDENCE_COMMANDS[:-1], - ), - 18: ( - *PACKAGE_COMMANDS, - PUBLIC_API_BUILD_COMMAND, - *PACKED_CONSUMER_COMMANDS, - *EXAMPLE_COMMANDS, - *AOT_COMMANDS, - *WORKFLOW_CONFIGURATION_COMMANDS, - NON_RETAINED_MATRIX_COMMAND, - *( - command - for command in FINAL_MATRIX_COMMANDS - if command - not in {"git diff --cached --check", SOURCE_WORKTREE_CLEAN_COMMAND} - ), - ), -} -REQUIRED_CLOSURE_COMMANDS: dict[str, tuple[str, ...]] = { - "Package": PACKAGE_COMMANDS, - "Public API": ( - PUBLIC_API_BUILD_COMMAND, - "uv run python eng/parity/verify_production_plan.py --phase closure docs/superpowers/plans/2026-08-09-libtmux-csharp-production.md", - ), - "Repository quality": ROOT_QUALITY_COMMANDS, - "Diff integrity": ("git diff --check",), - "Clean worktree": ('test -z "$(git status --porcelain)"',), - "Publication boundary": PUBLICATION_PROVENANCE_COMMANDS, - "Platform workflow configuration": (WORKFLOW_CONFIGURATION_COMMANDS[0],), - "macOS tmux workflow configuration": (WORKFLOW_CONFIGURATION_COMMANDS[1],), - "Packed consumers": PACKED_CONSUMER_COMMANDS, - "Executable examples": EXAMPLE_COMMANDS, - "NativeAOT": AOT_COMMANDS, - "Final matrix evidence": ( - VALIDATE_FINAL_MATRIX_COMMAND, - POSTCOMMIT_SOURCE_BINDING_COMMAND, - ), -} -REQUIRED_RED_TESTS: dict[int, tuple[str, ...]] = { - 1: ( - "EntityShellTests.Canonical_entities_are_public_sealed_partial_before_members_are_added", - "TmuxProcessTransportTests.Preserves_raw_bytes_and_projects_universal_newlines", - "TmuxProcessTransportTests.Treats_public_semicolon_as_data_and_internal_typed_separator_as_structure", - "TmuxProcessTransportTests.Defensively_copies_logical_arguments_and_uses_deep_record_equality", - "TmuxProcessTransportTests.Enforces_transport_limits_and_bounded_cleanup", - "TmuxProcessTransportTests.ThrowIfFailed_observes_projected_stderr_without_mutating_raw_bytes", - "TmuxProcessTransportTests.Injects_launcher_clock_and_limits_without_wall_clock_sleeps", - "TmuxProcessTransportTests.Missing_binary_throws_TmuxCommandNotFoundException_with_configured_path", - "TmuxProcessTransportTests.Pre_start_cancellation_throws_OperationCanceledException_with_caller_token_without_starting_process", - "TmuxProcessTransportTests.Post_start_cancellation_throws_TmuxOperationCanceledException_with_true_execution_risk_and_client_pid", - "TmuxProcessTransportTests.Cleanup_failure_throws_TmuxCleanupException_with_original_context", - "TmuxProcessTransportTests.Invalid_utf8_projects_each_bad_byte_as_lowercase_hex_escape", - "ProcessTransportTests.Pty_attached_client_scope_uses_real_pty", - "ProcessTransportTests.Test_child_preserves_concurrent_raw_stdout_and_stderr", - "ProcessTransportTests.Test_child_preserves_invalid_bytes", - "ProcessTransportTests.Test_child_projects_partial_final_output", - "ProcessTransportTests.Test_child_returns_nonzero_exit", - "ProcessTransportTests.Test_child_bounds_a_held_pump", - "ProcessTransportTests.Post_start_cancellation_reaps_client_but_leaves_descendant_alive", - "ProcessTransportTests.Test_child_reports_cleanup_faults", - "RequireRedTests.Accepts_only_nonzero_run_with_exact_failed_selected_test", - "RequireRedTests.Rejects_build_or_discovery_failure", - "RequireRedTests.Rejects_zero_tests_and_all_skipped_tests", - "RequireRedTests.Rejects_aborted_or_canceled_runs", - "RequireRedTests.Rejects_malformed_or_missing_trx", - "RequireRedTests.Rejects_unexpected_test_identity", - "RequireRedTests.Rejects_successful_test_run", - "RequireRedTests.Rejects_stale_exact_failed_trx_after_build_or_discovery_failure", - ), - 3: ( - "TmuxCapabilitiesTests.Comparisons_cover_equal_older_and_newer_versions", - "VersionParityTests.AttachmentAccounting", - "VersionParityTests.BreakPane37Workaround", - "VersionParityTests.ByteLengthFraming", - "VersionParityTests.CapturePane37Metadata", - "VersionParityTests.CapturePaneModeScreen", - "VersionParityTests.CapturePaneTrimTrailing", - "VersionParityTests.ChooseTreeSortTime", - "VersionParityTests.ClearHistoryHyperlinks", - "VersionParityTests.ClearPromptHistoryCommand", - "VersionParityTests.CommandPrompt37Behavior", - "VersionParityTests.CommandPromptBackground", - "VersionParityTests.CommandPromptLiteral", - "VersionParityTests.ConfirmBeforeAcceptance", - "VersionParityTests.ConfirmBeforeBackground", - "VersionParityTests.ControlNotifications", - "VersionParityTests.CopyModePageDown", - "VersionParityTests.DisplayMenuMouse", - "VersionParityTests.DisplayMenuStyles", - "VersionParityTests.DisplayMessageClient", - "VersionParityTests.DisplayMessageLiteral", - "VersionParityTests.DisplayMessageUpdatePane", - "VersionParityTests.DisplayPopup33Options", - "VersionParityTests.DisplayPopup36KeyPolicy", - "VersionParityTests.FormatFieldsAndOperators", - "VersionParityTests.HookScopePaneWindowSet", - "VersionParityTests.HookScopePaneWindowShow", - "VersionParityTests.KillSessionGroup", - "VersionParityTests.ListKeysFormat", - "VersionParityTests.NewPaneCommand", - "VersionParityTests.OptionDollarDoubleEscape", - "VersionParityTests.PasteBufferNoVis", - "VersionParityTests.RefreshClientClipboardQuery", - "VersionParityTests.RunShellArguments", - "VersionParityTests.RunShellShowStderr", - "VersionParityTests.RunShellWorkingDirectory", - "VersionParityTests.SemicolonGrouping", - "VersionParityTests.SendKeysClientKeys", - "VersionParityTests.ServerAccessCommand", - "VersionParityTests.ShowPromptHistoryCommand", - "VersionParityTests.SplitWindowAppearance", - "VersionParityTests.SplitWindowEmpty", - "VersionParityTests.CommandFlags", - ), - 4: ( - "MaterializationTests.Format_separator_exclusion_uses_single_expansion_decode", - "MaterializationTests.Materializer_uses_server_context_and_returns_typed_raw_fields", - "MaterializationTests.Generated_projection_round_trips_multiple_hostile_rows", - "MaterializationTests.Version_gates_emit_only_supported_fields", - "MaterializationTests.Window_and_pane_lookup_use_tmux_canonical_session", - "MaterializationTests.Missing_target_is_distinct_from_unreachable_server", - ), - 7: ( - "SnapshotCollectionTests.List_accessors_are_lenient_on_tmux_errors", - "SnapshotCollectionTests.Explicit_liveness_checks_preserve_failures", - ), - 8: ( - "QuerySemanticsTests.And_and_or_nodes_use_ordered_structural_equality_and_hashing", - ), - 10: ( - "ServerSessionLifecycleTests.New_session_flags_emit_exact_argv", - "ServerSessionLifecycleTests.Session_selection_and_attachment_flags_emit_exact_argv", - "ServerSessionLifecycleTests.Refresh_after_external_selection_captures_active_window_and_pane_relations", - ), - 11: ( - "WindowTopologyTests.New_split_move_link_swap_resize_rotate_and_respawn_flags_emit_exact_argv", - "WindowTopologyTests.Killed_window_is_a_raising_tombstone", - ), - 12: ( - "PaneOperationsTests.Capture_flags_emit_exact_argv_and_preserve_positions", - "PaneOperationsTests.Send_keys_flags_distinguish_literal_and_key_modes", - "PaneOperationsTests.Select_direction_last_keep_zoom_mark_and_input_flags_emit_exact_argv", - "PaneOperationsTests.New_split_move_join_paste_display_clear_and_break_flags_emit_exact_argv", - "PaneOperationsTests.Copy_find_pipe_swap_resize_and_respawn_flags_emit_exact_argv", - "PaneOperationsTests.Popup_menu_and_display_flags_emit_exact_argv", - ), - 13: ( - "ClientAdministrationTests.Attach_switch_detach_lock_and_suspend_flags_emit_exact_argv", - ), - 14: ( - "TmuxOptionsTests.Global_inherited_and_unset_scopes_emit_exact_flags", - "TmuxOptionsTests.Sparse_arrays_and_raw_values_round_trip", - "TmuxOptionsTests.Invalid_ambiguous_and_unknown_options_map_to_typed_failures", - "TmuxOptionsTests.Window_option_aliases_resolve_to_the_window_scope", - ), - 15: ( - "HookOperationsTests.Set_show_unset_and_run_flags_emit_exact_argv", - "EnvironmentOperationsTests.Set_show_unset_and_remove_flags_emit_exact_argv", - ), - 16: ( - "ServerUtilitiesTests.Bind_unbind_and_list_key_flags_emit_exact_argv", - "ServerUtilitiesTests.Prompt_menu_confirm_and_display_flags_emit_exact_argv", - "ServerUtilitiesTests.Buffer_flags_emit_exact_argv", - "ServerUtilitiesTests.Shell_if_source_wait_and_access_flags_emit_exact_argv", - ), - 17: ( - "ExceptionContractTests.Command_specific_errors_preserve_typed_context", - "ExceptionContractTests.Cancellation_and_cleanup_failures_preserve_distinct_state", - "ExceptionContractTests.Excluded_python_exceptions_have_exact_replacements", - ), - 18: ( - "PackageClosureTests.Packed_metadata_dependencies_and_assets_are_exact", - "PackageClosureTests.SourceLink_repository_revision_and_privacy_are_exact", - "PackageClosureTests.Packed_consumers_execute_on_both_frameworks", - "PackageClosureTests.Documented_examples_execute_against_real_tmux", - "PackageClosureTests.Trimmed_native_aot_executes_on_both_frameworks", - "WorkflowContractTests.Platform_and_macos_tmux_configurations_are_exact", - "PublicApiContractTests.Shipped_baselines_match_both_packages", - "SourceBindingTests.Final_matrix_matches_the_closing_source_tree", - ), -} -RED_CASES: dict[int, tuple[str, str]] = { - 1: ( - "tests/LibTmux.UnitTests/LibTmux.UnitTests.csproj", - "TmuxProcessTransportTests.Preserves_raw_bytes_and_projects_universal_newlines", - ), - 2: ( - "tests/LibTmux.IntegrationTests/LibTmux.IntegrationTests.csproj", - "ServerGenerationTests.Stale_entity_cannot_target_a_reused_id", - ), - 3: ( - "tests/LibTmux.UnitTests/LibTmux.UnitTests.csproj", - "TmuxCapabilitiesTests.Comparisons_cover_equal_older_and_newer_versions", - ), - 4: ( - "tests/LibTmux.IntegrationTests/LibTmux.IntegrationTests.csproj", - "MaterializationTests.Materializes_embedded_newlines_and_invalid_utf8", - ), - 5: ( - "tests/LibTmux.IntegrationTests/LibTmux.IntegrationTests.csproj", - "HierarchySnapshotTests.Linked_windows_preserve_edges_without_losing_entity_identity", - ), - 6: ( - "tests/LibTmux.IntegrationTests/LibTmux.IntegrationTests.csproj", - "ChildEnvironmentTests.Starting_server_removes_inherited_tmux_without_mutating_process_environment", - ), - 7: ( - "tests/LibTmux.UnitTests/LibTmux.UnitTests.csproj", - "SnapshotCollectionTests.Enumeration_is_local_and_uses_BCL_cardinality", - ), - 8: ( - "tests/LibTmux.UnitTests/LibTmux.UnitTests.csproj", - "QuerySemanticsTests.Matching_translates_and_interprets_the_canonical_AST", - ), - 9: ( - "tests/LibTmux.UnitTests/LibTmux.UnitTests.csproj", - "QueryJsonTests.Round_trips_every_version_one_golden_byte_for_byte", - ), - 10: ( - "tests/LibTmux.IntegrationTests/LibTmux.IntegrationTests.csproj", - "ServerSessionLifecycleTests.Refresh_returns_replacement_and_owned_scope_cleans_up", - ), - 11: ( - "tests/LibTmux.IntegrationTests/LibTmux.IntegrationTests.csproj", - "WindowTopologyTests.Linked_window_moves_preserve_session_scoped_indexes", - ), - 12: ( - "tests/LibTmux.IntegrationTests/LibTmux.IntegrationTests.csproj", - "PaneOperationsTests.Send_keys_and_capture_preserve_literal_payloads", - ), - 13: ( - "tests/LibTmux.IntegrationTests/LibTmux.IntegrationTests.csproj", - "ClientAdministrationTests.Detached_client_resolves_nullable_attachment", - ), - 14: ( - "tests/LibTmux.IntegrationTests/LibTmux.IntegrationTests.csproj", - "TmuxOptionsTests.Preserves_global_inherited_sparse_and_raw_values", - ), - 15: ( - "tests/LibTmux.IntegrationTests/LibTmux.IntegrationTests.csproj", - "HookOperationsTests.Server_and_session_hooks_round_trip_without_global_state", - ), - 16: ( - "tests/LibTmux.IntegrationTests/LibTmux.IntegrationTests.csproj", - "ServerUtilitiesTests.Keys_prompts_menus_buffers_and_shell_commands_use_exact_argv", - ), - 17: ( - "tests/LibTmux.IntegrationTests/LibTmux.IntegrationTests.csproj", - "StructuredLoggingTests.Records_stable_scalar_context_without_payload_leakage", - ), - 18: ( - "tests/LibTmux.IntegrationTests/LibTmux.IntegrationTests.csproj", - "TestingHelpersTests.Temporary_hierarchy_is_xunit_independent_and_cleans_up", - ), -} -RED_EVIDENCE: dict[int, str] = { - component: f"artifacts/tdd/component-{component:02d}.trx" - for component in COMPONENT_IDS -} -RED_TEST_NAMESPACES: dict[int, str] = { - 1: "LibTmux.UnitTests.Transport", - 2: "LibTmux.IntegrationTests.Connection", - 3: "LibTmux.UnitTests.Versioning", - 4: "LibTmux.IntegrationTests.Materialization", - 5: "LibTmux.IntegrationTests.Snapshots", - 6: "LibTmux.IntegrationTests.Environment", - 7: "LibTmux.UnitTests.Collections", - 8: "LibTmux.UnitTests.Query", - 9: "LibTmux.UnitTests.Query", - 10: "LibTmux.IntegrationTests.Hierarchy", - 11: "LibTmux.IntegrationTests.Hierarchy", - 12: "LibTmux.IntegrationTests.Hierarchy", - 13: "LibTmux.IntegrationTests.Clients", - 14: "LibTmux.IntegrationTests.Options", - 15: "LibTmux.IntegrationTests.Hooks", - 16: "LibTmux.IntegrationTests.Utilities", - 17: "LibTmux.IntegrationTests.Diagnostics", - 18: "LibTmux.IntegrationTests.Testing", -} -RED_TEST_IDENTITIES: dict[int, str] = { - component: f"{RED_TEST_NAMESPACES[component]}.{test_name}" - for component, (_, test_name) in RED_CASES.items() -} -RED_COMMANDS: dict[int, str] = { - component: ( - "uv run python eng/parity/require_red.py " - f"--project {project} --configuration Release --framework net8.0 " - f"--no-restore --test {RED_TEST_IDENTITIES[component]} " - f"--evidence {RED_EVIDENCE[component]}" - ) - for component, (project, _) in RED_CASES.items() -} - - -class StagedScopeError(RuntimeError): - """Git could not inspect the component's staged scope.""" - - -def load_ledger(path: pathlib.Path = LEDGER_PATH) -> dict[str, t.Any]: - """Load the approved parity ledger. - - Parameters - ---------- - path : pathlib.Path - Ledger path. - - Returns - ------- - dict[str, typing.Any] - Parsed ledger document. - - Examples - -------- - >>> "rows" in load_ledger() - True - """ - with path.open(encoding="utf-8") as file_handle: - return t.cast(dict[str, t.Any], json.load(file_handle)) - - -def parse_markdown( - markdown: str, -) -> tuple[list[dict[str, t.Any]], dict[str, list[list[str]]]]: - r"""Parse component tasks and closure fields from Markdown headings. - - Parameters - ---------- - markdown : str - Production plan Markdown. - - Returns - ------- - tuple[list[dict[str, typing.Any]], dict[str, list[list[str]]]] - Parsed component sections and closure fields. - - Examples - -------- - >>> sections, closure = parse_markdown("## Component 1: One\n### Files\n- `a`\n") - >>> sections[0]["id"], closure - (1, {}) - """ - components: list[dict[str, t.Any]] = [] - closure: dict[str, list[list[str]]] = collections.defaultdict(list) - current_component: dict[str, t.Any] | None = None - current_fields: dict[str, list[list[str]]] | None = None - current_block: list[str] | None = None - in_closure = False - - for line in markdown.splitlines(): - component_match = COMPONENT_RE.fullmatch(line) - if component_match: - current_component = { - "id": int(component_match.group(1)), - "fields": collections.defaultdict(list), - } - components.append(current_component) - current_fields = t.cast( - dict[str, list[list[str]]], current_component["fields"] - ) - current_block = None - in_closure = False - continue - if line == "## Closure": - current_component = None - current_fields = closure - current_block = None - in_closure = True - continue - if line.startswith("## "): - current_component = None - current_fields = None - current_block = None - in_closure = False - continue - field_match = FIELD_RE.fullmatch(line) - if field_match and current_fields is not None: - field_name = field_match.group(1) - if in_closure and field_name.endswith(" gate"): - field_name = field_name.removesuffix(" gate") - current_block = [] - current_fields[field_name].append(current_block) - continue - if current_block is not None: - current_block.append(line) - - return components, dict(closure) - - -def nonblank(block: list[str]) -> list[str]: - """Return nonblank lines from a Markdown field. - - Parameters - ---------- - block : list[str] - Field lines. - - Returns - ------- - list[str] - Nonblank lines. - - Examples - -------- - >>> nonblank(["", "value", ""]) - ['value'] - """ - return [line for line in block if line.strip()] - - -def planned_commit_command(block: list[str]) -> str | None: - """Build the exact shell command for one declared atomic commit. - - Parameters - ---------- - block : list[str] - Atomic commit field. - - Returns - ------- - str | None - Exact command, or ``None`` for an invalid field. - - Examples - -------- - >>> planned_commit_command(["`Scope(feat): Add behavior`", "", "why: Needed.", "", "what:", "- Add it."]).endswith("| git commit --file -") - True - """ - lines = nonblank(block) - if not lines or re.fullmatch(r"`[^`\n]+`", lines[0]) is None: - return None - why = [line for line in lines[1:] if line.startswith("why:")] - what_indexes = [index for index, line in enumerate(lines) if line == "what:"] - if len(why) != 1 or len(what_indexes) != 1: - return None - bullets = [line for line in lines[what_indexes[0] + 1 :] if line.startswith("- ")] - if not bullets: - return None - message = (lines[0][1:-1], "", why[0], "", "what:", *bullets) - return ( - "printf '%s\\n' " - + " ".join(shlex.quote(line) for line in message) - + " | git commit --file -" - ) - - -def list_tokens(block: list[str]) -> list[str] | None: - """Parse a field made only from backtick-wrapped list items. - - Parameters - ---------- - block : list[str] - Field lines. - - Returns - ------- - list[str] | None - Tokens, or ``None`` when the field is malformed. - - Examples - -------- - >>> list_tokens(["", "- `one`", "- `two`"]) - ['one', 'two'] - >>> list_tokens(["plain"]) is None - True - """ - lines = nonblank(block) - matches = [LIST_TOKEN_RE.fullmatch(line) for line in lines] - if not lines or any(match is None for match in matches): - return None - return [t.cast(re.Match[str], match).group("token") for match in matches] - - -def markdown_commands(markdown: str) -> list[str]: - """Return every backtick-wrapped Markdown list token. - - Parameters - ---------- - markdown : str - Production plan Markdown. - - Returns - ------- - list[str] - Tokens that may represent executable commands. - """ - return [ - match.group("token") - for line in markdown.splitlines() - if (match := LIST_TOKEN_RE.fullmatch(line)) is not None - ] - - -def one_field( - component: dict[str, t.Any], - name: str, - violations: list[str], -) -> list[str] | None: - """Return one required component field and report cardinality errors. - - Parameters - ---------- - component : dict[str, typing.Any] - Parsed component. - name : str - Field name. - violations : list[str] - Violation accumulator. - - Returns - ------- - list[str] | None - The unique field block, when present. - - Examples - -------- - >>> errors: list[str] = [] - >>> one_field({"id": 1, "fields": {}}, "Files", errors) is None - True - >>> errors - ['component 1 missing Files'] - """ - blocks = t.cast(dict[str, list[list[str]]], component["fields"]).get(name, []) - if not blocks: - violations.append(f"component {component['id']} missing {name}") - return None - if len(blocks) != 1: - violations.append(f"component {component['id']} has duplicate {name}") - return None - return blocks[0] - - -def validate_component( - component: dict[str, t.Any], - row_owners: dict[str, list[int]], - violations: list[str], - component_files: dict[int, set[str]] | None = None, -) -> None: - """Validate one structurally parsed component task. - - Parameters - ---------- - component : dict[str, typing.Any] - Parsed task. - row_owners : dict[str, list[int]] - Ledger-row ownership accumulator. - violations : list[str] - Violation accumulator. - component_files : dict[int, set[str]] | None - Exact file ownership accumulator. - - Examples - -------- - >>> errors: list[str] = [] - >>> validate_component({"id": 1, "fields": {}}, {}, errors) - >>> errors[0] - 'component 1 missing Files' - """ - blocks = {name: one_field(component, name, violations) for name in REQUIRED_FIELDS} - component_id = t.cast(int, component["id"]) - - files = list_tokens(blocks["Files"]) if blocks["Files"] is not None else None - if files is not None and component_files is not None: - component_files[component_id] = set(files) - if blocks["Files"] is not None and ( - files is None - or any( - not EXACT_PATH_RE.fullmatch(path) - or any(character in path for character in "*?[]{}") - for path in files - ) - ): - violations.append(f"component {component_id} has non-exact Files") - if blocks["Files"] is not None and tuple(files or ()) != COMPONENT_FILES.get( - component_id, () - ): - violations.append(f"component {component_id} has invalid Files inventory") - if ( - component_id == 1 - and files is not None - and not set(files).issuperset(BUILD_BOOTSTRAP_FILES) - ): - violations.append("component 1 missing build bootstrap Files") - if ( - component_id == 1 - and files is not None - and not set(files).issuperset(FOUNDATIONAL_FILES) - ): - violations.append("component 1 missing foundational Files") - if files is not None and not set(files).issuperset( - REQUIRED_PROJECT_FILES.get(component_id, frozenset()) - ): - violations.append(f"component {component_id} missing required project Files") - if files is not None and not set(files).issuperset( - ENTITY_FRAGMENT_FILES.get(component_id, ()) - ): - violations.append(f"component {component_id} missing entity partial Files") - if files is not None and len(files) != len(set(files)): - violations.append("Files path has multiple component owners") - - api_owners = ( - list_tokens(blocks["API owners"]) if blocks["API owners"] is not None else None - ) - if blocks["API owners"] is not None and tuple(api_owners or ()) != ( - COMPONENT_API_TYPES.get(component_id, ()) - ): - violations.append(f"component {component_id} has invalid API owners") - - shared_files = ( - list_tokens(blocks["Shared files"]) - if blocks["Shared files"] is not None - else None - ) - if blocks["Shared files"] is not None and ( - shared_files is None - or len(shared_files) != len(set(shared_files)) - or any( - not EXACT_PATH_RE.fullmatch(path) - or any(character in path for character in "*?[]{}") - for path in shared_files - ) - ): - violations.append(f"component {component_id} has non-exact Shared files") - if blocks["Shared files"] is not None and tuple(shared_files or ()) != ( - COMPONENT_SHARED_FILES.get(component_id, ()) - ): - violations.append(f"component {component_id} has invalid Shared files") - - dependencies = ( - list_tokens(blocks["Depends on"]) if blocks["Depends on"] is not None else None - ) - if blocks["Depends on"] is not None and tuple(dependencies or ()) != ( - COMPONENT_DEPENDENCIES.get(component_id, ()) - ): - violations.append(f"component {component_id} has invalid Depends on") - - project_wiring = ( - list_tokens(blocks["Project wiring"]) - if blocks["Project wiring"] is not None - else None - ) - if blocks["Project wiring"] is not None and tuple(project_wiring or ()) != ( - PROJECT_WIRING.get(component_id, ("not applicable",)) - ): - violations.append(f"component {component_id} has invalid Project wiring") - if component_id == 18 and ( - files is None - or "src/LibTmux.Query.Json/packages.packed.lock.json" not in files - or project_wiring is None - or tuple(project_wiring) != PROJECT_WIRING[18] - ): - violations.append("component 18 missing packed Query.Json lock graph") - - transport_contract_blocks = t.cast( - dict[str, list[list[str]]], component["fields"] - ).get("Transport contract", []) - transport_contract = ( - list_tokens(transport_contract_blocks[0]) - if len(transport_contract_blocks) == 1 - else None - ) - if component_id == 1 and tuple(transport_contract or ()) != ( - COMPONENT_ONE_TRANSPORT_CONTRACT - ): - violations.append("component 1 missing frozen transport contract") - if component_id != 1 and transport_contract_blocks: - violations.append(f"component {component_id} has unexpected transport contract") - - red_runner_blocks = t.cast(dict[str, list[list[str]]], component["fields"]).get( - "RED runner contract", [] - ) - red_runner_contract = ( - list_tokens(red_runner_blocks[0]) if len(red_runner_blocks) == 1 else None - ) - if component_id == 1 and tuple(red_runner_contract or ()) != RED_RUNNER_CONTRACT: - violations.append("component 1 missing frozen RED runner contract") - if component_id != 1 and red_runner_blocks: - violations.append( - f"component {component_id} has unexpected RED runner contract" - ) - - freshness_blocks = t.cast(dict[str, list[list[str]]], component["fields"]).get( - "RED evidence freshness", [] - ) - freshness_contract = ( - list_tokens(freshness_blocks[0]) if len(freshness_blocks) == 1 else None - ) - if component_id == 1 and tuple(freshness_contract or ()) != ( - RED_EVIDENCE_FRESHNESS_CONTRACT - ): - violations.append("component 1 missing fresh RED evidence contract") - if component_id != 1 and freshness_blocks: - violations.append( - f"component {component_id} has unexpected RED evidence freshness" - ) - - tmux_37_transition_blocks = t.cast( - dict[str, list[list[str]]], component["fields"] - ).get("tmux 3.7 transition proof", []) - tmux_37_transition_proof = ( - list_tokens(tmux_37_transition_blocks[0]) - if len(tmux_37_transition_blocks) == 1 - else None - ) - if component_id == 3 and tuple(tmux_37_transition_proof or ()) != ( - TMUX_37_TRANSITION_PROOF_CONTRACT - ): - violations.append("component 3 has invalid tmux 3.7 transition proof") - if component_id != 3 and tmux_37_transition_blocks: - violations.append( - f"component {component_id} has unexpected tmux 3.7 transition proof" - ) - - policy_proof_blocks = t.cast(dict[str, list[list[str]]], component["fields"]).get( - "Version policy proofs", [] - ) - policy_proofs = ( - list_tokens(policy_proof_blocks[0]) if len(policy_proof_blocks) == 1 else None - ) - expected_policy_proofs = VERSION_POLICY_PROOFS_BY_COMPONENT.get(component_id) - if expected_policy_proofs is not None and tuple(policy_proofs or ()) != ( - expected_policy_proofs - ): - violations.append(f"component {component_id} has invalid version policy proofs") - if expected_policy_proofs is None and policy_proof_blocks: - violations.append( - f"component {component_id} has unexpected version policy proofs" - ) - - materialization_contract_blocks = t.cast( - dict[str, list[list[str]]], component["fields"] - ).get("Materialization contract", []) - materialization_contract = ( - list_tokens(materialization_contract_blocks[0]) - if len(materialization_contract_blocks) == 1 - else None - ) - if component_id == 4 and tuple(materialization_contract or ()) != ( - C4_MATERIALIZATION_CONTRACT - ): - violations.append("component 4 has invalid materialization contract") - if component_id != 4 and materialization_contract_blocks: - violations.append( - f"component {component_id} has unexpected materialization contract" - ) - - failure_corpus_blocks = t.cast(dict[str, list[list[str]]], component["fields"]).get( - "Failure corpus contract", [] - ) - failure_corpus = ( - list_tokens(failure_corpus_blocks[0]) - if len(failure_corpus_blocks) == 1 - else None - ) - if component_id == 1 and tuple(failure_corpus or ()) != ( - C1_FAILURE_CORPUS_CONTRACT - ): - violations.append("component 1 missing frozen failure corpus") - if component_id != 1 and failure_corpus_blocks: - violations.append( - f"component {component_id} has unexpected failure corpus contract" - ) - - red_bootstrap_blocks = t.cast(dict[str, list[list[str]]], component["fields"]).get( - "RED bootstrap", [] - ) - red_bootstrap = ( - list_tokens(red_bootstrap_blocks[0]) if len(red_bootstrap_blocks) == 1 else None - ) - expected_red_bootstrap = RED_BOOTSTRAP.get(component_id) - if expected_red_bootstrap is not None and tuple(red_bootstrap or ()) != ( - expected_red_bootstrap - ): - violations.append(f"component {component_id} has invalid RED bootstrap") - if expected_red_bootstrap is None and red_bootstrap_blocks: - violations.append(f"component {component_id} has unexpected RED bootstrap") - - rows = ( - list_tokens(blocks["Ledger rows"]) - if blocks["Ledger rows"] is not None - else None - ) - if rows is not None: - for row_id in rows: - row_owners.setdefault(row_id, []).append(component_id) - elif blocks["Ledger rows"] is not None: - violations.append(f"component {component_id} has invalid Ledger rows") - - red = nonblank(blocks["Red behavioral test"] or []) - if blocks["Red behavioral test"] is not None and ( - not red - or not any("`" in line for line in red) - or not any("fail" in line.lower() for line in red) - ): - violations.append(f"component {component_id} has invalid Red behavioral test") - required_red_tests = REQUIRED_RED_TESTS.get(component_id, ()) - red_content = "\n".join(red) - if blocks["Red behavioral test"] is not None and any( - f"`{test_name}`" not in red_content for test_name in required_red_tests - ): - violations.append( - f"component {component_id} missing required Red behavioral tests" - ) - violations.extend( - f"component {component_id} declares Red behavioral test " - f"{test_name}, which no test file defines" - for test_name in required_red_tests - if not defines_test(test_name) - ) - if component_id == 3 and tuple(re.findall(r"`([^`]+)`", red_content)) != ( - required_red_tests - ): - violations.append("component 3 has invalid Red behavioral tests") - if component_id == 4 and tuple( - line.split("`", 2)[1] for line in red if line.startswith("- `") - ) != ( - RED_CASES[4][1], - *required_red_tests, - ): - violations.append("component 4 has invalid Red behavioral tests") - - red_commands = ( - list_tokens(blocks["RED command"]) - if blocks["RED command"] is not None - else None - ) - if blocks["RED command"] is not None and red_commands != [ - RED_COMMANDS.get(component_id) - ]: - violations.append(f"component {component_id} missing executable RED command") - red_evidence = ( - list_tokens(blocks["RED evidence"]) - if blocks["RED evidence"] is not None - else None - ) - if blocks["RED evidence"] is not None and red_evidence != [ - RED_EVIDENCE.get(component_id) - ]: - violations.append(f"component {component_id} missing RED evidence") - - frameworks = ( - list_tokens(blocks["Frameworks"]) if blocks["Frameworks"] is not None else None - ) - if blocks["Frameworks"] is not None and ( - frameworks is None - or set(frameworks) != TARGET_FRAMEWORKS - or len(frameworks) != 2 - ): - violations.append(f"component {component_id} has invalid Frameworks") - - lanes = ( - list_tokens(blocks["tmux lanes"]) if blocks["tmux lanes"] is not None else None - ) - if blocks["tmux lanes"] is not None and ( - lanes is None - or not ( - (set(lanes) == TMUX_LANES and len(lanes) == len(TMUX_LANES)) - or lanes == ["not applicable"] - ) - ): - violations.append(f"component {component_id} has invalid tmux lanes") - - updates = "\n".join(nonblank(blocks["Ledger updates"] or [])) - if blocks["Ledger updates"] is not None and ( - any(not line.startswith("- ") for line in nonblank(blocks["Ledger updates"])) - or not { - "implementationStatus=implemented", - "evidenceStatus=verified", - } - <= set(re.findall(r"(?:implementationStatus|evidenceStatus)=[a-z_]+", updates)) - or "before the phase-aware validator runs" not in updates - ): - violations.append(f"component {component_id} has invalid Ledger updates") - - commit = nonblank(blocks["Atomic commit"] or []) - subject_lines = [line for line in commit if re.fullmatch(r"`[^`\n]+`", line)] - subject = subject_lines[0][1:-1] if len(subject_lines) == 1 else "" - subject_pattern = re.compile( - r"^[A-Za-z][A-Za-z0-9._/-]*" - r"\([a-z]+(?:\[[A-Za-z0-9._/-]+\])?\): [A-Za-z0-9].+$" - ) - if blocks["Atomic commit"] is not None and ( - not commit - or len(subject_lines) != 1 - or commit[0] != subject_lines[0] - or len(subject) > 50 - or subject_pattern.fullmatch(subject) is None - ): - violations.append(f"component {component_id} has invalid Atomic commit subject") - why_lines = [line for line in commit if line.startswith("why:")] - if blocks["Atomic commit"] is not None and ( - len(why_lines) != 1 or not why_lines[0].removeprefix("why:").strip() - ): - violations.append(f"component {component_id} has invalid Atomic commit why") - what_indexes = [index for index, line in enumerate(commit) if line == "what:"] - if blocks["Atomic commit"] is not None and ( - len(what_indexes) != 1 - or not any( - line.startswith("- ") and line.removeprefix("- ").strip() - for line in commit[what_indexes[0] + 1 :] - ) - ): - violations.append(f"component {component_id} has invalid Atomic commit what") - if blocks["Atomic commit"] is not None and any( - len(line) > 72 for line in commit[1:] - ): - violations.append(f"component {component_id} has overlong Atomic commit body") - expected_commit_command = planned_commit_command(blocks["Atomic commit"] or []) - - gate_tokens = ( - list_tokens(blocks["Full gate"]) if blocks["Full gate"] is not None else None - ) - gate = "\n".join(gate_tokens or []).lower() - gate_requirements = ["net8.0", "net10.0", "dotnet format", "dotnet build"] - if lanes == sorted(TMUX_LANES) or (lanes is not None and set(lanes) == TMUX_LANES): - gate_requirements.append("run-matrix.sh") - if blocks["Full gate"] is not None and ( - gate_tokens is None - or any(requirement not in gate for requirement in gate_requirements) - ): - violations.append(f"component {component_id} has invalid Full gate") - if gate_tokens is not None and any( - "dotnet test " in command and "--project " not in command - for command in gate_tokens - ): - violations.append( - f"component {component_id} has positional dotnet test project" - ) - if gate_tokens is not None and any( - "--no-build" in command and "--configuration Release" not in command - for command in gate_tokens - ): - violations.append( - f"component {component_id} has non-Release --no-build command" - ) - if gate_tokens is not None and any( - "dotnet build " in command and "--configuration Release" not in command - for command in gate_tokens - ): - violations.append( - f"component {component_id} has non-Release dotnet build command" - ) - if gate_tokens is not None and any( - validator in command - for command in gate_tokens - for validator in ("verify_public_api.py", "verify_ledger.py") - ): - violations.append( - f"component {component_id} bypasses phase-aware approval validation" - ) - phase_command = ( - "uv run python eng/parity/verify_production_plan.py " - f"--phase component --component {component_id} " - "docs/superpowers/plans/2026-08-09-libtmux-csharp-production.md" - ) - stage_command = ( - "uv run python eng/parity/verify_production_plan.py " - f"--phase component --component {component_id} --print-stage-paths " - "docs/superpowers/plans/2026-08-09-libtmux-csharp-production.md " - "| xargs git add --" - ) - verify_stage_command = ( - "uv run python eng/parity/verify_production_plan.py " - f"--phase component --component {component_id} --verify-staged-scope " - "docs/superpowers/plans/2026-08-09-libtmux-csharp-production.md" - ) - clean_index_command = 'test -z "$(git diff --cached --name-only)"' - if gate_tokens is not None and phase_command not in gate_tokens: - violations.append(f"component {component_id} missing phase-aware Full gate") - required_gate_commands = REQUIRED_GATE_COMMANDS.get(component_id, ()) - # A command the source commit and the evidence closure both need must appear - # once for each, so demand is counted rather than tested for membership. - demanded_gate_commands = collections.Counter(required_gate_commands) - demanded_gate_commands.update( - command - for command in ( - phase_command, - stage_command, - verify_stage_command, - "git diff --check", - "git diff --cached --name-only", - "git diff --cached --check", - expected_commit_command, - clean_index_command, - ) - if command is not None - ) - if gate_tokens is not None: - present_gate_commands = collections.Counter(gate_tokens) - if any( - present_gate_commands[command] < demand - for command, demand in demanded_gate_commands.items() - ): - violations.append( - f"component {component_id} missing required Full gate commands" - ) - elif not covers_in_order(gate_tokens, required_gate_commands): - violations.append( - f"component {component_id} has invalid required Full gate ordering" - ) - if gate_tokens is not None and component_id == 18: - aot_indexes = [ - gate_tokens.index(command) if command in gate_tokens else -1 - for command in AOT_COMMANDS - ] - if -1 not in aot_indexes and aot_indexes != sorted(aot_indexes): - violations.append("component 18 has invalid AOT restore ordering") - lock_generation_is_exact = all( - gate_tokens.count(unlocked) == 1 - and gate_tokens.count(locked) == 1 - and gate_tokens.index(locked) == gate_tokens.index(unlocked) + 1 - for unlocked, locked in C18_RESTORE_PAIRS - ) - if not lock_generation_is_exact: - violations.append("component 18 has invalid NuGet lock generation") - if ( - gate_tokens is not None - and component_id in RED_BOOTSTRAP - and any( - "dotnet restore LibTmux.slnx" in command and "--locked-mode" not in command - for command in gate_tokens - ) - ): - violations.append( - f"component {component_id} regenerates locks during Full gate" - ) - if gate_tokens is not None: - phase_indexes = [ - index - for index, command in enumerate(gate_tokens) - if command == phase_command - ] - stage_indexes = [ - index - for index, command in enumerate(gate_tokens) - if command == stage_command - ] - commit_indexes = [ - index - for index, command in enumerate(gate_tokens) - if expected_commit_command is not None - and command == expected_commit_command - ] - clean_indexes = [ - index - for index, command in enumerate(gate_tokens) - if command == clean_index_command - ] - source_commit_index = commit_indexes[0] if len(commit_indexes) == 1 else -1 - scope_indexes = [ - index - for index, command in enumerate(gate_tokens) - if index < source_commit_index - and command - in { - verify_stage_command, - "git diff --cached --name-only", - "git diff --cached --check", - } - ] - if ( - len(stage_indexes) != 1 - or len(scope_indexes) != 3 - or stage_indexes[0] > min(scope_indexes) - ): - violations.append( - f"component {component_id} stages after cached scope inspection" - ) - if expected_commit_command is None or len(commit_indexes) != 1: - violations.append( - f"component {component_id} missing exact Atomic commit command" - ) - if len(clean_indexes) != 1: - violations.append( - f"component {component_id} missing clean-index checkpoint" - ) - expected_source_sequence = ( - phase_command, - "git diff --check", - stage_command, - verify_stage_command, - "git diff --cached --name-only", - "git diff --cached --check", - expected_commit_command, - clean_index_command, - ) - source_sequence_is_exact = ( - len(phase_indexes) == 1 - and len(stage_indexes) == 1 - and len(scope_indexes) == 3 - and len(commit_indexes) == 1 - and len(clean_indexes) == 1 - and tuple(gate_tokens[phase_indexes[0] : clean_indexes[0] + 1]) - == expected_source_sequence - ) - if not source_sequence_is_exact: - violations.append( - f"component {component_id} has invalid commit checkpoint order" - ) - if ( - len(phase_indexes) == 1 - and len(stage_indexes) == 1 - and any( - command not in {"git diff --check"} - for command in gate_tokens[phase_indexes[0] + 1 : stage_indexes[0]] - ) - ): - violations.append( - f"component {component_id} validates phase before behavioral gates" - ) - if component_id in EVIDENCE_CLOSURE_TAILS: - # The evidence commit must observe a clean worktree left by the - # source commit, so both roots are checked as one exact sequence. - expected_closure = ( - *expected_source_sequence, - *EVIDENCE_CLOSURE_TAILS[component_id], - ) - if ( - len(phase_indexes) != 1 - or tuple(gate_tokens[phase_indexes[0] :]) != expected_closure - ): - violations.append( - f"component {component_id} has invalid source/evidence " - "closure ordering" - ) - if component_id == 18: - violations.append( - "component 18 has invalid final evidence ordering" - ) - elif len(clean_indexes) == 1 and gate_tokens[clean_indexes[0] + 1 :]: - violations.append( - f"component {component_id} validates phase before behavioral gates" - ) - if component_id == 18 and not { - PRECOMMIT_SOURCE_BINDING_COMMAND, - POSTCOMMIT_SOURCE_BINDING_COMMAND, - EVIDENCE_STAGE_COMMAND, - EVIDENCE_SCOPE_COMMAND, - EVIDENCE_COMMIT_COMMAND, - }.issubset(gate_tokens): - violations.append("component 18 has incomplete source-binding closure") - - -def approval_ledger(ledger: dict[str, t.Any]) -> dict[str, t.Any]: - """Return an approval-validator copy with production claims removed. - - Parameters - ---------- - ledger : dict[str, typing.Any] - Current parity ledger. - - Returns - ------- - dict[str, typing.Any] - Deep-copied approval snapshot. - - Examples - -------- - >>> source = {"rows": [{"implementationStatus": "implemented"}]} - >>> approval_ledger(source)["rows"][0]["implementationStatus"] - 'not_started' - >>> source["rows"][0]["implementationStatus"] - 'implemented' - """ - normalized = copy.deepcopy(ledger) - for row in t.cast(list[dict[str, t.Any]], normalized.get("rows", [])): - row["implementationStatus"] = "not_started" - row["evidenceStatus"] = "none" - return normalized - - -def validate_phase( - ledger: dict[str, t.Any], - phase: str, - component: int | None, -) -> list[str]: - """Validate the ledger state allowed at one production phase. - - Parameters - ---------- - ledger : dict[str, typing.Any] - Current parity ledger. - phase : str - Approval, component, or closure phase. - component : int | None - Exact completed component for a component phase. - - Returns - ------- - list[str] - Stable phase violations. - - Examples - -------- - >>> validate_phase({"rows": []}, "approval", None) - [] - >>> validate_phase({"rows": []}, "unknown", None) - ['invalid validation phase'] - """ - rows = t.cast(list[dict[str, t.Any]], ledger.get("rows", [])) - initial = ("not_started", "none") - complete = ("implemented", "verified") - statuses = [ - (row.get("implementationStatus"), row.get("evidenceStatus")) for row in rows - ] - if phase == "approval": - return ( - ["approval phase has production status claims"] - if any(status != initial for status in statuses) - else [] - ) - if phase == "component": - if component not in COMPONENT_IDS: - return ["component phase requires a valid component"] - mismatch = any( - status - != ( - complete - if isinstance(row.get("componentId"), int) - and t.cast(int, row["componentId"]) <= component - else initial - ) - for row, status in zip(rows, statuses, strict=True) - ) - return ["component phase status mismatch"] if mismatch else [] - if phase == "closure": - return ( - ["closure phase has incomplete statuses"] - if any(status != complete for status in statuses) - else [] - ) - return ["invalid validation phase"] - - -@functools.lru_cache(maxsize=1) -def declared_test_methods() -> frozenset[str]: - """Return every test name the repository defines, in one comparable form. - - A declared Red behavioral test that nothing defines is a plan promising a - proof nobody wrote, and reading the plan alone cannot notice that. Some - proofs are C# methods and some are Python functions, so both are read and - both are lowered with their underscores dropped: `Rejects_stale_trx` and - `test_rejects_stale_trx` are the same proof written twice. - """ - names: set[str] = set() - for path in (CSHARP_ROOT / "tests").rglob("*.cs"): - if "/obj/" in path.as_posix() or "/bin/" in path.as_posix(): - continue - names.update( - re.findall( - r"\b(?:public|internal)\s+(?:async\s+)?[\w<>,?\[\]. ]+?\s+(\w+)\s*\(", - path.read_text(encoding="utf-8"), - ) - ) - - for path in (CSHARP_ROOT / "eng").rglob("test_*.py"): - names.update( - re.findall( - r"^def (test_\w+)", path.read_text(encoding="utf-8"), re.MULTILINE - ) - ) - - return frozenset(comparable_test_name(name) for name in names) - - -def comparable_test_name(name: str) -> str: - """Return one test name in the form both languages compare equal in.""" - return name.removeprefix("test_").replace("_", "").casefold() - - -def defines_test(test_name: str) -> bool: - """Return whether the test tree defines one declared Red behavioral test.""" - method = test_name.rsplit(".", 1)[-1] - return comparable_test_name(method) in declared_test_methods() - - -def stage_paths(markdown: str, component: int) -> list[str]: - r"""Return one component's exact owned and shared staging allow-list. - - Parameters - ---------- - markdown : str - Production plan Markdown. - component : int - Component ID. - - Returns - ------- - list[str] - Sorted exact repository paths. - - Examples - -------- - >>> stage_paths("## Component 1: One\n### Files\n- `a`\n" - ... "### Shared files\n- `b`\n", 1) - ['a', 'b'] - """ - components, _ = parse_markdown(markdown) - matches = [entry for entry in components if entry["id"] == component] - if len(matches) != 1: - raise ValueError(component, "must appear exactly once") - fields = t.cast(dict[str, list[list[str]]], matches[0]["fields"]) - paths: set[str] = set() - for field_name in ("Files", "Shared files"): - blocks = fields.get(field_name, []) - if len(blocks) != 1: - raise ValueError(component, "has invalid field", field_name) - tokens = list_tokens(blocks[0]) - if tokens is None or any( - EXACT_PATH_RE.fullmatch(path) is None for path in tokens - ): - raise ValueError(component, "has invalid field", field_name) - paths.update(tokens) - return sorted(paths) - - -def covers_in_order( - commands: t.Sequence[str], - required: t.Sequence[str], -) -> bool: - """Report whether required commands appear in order as a subsequence. - - Repeated commands match distinct positions, so a command demanded by both - the source commit and the evidence closure cannot be satisfied twice by one - line. - - Parameters - ---------- - commands : Sequence[str] - Full gate commands in declared order. - required : Sequence[str] - Commands that must appear in the given relative order. - - Returns - ------- - bool - True when every required command matches a later position. - - Examples - -------- - >>> covers_in_order(["a", "b", "c"], ["a", "c"]) - True - >>> covers_in_order(["a", "b", "c"], ["c", "a"]) - False - >>> covers_in_order(["a", "b"], ["a", "a"]) - False - """ - position = 0 - for command in required: - while position < len(commands) and commands[position] != command: - position += 1 - if position == len(commands): - return False - position += 1 - return True - - -def compare_staged_scope( - allowed_paths: t.Iterable[str], - staged_paths: t.Iterable[str], -) -> list[str]: - """Compare staged files with exact declared file or directory allow-roots. - - Parameters - ---------- - allowed_paths : Iterable[str] - Declared Files and Shared files. - staged_paths : Iterable[str] - Exact paths reported by Git. - - Returns - ------- - list[str] - One stable violation when coverage differs. - - Examples - -------- - >>> compare_staged_scope(["a.cs"], ["a.cs"]) - [] - >>> compare_staged_scope(["a.cs"], ["other.cs"]) - ['staged paths do not exactly match component allow-list'] - """ - allowed = set(allowed_paths) - staged = set(staged_paths) - - def covers(root: str, path: str) -> bool: - return path == root or path.startswith(f"{root}/") - - every_staged_path_is_allowed = all( - any(covers(root, path) for root in allowed) for path in staged - ) - every_allow_root_is_staged = all( - any(covers(root, path) for path in staged) for root in allowed - ) - return ( - [] - if allowed - and staged - and every_staged_path_is_allowed - and every_allow_root_is_staged - else ["staged paths do not exactly match component allow-list"] - ) - - -def read_staged_paths(repository: pathlib.Path = CSHARP_ROOT.parent) -> list[str]: - """Read exact staged paths from Git without changing repository state. - - Parameters - ---------- - repository : pathlib.Path - Repository worktree root. - - Returns - ------- - list[str] - Sorted staged paths relative to the worktree. - - Raises - ------ - RuntimeError - Git cannot inspect the index. - """ - try: - output = subprocess.run( - [ - "git", - "-C", - str(repository), - "diff", - "--cached", - "--name-only", - "--no-renames", - "-z", - "--", - ], - check=True, - capture_output=True, - ).stdout - except (OSError, subprocess.CalledProcessError) as exception: - raise StagedScopeError(STAGED_PATH_ERROR) from exception - return sorted( - raw.decode("utf-8", errors="surrogateescape") - for raw in output.split(b"\0") - if raw - ) - - -def validate_public_api_files( - markdown: str, - public_api: dict[str, t.Any], -) -> list[str]: - """Cross-check frozen public contracts with exact production-file owners. - - Parameters - ---------- - markdown : str - Production plan Markdown. - public_api : dict[str, typing.Any] - Frozen public API document. - - Returns - ------- - list[str] - Stable binding violations. - - Examples - -------- - >>> validate_public_api_files("# Plan", {"types": []}) - ['planned production type missing from public API', 'public API type ownership incomplete or drifted', 'public API production file missing or misowned', 'planned public member missing from public API', 'public API member production file missing or misowned'] - """ - components, _ = parse_markdown(markdown) - file_owners: dict[str, list[int]] = collections.defaultdict(list) - api_owners: dict[str, list[int]] = collections.defaultdict(list) - for component in components: - fields = t.cast(dict[str, list[list[str]]], component["fields"]) - blocks = fields.get("Files", []) - if len(blocks) != 1: - continue - for path in list_tokens(blocks[0]) or []: - file_owners[path].append(t.cast(int, component["id"])) - api_blocks = fields.get("API owners", []) - if len(api_blocks) == 1: - for type_id in list_tokens(api_blocks[0]) or []: - if type_id != "not applicable": - api_owners[type_id].append(t.cast(int, component["id"])) - type_ids = { - entry.get("id") - for entry in t.cast(list[dict[str, t.Any]], public_api.get("types", [])) - if isinstance(entry, dict) and isinstance(entry.get("id"), str) - } - member_ids = { - entry.get("id") - for entry in t.cast(list[dict[str, t.Any]], public_api.get("members", [])) - if isinstance(entry, dict) and isinstance(entry.get("id"), str) - } - violations: list[str] = [] - expected_api_owners = { - type_id: component - for component, component_types in COMPONENT_API_TYPES.items() - for type_id in component_types - if type_id != "not applicable" - } - if set(type_ids) != set(expected_api_owners): - violations.append("planned production type missing from public API") - if api_owners != { - type_id: [component] for type_id, component in expected_api_owners.items() - }: - violations.append("public API type ownership incomplete or drifted") - if any( - file_owners.get(path) != [component] - for component, path in PUBLIC_API_FILE_BINDINGS.values() - ): - violations.append("public API production file missing or misowned") - if not set(PUBLIC_API_MEMBER_FILE_BINDINGS).issubset(member_ids): - violations.append("planned public member missing from public API") - if any( - file_owners.get(path) != [component] - for component, path in PUBLIC_API_MEMBER_FILE_BINDINGS.values() - ): - violations.append("public API member production file missing or misowned") - return violations - - -def validate_format_separator_contract(ledger: dict[str, t.Any]) -> list[str]: - """Keep the excluded delimiter bound to the approved byte framer. - - Parameters - ---------- - ledger : dict[str, typing.Any] - Approved parity ledger. - - Returns - ------- - list[str] - Stable contract violations. - - Examples - -------- - >>> row = { - ... "pythonSymbolId": "libtmux.formats:FORMAT_SEPARATOR", - ... **FORMAT_SEPARATOR_CONTRACT, - ... } - >>> validate_format_separator_contract({"rows": [row]}) - [] - """ - rows = [ - row - for row in t.cast(list[dict[str, t.Any]], ledger.get("rows", [])) - if row.get("pythonSymbolId") == "libtmux.formats:FORMAT_SEPARATOR" - ] - if not rows: - return [] - if len(rows) != 1 or any( - rows[0].get(field) != expected - for field, expected in FORMAT_SEPARATOR_CONTRACT.items() - ): - return ["FORMAT_SEPARATOR exclusion contract drifted"] - return [] - - -def validate_c4_ledger_ownership(ledger: dict[str, t.Any]) -> list[str]: - """Keep canonical window and pane lookup in the materialization slice. - - Parameters - ---------- - ledger : dict[str, typing.Any] - Approved parity ledger. - - Returns - ------- - list[str] - Stable ownership violation. - - Examples - -------- - >>> validate_c4_ledger_ownership({"rows": []}) - [] - """ - rows = t.cast(list[dict[str, t.Any]], ledger.get("rows", [])) - expected = { - "libtmux.pane:Pane.from_pane_id": { - "componentId": 4, - "testPath": ( - "tests/LibTmux.IntegrationTests/Parity/Component04ParityTests.cs" - ), - }, - "libtmux.window:Window.from_window_id": { - "componentId": 4, - "testPath": ( - "tests/LibTmux.IntegrationTests/Parity/Component04ParityTests.cs" - ), - }, - } - lookup_rows = { - t.cast(str, row["pythonSymbolId"]): row - for row in rows - if row.get("pythonSymbolId") in expected - } - if not lookup_rows: - return [] - component_counts = collections.Counter(row.get("componentId") for row in rows) - if ( - component_counts[2] != 9 - or component_counts[4] != 197 - or set(lookup_rows) != set(expected) - or any( - lookup_rows[row_id].get(field) != value - for row_id, fields in expected.items() - for field, value in fields.items() - ) - ): - return ["C4 lookup ledger ownership drifted"] - return [] - - -def validate( - markdown: str, - ledger: dict[str, t.Any], - *, - phase: str = "approval", - component: int | None = None, -) -> list[str]: - r"""Return structural production-plan violations. - - Parameters - ---------- - markdown : str - Production plan Markdown. - ledger : dict[str, typing.Any] - Approved parity ledger. - phase : str - Approval, component, or closure phase. - component : int | None - Exact completed component for a component phase. - - Returns - ------- - list[str] - Stable violation messages. - - Examples - -------- - >>> validate("# Plan\n", {"rows": []})[:3] - ['missing component IDs', 'component sections are out of frozen order', 'declaring type unavailable before member ownership'] - """ - components, closure = parse_markdown(markdown) - component_counts = collections.Counter(component["id"] for component in components) - present_components = set(component_counts) - violations: list[str] = [] - commands = markdown_commands(markdown) - if any(re.search(r"\bgit\s+push\b", command) for command in commands) or any( - re.search(r"\bgit\s+tag\b", command) and command != "git tag --points-at HEAD" - for command in commands - ): - violations.append("plan contains forbidden publication command") - if any( - "dotnet build " in command and "--configuration Release" not in command - for command in commands - ): - violations.append("plan has non-Release dotnet build command") - violations.extend(validate_format_separator_contract(ledger)) - violations.extend(validate_c4_ledger_ownership(ledger)) - if COMPONENT_IDS - present_components: - violations.append("missing component IDs") - if present_components - COMPONENT_IDS: - violations.append("unknown component IDs") - if any(count > 1 for count in component_counts.values()): - violations.append("duplicate component IDs") - if [component_entry["id"] for component_entry in components] != list(range(1, 19)): - violations.append("component sections are out of frozen order") - - row_owners: dict[str, list[int]] = {} - component_files: dict[int, set[str]] = {} - for component_entry in components: - validate_component(component_entry, row_owners, violations, component_files) - - file_owners: dict[str, list[int]] = collections.defaultdict(list) - for component_id, paths in component_files.items(): - for path in paths: - file_owners[path].append(component_id) - if any(len(owners) > 1 for owners in file_owners.values()): - violations.append("Files path has multiple component owners") - if any(file_owners.get(path) != [1] for path in ENTITY_SHELL_FILES): - violations.append("declaring type unavailable before member ownership") - if set(file_owners) & FORBIDDEN_PRODUCTION_FILES: - violations.append("plan contains stale or unapproved production Files") - - ledger_rows = t.cast(list[dict[str, t.Any]], ledger.get("rows", [])) - ledger_ids = { - t.cast(str, row["pythonSymbolId"]) - for row in ledger_rows - if isinstance(row.get("pythonSymbolId"), str) - } - owned_ids = set(row_owners) - if ledger_ids - owned_ids: - violations.append("missing ledger row IDs") - if owned_ids - ledger_ids: - violations.append("unknown ledger row IDs") - if any(len(owners) > 1 for owners in row_owners.values()): - violations.append("duplicate ledger row IDs") - - rows_by_id = { - t.cast(str, row["pythonSymbolId"]): row - for row in ledger_rows - if isinstance(row.get("pythonSymbolId"), str) - } - for row_id, owners in row_owners.items(): - if row_id not in rows_by_id or len(owners) != 1: - continue - frozen_component = rows_by_id[row_id].get("componentId") - if frozen_component is not None and frozen_component != owners[0]: - violations.append("ledger row assigned to wrong component") - break - - invalid_test_path = False - missing_owned_test_path = False - for row in ledger_rows: - ledger_row_id = row.get("pythonSymbolId") - test_path = row.get("testPath") - if ( - not isinstance(test_path, str) - or not EXACT_PATH_RE.fullmatch(test_path) - or any(character in test_path for character in "*?[]{}") - ): - invalid_test_path = True - continue - owners = ( - row_owners.get(ledger_row_id, []) if isinstance(ledger_row_id, str) else [] - ) - if len(owners) == 1 and test_path not in component_files.get(owners[0], set()): - missing_owned_test_path = True - if invalid_test_path: - violations.append("ledger row has invalid testPath") - if missing_owned_test_path: - violations.append("ledger row testPath missing from owning component Files") - - if not closure: - violations.append("missing Closure section") - for gate_name, required_tokens in CLOSURE_GATES.items(): - blocks = closure.get(gate_name, []) - if not blocks: - violations.append(f"closure missing {gate_name} gate") - continue - if len(blocks) != 1: - violations.append(f"closure has duplicate {gate_name} gate") - continue - lines = nonblank(blocks[0]) - content = "\n".join(lines).lower() - if ( - not content - or any(not line.startswith("- ") for line in lines) - or any(token not in content for token in required_tokens) - ): - violations.append(f"closure has invalid {gate_name} gate") - - closure_commands = { - match.group("token") - for line in lines - if (match := LIST_TOKEN_RE.fullmatch(line)) is not None - } - if not set(REQUIRED_CLOSURE_COMMANDS.get(gate_name, ())).issubset( - closure_commands - ): - violations.append(f"closure missing required {gate_name} commands") - if ( - gate_name == "Repository quality" - and closure_commands & FORBIDDEN_ROOT_QUALITY_COMMANDS - ): - violations.append("closure has invalid Repository quality commands") - if any( - "--no-build" in command and "--configuration Release" not in command - for command in closure_commands - ): - violations.append(f"closure has non-Release {gate_name} command") - - violations.extend(validate_phase(ledger, phase, component)) - return violations - - -def load_json(path: pathlib.Path) -> dict[str, t.Any]: - """Load a JSON object from an exact repository path. - - Parameters - ---------- - path : pathlib.Path - JSON path. - - Returns - ------- - dict[str, typing.Any] - Parsed document. - - Examples - -------- - >>> len(load_json(LEDGER_PATH)["rows"]) > 0 - True - """ - with path.open(encoding="utf-8") as file_handle: - return t.cast(dict[str, t.Any], json.load(file_handle)) - - -def validate_approval_contracts(ledger: dict[str, t.Any]) -> list[str]: - """Run strict approval validators against a normalized ledger copy. - - Parameters - ---------- - ledger : dict[str, typing.Any] - Current production ledger. - - Returns - ------- - list[str] - Prefixed approval-contract violations. - - Examples - -------- - >>> isinstance(validate_approval_contracts(load_ledger()), list) - True - """ - normalized = approval_ledger(ledger) - public_api = runpy.run_path(str(PUBLIC_API_VALIDATOR_PATH)) - ledger_validator = runpy.run_path(str(LEDGER_VALIDATOR_PATH)) - public_violations = t.cast( - t.Callable[[dict[str, t.Any], dict[str, t.Any]], list[str]], - public_api["validate"], - )(load_json(PUBLIC_API_PATH), normalized) - public_violations.extend( - t.cast(t.Callable[[], list[str]], public_api["validate_repository"])() - ) - inventory = load_json(INVENTORY_PATH) - ledger_violations = t.cast( - t.Callable[[dict[str, t.Any], dict[str, t.Any]], list[str]], - ledger_validator["validate"], - )(inventory, normalized) - ledger_violations.extend( - t.cast( - t.Callable[[dict[str, t.Any], dict[str, t.Any]], list[str]], - ledger_validator["validate_error_policies"], - )(load_json(ERROR_POLICIES_PATH), inventory) - ) - return [ - *(f"public API approval: {violation}" for violation in public_violations), - *(f"ledger approval: {violation}" for violation in ledger_violations), - ] - - -def main(argv: list[str] | None = None) -> int: - """Validate one production plan from the command line. - - Parameters - ---------- - argv : list[str] | None - Optional command-line arguments. - - Returns - ------- - int - Zero when valid, one for violations, or two for invalid usage. - - Examples - -------- - >>> main([]) - 2 - """ - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("plan", nargs="?", type=pathlib.Path) - parser.add_argument( - "--phase", - choices=("approval", "component", "closure"), - default="approval", - ) - parser.add_argument("--component", type=int) - parser.add_argument("--print-stage-paths", action="store_true") - parser.add_argument("--verify-staged-scope", action="store_true") - parser.add_argument( - "--verify-final-evidence-staged-scope", - action="store_true", - ) - parser.add_argument( - "--verify-retained-evidence-staged-scope", - action="store_true", - ) - arguments = parser.parse_args(argv) - if arguments.plan is None: - parser.print_usage(sys.stderr) - return 2 - if (arguments.phase == "component") != (arguments.component is not None): - parser.print_usage(sys.stderr) - return 2 - if ( - arguments.print_stage_paths - or arguments.verify_staged_scope - or arguments.verify_retained_evidence_staged_scope - ) and arguments.phase != "component": - parser.print_usage(sys.stderr) - return 2 - if arguments.verify_final_evidence_staged_scope and arguments.phase != "closure": - parser.print_usage(sys.stderr) - return 2 - if ( - arguments.verify_retained_evidence_staged_scope - and arguments.component not in RETAINED_EVIDENCE_SCOPES - ): - parser.print_usage(sys.stderr) - return 2 - if ( - sum( - ( - arguments.print_stage_paths, - arguments.verify_staged_scope, - arguments.verify_final_evidence_staged_scope, - arguments.verify_retained_evidence_staged_scope, - ) - ) - > 1 - ): - parser.print_usage(sys.stderr) - return 2 - markdown = t.cast(pathlib.Path, arguments.plan).read_text(encoding="utf-8") - current_ledger = load_ledger() - violations = validate( - markdown, - current_ledger, - phase=arguments.phase, - component=arguments.component, - ) - violations.extend(validate_public_api_files(markdown, load_json(PUBLIC_API_PATH))) - violations.extend(validate_approval_contracts(current_ledger)) - if arguments.verify_staged_scope and not violations: - try: - current_staged_paths = read_staged_paths() - except RuntimeError as exception: - violations.append(str(exception)) - else: - violations.extend( - compare_staged_scope( - stage_paths(markdown, t.cast(int, arguments.component)), - current_staged_paths, - ) - ) - if arguments.verify_final_evidence_staged_scope and not violations: - try: - current_staged_paths = read_staged_paths() - except RuntimeError as exception: - violations.append(str(exception)) - else: - violations.extend( - compare_staged_scope( - [FINAL_EVIDENCE_ROOT, VERSION_DELTA_PATH], - current_staged_paths, - ) - ) - if arguments.verify_retained_evidence_staged_scope and not violations: - try: - current_staged_paths = read_staged_paths() - except RuntimeError as exception: - violations.append(str(exception)) - else: - violations.extend( - compare_staged_scope( - RETAINED_EVIDENCE_SCOPES[t.cast(int, arguments.component)], - current_staged_paths, - ) - ) - if violations: - for violation in violations: - print(violation, file=sys.stderr) - return 1 - if arguments.print_stage_paths: - for path in stage_paths(markdown, t.cast(int, arguments.component)): - print(path) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) From 280dcd3513147ce8630a577d38c9d04e792ea93d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 10:40:00 -0500 Subject: [PATCH 046/129] Engineering(fix[tests]): Import source binder normally why: Repeated runpy loading exposed functions backed by temporary module globals, making the full engineering suite fail intermittently with missing verifier names. what: - import the source-binding verifier once through its namespace package - exercise the same public functions without detached runpy globals --- eng/evidence/tests/test_source_binding.py | 26 +++++++++-------------- 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/eng/evidence/tests/test_source_binding.py b/eng/evidence/tests/test_source_binding.py index 5c5d1b4..3b5d302 100644 --- a/eng/evidence/tests/test_source_binding.py +++ b/eng/evidence/tests/test_source_binding.py @@ -4,24 +4,18 @@ import json import pathlib -import runpy import subprocess -import typing as t import zipfile import pytest -MODULE_PATH = pathlib.Path(__file__).parents[1] / "verify_source_binding.py" +from eng.evidence import verify_source_binding + EVIDENCE_ROOT = "csharp/docs/parity/evidence/0001" FINAL_EVIDENCE_ROOT = "csharp/docs/parity/evidence/final" DELTA_PATH = "csharp/docs/parity/version-deltas.json" -def namespace() -> dict[str, t.Any]: - """Return the loaded verifier namespace.""" - return runpy.run_path(str(MODULE_PATH)) - - def run_git(repository: pathlib.Path, *arguments: str) -> str: """Run one Git command inside a fixture repository.""" return subprocess.run( @@ -70,7 +64,7 @@ def repository(tmp_path: pathlib.Path) -> pathlib.Path: def precommit(repository: pathlib.Path) -> int: """Run the pre-commit binding arguments.""" - return t.cast(t.Callable[[list[str]], int], namespace()["main"])( + return verify_source_binding.main( [ "--evidence", str(repository / EVIDENCE_ROOT), @@ -88,7 +82,7 @@ def precommit(repository: pathlib.Path) -> int: def postcommit(repository: pathlib.Path) -> int: """Run the post-commit binding arguments.""" - return t.cast(t.Callable[[list[str]], int], namespace()["main"])( + return verify_source_binding.main( [ "--evidence", str(repository / EVIDENCE_ROOT), @@ -108,7 +102,7 @@ def postcommit(repository: pathlib.Path) -> int: def bind_final(repository: pathlib.Path) -> int: """Run the binding arguments the closing matrix run is retained under.""" - return t.cast(t.Callable[[list[str]], int], namespace()["main"])( + return verify_source_binding.main( [ "--evidence", str(repository / FINAL_EVIDENCE_ROOT), @@ -269,7 +263,7 @@ def test_final_matrix_matches_the_closing_source_tree( def test_usage_requires_exactly_one_binding_mode(repository: pathlib.Path) -> None: """Reject invocations that request neither or both binding modes.""" - main = t.cast(t.Callable[[list[str]], int], namespace()["main"]) + main = verify_source_binding.main common = [ "--evidence", str(repository / EVIDENCE_ROOT), @@ -298,7 +292,7 @@ def test_usage_requires_exactly_one_binding_mode(repository: pathlib.Path) -> No def test_usage_requires_a_fingerprint_mode(repository: pathlib.Path) -> None: """Reject invocations that do not declare how the tree is bound.""" - main = t.cast(t.Callable[[list[str]], int], namespace()["main"]) + main = verify_source_binding.main assert ( main( @@ -323,14 +317,14 @@ def test_a_package_naming_its_commit_passes(tmp_path: pathlib.Path) -> None: tmp_path, commit="a" * 40, url="https://example.invalid/repo" ) - assert namespace()["package_source_binding"](package) == [] + assert verify_source_binding.package_source_binding(package) == [] def test_a_package_without_a_commit_is_reported(tmp_path: pathlib.Path) -> None: """A report against a released version needs the source it was built from.""" package = write_package(tmp_path, commit="", url="https://example.invalid/repo") - assert namespace()["package_source_binding"](package) == [ + assert verify_source_binding.package_source_binding(package) == [ "package names no exact commit: none" ] @@ -339,7 +333,7 @@ def test_a_package_without_a_repository_is_reported(tmp_path: pathlib.Path) -> N """Naming a commit is no use without saying which repository holds it.""" package = write_package(tmp_path, commit="b" * 40, url="") - assert namespace()["package_source_binding"](package) == [ + assert verify_source_binding.package_source_binding(package) == [ "package names no repository url" ] From aaf731ecbcb332b0131c754400d2cd59c3cab2f2 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 10:40:16 -0500 Subject: [PATCH 047/129] Docs(fix[decisions]): Give capability ADR a unique number why: Two accepted decisions were both numbered 0005, leaving references to the capability decision ambiguous. what: - renumber stable capability intervals as ADR 0006 - update the supersession reference in ADR 0004 --- docs/decisions/0004-public-api-approval.md | 2 +- ...ability-intervals.md => 0006-stable-capability-intervals.md} | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename docs/decisions/{0005-stable-capability-intervals.md => 0006-stable-capability-intervals.md} (98%) diff --git a/docs/decisions/0004-public-api-approval.md b/docs/decisions/0004-public-api-approval.md index 4060664..dd290af 100644 --- a/docs/decisions/0004-public-api-approval.md +++ b/docs/decisions/0004-public-api-approval.md @@ -6,7 +6,7 @@ Accepted; implementation complete. The production plan and its phase validator were transient execution scaffolding and were removed after closure. The canonical API and parity documents retain the durable contract. -ADR 0005 supersedes this decision's exact capability-profile selection rule +ADR 0006 supersedes this decision's exact capability-profile selection rule and closed stable-version support boundary. This decision approves names, signatures, ownership, package placement, and diff --git a/docs/decisions/0005-stable-capability-intervals.md b/docs/decisions/0006-stable-capability-intervals.md similarity index 98% rename from docs/decisions/0005-stable-capability-intervals.md rename to docs/decisions/0006-stable-capability-intervals.md index e3ce792..45650a0 100644 --- a/docs/decisions/0005-stable-capability-intervals.md +++ b/docs/decisions/0006-stable-capability-intervals.md @@ -1,4 +1,4 @@ -# ADR 0005: Stable capability intervals +# ADR 0006: Stable capability intervals ## Status From 3d0d6627ac2050c199a59a6dc03be1ec0806613a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 10:53:47 -0500 Subject: [PATCH 048/129] Query(refactor[catalog]): Drop private generator why: The imported generator only emitted a hard-coded table and retained none of the integrity diagnostics that justified a separate analyzer project. what: - replace the analyzer project with one immutable runtime and AOT-safe field table - bind the catalog manifest to the shipped JSON schema - record the bakeoff decision and verification evidence --- LibTmux.slnx | 1 - docs/decisions/0003-query-bakeoff.md | 28 +- docs/decisions/0004-public-api-approval.md | 2 +- eng/parity/verify_public_api.py | 2 +- .../FieldCatalogGenerator.cs | 251 ------------------ .../LibTmux.Generators.csproj | 20 -- src/LibTmux.Generators/packages.lock.json | 119 --------- src/LibTmux/LibTmux.csproj | 5 - src/LibTmux/Query/QueryFieldCatalog.cs | 190 +++++++++++++ .../LibTmux.UnitTests.csproj | 1 + .../LibTmux.UnitTests/Query/QueryJsonTests.cs | 89 +++++++ 11 files changed, 298 insertions(+), 410 deletions(-) delete mode 100644 src/LibTmux.Generators/FieldCatalogGenerator.cs delete mode 100644 src/LibTmux.Generators/LibTmux.Generators.csproj delete mode 100644 src/LibTmux.Generators/packages.lock.json create mode 100644 src/LibTmux/Query/QueryFieldCatalog.cs diff --git a/LibTmux.slnx b/LibTmux.slnx index c4f983f..f6fa98f 100644 --- a/LibTmux.slnx +++ b/LibTmux.slnx @@ -1,6 +1,5 @@ - diff --git a/docs/decisions/0003-query-bakeoff.md b/docs/decisions/0003-query-bakeoff.md index 66c53af..d2c2170 100644 --- a/docs/decisions/0003-query-bakeoff.md +++ b/docs/decisions/0003-query-bakeoff.md @@ -1,4 +1,4 @@ -# ADR 0003: Generated closed query catalog +# ADR 0003: Closed query catalog ## Status @@ -16,6 +16,15 @@ remains the explicit native escape hatch and makes no equivalence guarantee. The remote-planning results and graft list below are historical bakeoff evidence, not unimplemented production requirements. +The production catalog is checked-in code rather than a private analyzer +project. The imported generator had become a post-initialization emitter over a +hard-coded table and retained none of the bakeoff contender's `LTQG001`–`LTQG008` +integrity diagnostics. Keeping that extra build project therefore added an +indirection without the property that selected it. One immutable field table +now owns target, value kind, relation, CLR property, and bound accessors. A +unit contract compares those entries with the shipped JSON Schema, and the +package NativeAOT smoke exercises the table. + ## Context The bakeoff compared attribute discovery, a hand-written static table, and a @@ -51,10 +60,9 @@ not metadata-free execution. ## Decision -Use a source-generated closed field catalog with one immutable canonical query -AST shared by expression translation, direct local interpretation, and JSON. -The production generator and emitted catalog are internal implementation -details; public API does not expose contender or generator vocabulary. +Use a closed field catalog with one immutable canonical query AST shared by +expression translation, direct local interpretation, and JSON. The catalog is +an internal implementation detail; public API does not expose its vocabulary. Public query entry points do not require a catalog or capability object. Query translation and interpretation are independent of the connected tmux version. @@ -152,8 +160,8 @@ not thresholds. The production implementation retains the parts that serve local portable queries: -- An internal generator and generated catalog over the approved production - snapshots, with the exact closed manifest retained as a compile-time test. +- One internal immutable catalog over the approved production snapshots, with + its field manifest checked against the shipped schema. - One public immutable query-document contract shared with the optional JSON package, without public contender or source-generator vocabulary. - Structural equality and hashing for the full public AST, including @@ -164,8 +172,6 @@ queries: quantifiers, including incomplete-snapshot errors before enumeration. - Python parity dispositions for the complete QueryList inventory while keeping BCL cardinality and the narrow `name__contains` parser. -- A build-private analyzer deployment or a separately validated analyzer - package with tested compiler compatibility and transitivity. - Shipping trimming analysis, Public API baselines, package validation, platform annotations, and supported-platform AOT tests. - Trimming annotations and AOT cases for the retained `MemberInfo`, public- @@ -174,7 +180,7 @@ queries: ## Rejected risks - Runtime attribute discovery as the production schema authority. -- A manually duplicated static table as the production schema authority. +- Independent hand-maintained catalog and schema tables without a drift gate. - `IQueryable`, silent client evaluation, or compilation of the caller's original expression as a fallback. - Culture-sensitive string translation or regex evaluation. @@ -202,8 +208,6 @@ queries: ## Remaining unknowns - macOS behavior for NativeAOT publication. -- Analyzer NuGet layout, transitivity, compiler compatibility, and package - validation for the production generator. - Shipping trimming, Public API, and platform-annotation results. - Allocation and local-matching behavior on large captured topologies. - Final public names and exhaustive Python inventory dispositions, which belong diff --git a/docs/decisions/0004-public-api-approval.md b/docs/decisions/0004-public-api-approval.md index dd290af..8f82e58 100644 --- a/docs/decisions/0004-public-api-approval.md +++ b/docs/decisions/0004-public-api-approval.md @@ -18,7 +18,7 @@ and `evidenceStatus=none` until its production component passes its full gate. The Python public inventory contains 626 source-grounded rows. Decisions 0001, 0002, and 0003 select the raw-byte process transport, immutable hierarchy, and -generated closed query catalog. A production port still needs one idiomatic +closed query catalog. A production port still needs one idiomatic .NET surface rather than a transliteration of Python implementation details. The canonical contract is `docs/public-api.json`. diff --git a/eng/parity/verify_public_api.py b/eng/parity/verify_public_api.py index b0157ea..997ee81 100644 --- a/eng/parity/verify_public_api.py +++ b/eng/parity/verify_public_api.py @@ -1333,7 +1333,7 @@ def validate(contract: dict[str, t.Any], ledger: dict[str, t.Any]) -> list[str]: def validate_repository() -> list[str]: - """Validate repository policy coupled to the approved generated catalog. + """Validate repository policy coupled to the approved API contract. Returns ------- diff --git a/src/LibTmux.Generators/FieldCatalogGenerator.cs b/src/LibTmux.Generators/FieldCatalogGenerator.cs deleted file mode 100644 index 784266d..0000000 --- a/src/LibTmux.Generators/FieldCatalogGenerator.cs +++ /dev/null @@ -1,251 +0,0 @@ -using System.Text; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.Text; - -namespace LibTmux.Generators; - -/// Emits the closed catalog of queryable tmux format fields. -/// -/// The catalog is generated rather than hand-written so the query vocabulary -/// stays closed: a field absent here cannot be translated, which is what makes -/// translate-or-throw enforceable instead of advisory. -/// -[Generator(LanguageNames.CSharp)] -public sealed class FieldCatalogGenerator : IIncrementalGenerator -{ - /// The closed catalog, and where each field lives on its entity. - /// - /// Wire name and property are declared explicitly because the mapping is - /// not systematic (client_controlIsControlClient, and two - /// fields have no property at all). - /// - private static readonly ( - string WireName, - string Target, - string Kind, - bool Relation, - string? Property)[] Fields = - { - ("client_control", "Client", "Boolean", false, "IsControlClient"), - ("client_id", "Client", "TypedId", false, null), - ("client_name", "Client", "String", false, "Name"), - ("pane_command", "Pane", "String", false, null), - ("pane_id", "Pane", "TypedId", false, "Id"), - ("session_attached", "Session", "Boolean", false, "Attached"), - ("session_id", "Session", "TypedId", false, "Id"), - ("session_name", "Session", "String", false, "Name"), - ("session_windows", "Session", "Int64", true, "Windows"), - ("window_id", "Window", "TypedId", false, "Id"), - ("window_name", "Window", "String", false, "Name"), - ("window_panes", "Window", "Int64", true, "Panes"), - }; - - /// - public void Initialize(IncrementalGeneratorInitializationContext context) => - context.RegisterPostInitializationOutput(static registration => - registration.AddSource( - "QueryFieldCatalog.g.cs", - SourceText.From(Render(), Encoding.UTF8))); - - private static string Render() - { - var source = new StringBuilder(); - source.AppendLine("// "); - source.AppendLine("#nullable enable"); - source.AppendLine(); - source.AppendLine("namespace LibTmux.Query;"); - source.AppendLine(); - source.AppendLine("internal static partial class QueryFieldCatalog"); - source.AppendLine("{"); - source.AppendLine( - " internal static bool IsRelation(string wireName) => wireName switch"); - source.AppendLine(" {"); - foreach ((string wireName, _, _, bool relation, _) in Fields) - { - if (relation) - { - source.AppendLine($" \"{wireName}\" => true,"); - } - } - - source.AppendLine(" _ => false,"); - source.AppendLine(" };"); - source.AppendLine(); - source.AppendLine( - " internal static bool TryGetTarget(string wireName, out QueryTarget target)"); - source.AppendLine(" {"); - source.AppendLine(" switch (wireName)"); - source.AppendLine(" {"); - foreach ((string wireName, string target, _, _, _) in Fields) - { - source.AppendLine($" case \"{wireName}\":"); - source.AppendLine($" target = QueryTarget.{target};"); - source.AppendLine(" return true;"); - } - - source.AppendLine(" default:"); - source.AppendLine(" target = default;"); - source.AppendLine(" return false;"); - source.AppendLine(" }"); - source.AppendLine(" }"); - source.AppendLine(); - source.AppendLine( - " internal static bool TryGetKind(string wireName, out QueryValueKind kind)"); - source.AppendLine(" {"); - source.AppendLine(" switch (wireName)"); - source.AppendLine(" {"); - foreach ((string wireName, _, string kind, _, _) in Fields) - { - source.AppendLine($" case \"{wireName}\":"); - source.AppendLine($" kind = QueryValueKind.{kind};"); - source.AppendLine(" return true;"); - } - - source.AppendLine(" default:"); - source.AppendLine(" kind = default;"); - source.AppendLine(" return false;"); - source.AppendLine(" }"); - source.AppendLine(" }"); - source.AppendLine(); - source.AppendLine( - " internal static bool TryGetWireName(global::System.Type owner, string property, " - + "out string wireName)"); - source.AppendLine(" {"); - foreach ((string wireName, string target, _, _, string? property) in Fields) - { - if (property is null) - { - continue; - } - - source.AppendLine( - $" if (owner == typeof(global::LibTmux.{target}) " - + $"&& property == \"{property}\")"); - source.AppendLine(" {"); - source.AppendLine($" wireName = \"{wireName}\";"); - source.AppendLine(" return true;"); - source.AppendLine(" }"); - } - - source.AppendLine(" wireName = string.Empty;"); - source.AppendLine(" return false;"); - source.AppendLine(" }"); - source.AppendLine(); - source.AppendLine( - " internal static bool TryGetProperty(global::System.Type owner, string wireName, " - + "out string property)"); - source.AppendLine(" {"); - foreach ((string wireName, string target, _, _, string? property) in Fields) - { - if (property is null) - { - continue; - } - - source.AppendLine( - $" if (owner == typeof(global::LibTmux.{target}) " - + $"&& wireName == \"{wireName}\")"); - source.AppendLine(" {"); - source.AppendLine($" property = \"{property}\";"); - source.AppendLine(" return true;"); - source.AppendLine(" }"); - } - - source.AppendLine(" property = string.Empty;"); - source.AppendLine(" return false;"); - source.AppendLine(" }"); - source.AppendLine(); - source.AppendLine( - " internal static bool TryBindEntityScalar(global::System.Type owner, " - + "string wireName, out QueryFieldAccessor accessor)"); - source.AppendLine(" {"); - foreach ((string wireName, string target, string kind, bool relation, string? property) in Fields) - { - if (property is null) - { - continue; - } - - string read = relation - ? $"checked((long)((global::LibTmux.{target})element).{property}.Count)" - : kind switch - { - "Boolean" => $"(bool)((global::LibTmux.{target})element).{property}", - "String" => $"(string)((global::LibTmux.{target})element).{property}", - "TypedId" => - $"(global::LibTmux.{target}Id)((global::LibTmux.{target})element).{property}", - _ => $"((global::LibTmux.{target})element).{property}", - }; - string valueType = relation - ? "typeof(long)" - : kind switch - { - "Boolean" => "typeof(bool)", - "String" => "typeof(string)", - "TypedId" => $"typeof(global::LibTmux.{target}Id)", - _ => - $"typeof(global::LibTmux.{target}).GetProperty(\"{property}\")!.PropertyType", - }; - - source.AppendLine( - $" if (owner == typeof(global::LibTmux.{target}) " - + $"&& wireName == \"{wireName}\")"); - source.AppendLine(" {"); - source.AppendLine( - " accessor = new QueryFieldAccessor(" - + $"static element => {read}, {valueType});"); - source.AppendLine(" return true;"); - source.AppendLine(" }"); - } - - source.AppendLine(" accessor = null!;"); - source.AppendLine(" return false;"); - source.AppendLine(" }"); - source.AppendLine(); - source.AppendLine( - " internal static bool TryBindEntityRelation(global::System.Type owner, " - + "string wireName, out QueryFieldAccessor accessor)"); - source.AppendLine(" {"); - foreach ((string wireName, string target, _, bool relation, string? property) in Fields) - { - if (!relation || property is null) - { - continue; - } - - string child = wireName switch - { - "session_windows" => "Window", - "window_panes" => "Pane", - _ => throw new System.InvalidOperationException( - $"Unknown relation field '{wireName}'."), - }; - source.AppendLine( - $" if (owner == typeof(global::LibTmux.{target}) " - + $"&& wireName == \"{wireName}\")"); - source.AppendLine(" {"); - source.AppendLine( - " accessor = new QueryFieldAccessor(" - + $"static element => (global::LibTmux.CapturedRelation)" - + $"((global::LibTmux.{target})element).{property}, " - + $"typeof(global::LibTmux.CapturedRelation));"); - source.AppendLine(" return true;"); - source.AppendLine(" }"); - } - - source.AppendLine(" accessor = null!;"); - source.AppendLine(" return false;"); - source.AppendLine(" }"); - source.AppendLine(); - source.AppendLine(" internal static IReadOnlyList WireNames { get; } ="); - source.AppendLine(" ["); - foreach ((string wireName, _, _, _, _) in Fields) - { - source.AppendLine($" \"{wireName}\","); - } - - source.AppendLine(" ];"); - source.AppendLine("}"); - return source.ToString(); - } -} diff --git a/src/LibTmux.Generators/LibTmux.Generators.csproj b/src/LibTmux.Generators/LibTmux.Generators.csproj deleted file mode 100644 index c6ce1a0..0000000 --- a/src/LibTmux.Generators/LibTmux.Generators.csproj +++ /dev/null @@ -1,20 +0,0 @@ - - - netstandard2.0 - LibTmux.Generators - LibTmux.Generators - false - - true - false - - disable - true - - - - - - diff --git a/src/LibTmux.Generators/packages.lock.json b/src/LibTmux.Generators/packages.lock.json deleted file mode 100644 index 4171f87..0000000 --- a/src/LibTmux.Generators/packages.lock.json +++ /dev/null @@ -1,119 +0,0 @@ -{ - "version": 2, - "dependencies": { - ".NETStandard,Version=v2.0": { - "Microsoft.CodeAnalysis.CSharp": { - "type": "Direct", - "requested": "[5.6.0, )", - "resolved": "5.6.0", - "contentHash": "r1DrKQ/L0xTw03wJrLr36AMQNslyaeEKBFyFmQcOKa8HX3YvmhY//JEUafb6IR/m0gmaUVCfBTWitKJRNb7YAA==", - "dependencies": { - "Microsoft.CodeAnalysis.Analyzers": "5.3.0", - "Microsoft.CodeAnalysis.Common": "[5.6.0]", - "System.Buffers": "4.6.1", - "System.Collections.Immutable": "10.0.1", - "System.Memory": "4.6.3", - "System.Numerics.Vectors": "4.6.1", - "System.Reflection.Metadata": "10.0.1", - "System.Runtime.CompilerServices.Unsafe": "6.1.2", - "System.Text.Encoding.CodePages": "8.0.0", - "System.Threading.Tasks.Extensions": "4.6.3" - } - }, - "NETStandard.Library": { - "type": "Direct", - "requested": "[2.0.3, )", - "resolved": "2.0.3", - "contentHash": "st47PosZSHrjECdjeIzZQbzivYBJFv6P2nv4cj2ypdI204DO+vZ7l5raGMiX4eXMJ53RfOIg+/s4DHVZ54Nu2A==", - "dependencies": { - "Microsoft.NETCore.Platforms": "1.1.0" - } - }, - "Microsoft.CodeAnalysis.Analyzers": { - "type": "Transitive", - "resolved": "5.3.0", - "contentHash": "KuLhbZwB0L8JikL86AE5VWEp3RLNjIcp+j8yz9EJ/UBgRz4+qDEjHg/tluRFbpYpD/e37BqaaNFbQ0vqawBwWQ==" - }, - "Microsoft.CodeAnalysis.Common": { - "type": "Transitive", - "resolved": "5.6.0", - "contentHash": "eWYNB5e92PSdkQ0xcmy2aLtrvBXNydnVi0Hj/VjaAely6XBqA3By+ClGAJaj4d16pzQmrXPLLK9RDVuS1Ec9xQ==", - "dependencies": { - "Microsoft.CodeAnalysis.Analyzers": "5.3.0", - "System.Buffers": "4.6.1", - "System.Collections.Immutable": "10.0.1", - "System.Memory": "4.6.3", - "System.Numerics.Vectors": "4.6.1", - "System.Reflection.Metadata": "10.0.1", - "System.Runtime.CompilerServices.Unsafe": "6.1.2", - "System.Text.Encoding.CodePages": "8.0.0", - "System.Threading.Tasks.Extensions": "4.6.3" - } - }, - "Microsoft.NETCore.Platforms": { - "type": "Transitive", - "resolved": "1.1.0", - "contentHash": "kz0PEW2lhqygehI/d6XsPCQzD7ff7gUJaVGPVETX611eadGsA3A877GdSlU0LRVMCTH/+P3o2iDTak+S08V2+A==" - }, - "System.Buffers": { - "type": "Transitive", - "resolved": "4.6.1", - "contentHash": "N8GXpmiLMtljq7gwvyS+1QvKT/W2J8sNAvx+HVg4NGmsG/H+2k/y9QI23auLJRterrzCiDH+IWAw4V/GPwsMlw==" - }, - "System.Collections.Immutable": { - "type": "Transitive", - "resolved": "10.0.1", - "contentHash": "kdTe61B8P7i2M1pODC3MLbZ/CfFGjpC6c6jzxjQoB5DHZNewayCRqgFUmx3JKB6vLQtozpMQEiw+R5fO32Jv4g==", - "dependencies": { - "System.Memory": "4.6.3", - "System.Runtime.CompilerServices.Unsafe": "6.1.2" - } - }, - "System.Memory": { - "type": "Transitive", - "resolved": "4.6.3", - "contentHash": "qdcDOgnFZY40+Q9876JUHnlHu7bosOHX8XISRoH94fwk6hgaeQGSgfZd8srWRZNt5bV9ZW2TljcegDNxsf+96A==", - "dependencies": { - "System.Buffers": "4.6.1", - "System.Numerics.Vectors": "4.6.1", - "System.Runtime.CompilerServices.Unsafe": "6.1.2" - } - }, - "System.Numerics.Vectors": { - "type": "Transitive", - "resolved": "4.6.1", - "contentHash": "sQxefTnhagrhoq2ReR0D/6K0zJcr9Hrd6kikeXsA1I8kOCboTavcUC4r7TSfpKFeE163uMuxZcyfO1mGO3EN8Q==" - }, - "System.Reflection.Metadata": { - "type": "Transitive", - "resolved": "10.0.1", - "contentHash": "zpcfT/wacPPhE17zcudozlxQtWN/84qyiMyZNGLnK4cj2IMBtLsZYwYjVnALUhPliwyUVj/P7kaZvBWYBCnf2Q==", - "dependencies": { - "System.Collections.Immutable": "10.0.1" - } - }, - "System.Runtime.CompilerServices.Unsafe": { - "type": "Transitive", - "resolved": "6.1.2", - "contentHash": "2hBr6zdbIBTDE3EhK7NSVNdX58uTK6iHW/P/Axmm9sl1xoGSLqDvMtpecn226TNwHByFokYwJmt/aQQNlO5CRw==" - }, - "System.Text.Encoding.CodePages": { - "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "OZIsVplFGaVY90G2SbpgU7EnCoOO5pw1t4ic21dBF3/1omrJFpAGoNAVpPyMVOC90/hvgkGG3VFqR13YgZMQfg==", - "dependencies": { - "System.Memory": "4.5.5", - "System.Runtime.CompilerServices.Unsafe": "6.0.0" - } - }, - "System.Threading.Tasks.Extensions": { - "type": "Transitive", - "resolved": "4.6.3", - "contentHash": "7sCiwilJLYbTZELaKnc7RecBBXWXA+xMLQWZKWawBxYjp6DBlSE3v9/UcvKBvr1vv2tTOhipiogM8rRmxlhrVA==", - "dependencies": { - "System.Runtime.CompilerServices.Unsafe": "6.1.2" - } - } - } - } -} diff --git a/src/LibTmux/LibTmux.csproj b/src/LibTmux/LibTmux.csproj index 8d75947..844eb24 100644 --- a/src/LibTmux/LibTmux.csproj +++ b/src/LibTmux/LibTmux.csproj @@ -51,10 +51,5 @@ - - diff --git a/src/LibTmux/Query/QueryFieldCatalog.cs b/src/LibTmux/Query/QueryFieldCatalog.cs new file mode 100644 index 0000000..03abe33 --- /dev/null +++ b/src/LibTmux/Query/QueryFieldCatalog.cs @@ -0,0 +1,190 @@ +using System.Collections.Frozen; +using System.Collections.ObjectModel; + +namespace LibTmux.Query; + +internal static class QueryFieldCatalog +{ + private static readonly FieldDefinition[] Fields = + [ + new( + "client_control", + QueryTarget.Client, + QueryValueKind.Boolean, + typeof(Client), + nameof(Client.IsControlClient), + new(static element => ((Client)element).IsControlClient, typeof(bool))), + new("client_id", QueryTarget.Client, QueryValueKind.TypedId), + new( + "client_name", + QueryTarget.Client, + QueryValueKind.String, + typeof(Client), + nameof(Client.Name), + new(static element => ((Client)element).Name, typeof(string))), + new("pane_command", QueryTarget.Pane, QueryValueKind.String), + new( + "pane_id", + QueryTarget.Pane, + QueryValueKind.TypedId, + typeof(Pane), + nameof(Pane.Id), + new(static element => ((Pane)element).Id, typeof(PaneId))), + new( + "session_attached", + QueryTarget.Session, + QueryValueKind.Boolean, + typeof(Session), + nameof(Session.Attached), + new(static element => ((Session)element).Attached, typeof(bool))), + new( + "session_id", + QueryTarget.Session, + QueryValueKind.TypedId, + typeof(Session), + nameof(Session.Id), + new(static element => ((Session)element).Id, typeof(SessionId))), + new( + "session_name", + QueryTarget.Session, + QueryValueKind.String, + typeof(Session), + nameof(Session.Name), + new(static element => ((Session)element).Name, typeof(string))), + new( + "session_windows", + QueryTarget.Session, + QueryValueKind.Int64, + typeof(Session), + nameof(Session.Windows), + new(static element => checked((long)((Session)element).Windows.Count), typeof(long)), + new( + static element => ((Session)element).Windows, + typeof(CapturedRelation))), + new( + "window_id", + QueryTarget.Window, + QueryValueKind.TypedId, + typeof(Window), + nameof(Window.Id), + new(static element => ((Window)element).Id, typeof(WindowId))), + new( + "window_name", + QueryTarget.Window, + QueryValueKind.String, + typeof(Window), + nameof(Window.Name), + new(static element => ((Window)element).Name, typeof(string))), + new( + "window_panes", + QueryTarget.Window, + QueryValueKind.Int64, + typeof(Window), + nameof(Window.Panes), + new(static element => checked((long)((Window)element).Panes.Count), typeof(long)), + new(static element => ((Window)element).Panes, typeof(CapturedRelation))), + ]; + + private static readonly FrozenDictionary FieldsByWireName = + Fields.ToFrozenDictionary(static field => field.WireName, StringComparer.Ordinal); + + internal static IReadOnlyList WireNames { get; } = + new ReadOnlyCollection([.. Fields.Select(static field => field.WireName)]); + + internal static bool IsRelation(string wireName) => + FieldsByWireName.TryGetValue(wireName, out FieldDefinition field) + && field.Relation is not null; + + internal static bool TryGetTarget(string wireName, out QueryTarget target) + { + if (FieldsByWireName.TryGetValue(wireName, out FieldDefinition field)) + { + target = field.Target; + return true; + } + + target = default; + return false; + } + + internal static bool TryGetKind(string wireName, out QueryValueKind kind) + { + if (FieldsByWireName.TryGetValue(wireName, out FieldDefinition field)) + { + kind = field.Kind; + return true; + } + + kind = default; + return false; + } + + internal static bool TryGetWireName(Type owner, string property, out string wireName) + { + foreach (FieldDefinition field in Fields) + { + if (field.Owner == owner + && string.Equals(field.Property, property, StringComparison.Ordinal)) + { + wireName = field.WireName; + return true; + } + } + + wireName = string.Empty; + return false; + } + + internal static bool TryGetProperty(Type owner, string wireName, out string property) + { + if (FieldsByWireName.TryGetValue(wireName, out FieldDefinition field) + && field.Owner == owner + && field.Property is not null) + { + property = field.Property; + return true; + } + + property = string.Empty; + return false; + } + + internal static bool TryBindEntityScalar( + Type owner, + string wireName, + out QueryFieldAccessor accessor) => + TryBind(owner, wireName, relation: false, out accessor); + + internal static bool TryBindEntityRelation( + Type owner, + string wireName, + out QueryFieldAccessor accessor) => + TryBind(owner, wireName, relation: true, out accessor); + + private static bool TryBind( + Type owner, + string wireName, + bool relation, + out QueryFieldAccessor accessor) + { + if (FieldsByWireName.TryGetValue(wireName, out FieldDefinition field) + && field.Owner == owner + && (relation ? field.Relation : field.Scalar) is { } bound) + { + accessor = bound; + return true; + } + + accessor = null!; + return false; + } + + private readonly record struct FieldDefinition( + string WireName, + QueryTarget Target, + QueryValueKind Kind, + Type? Owner = null, + string? Property = null, + QueryFieldAccessor? Scalar = null, + QueryFieldAccessor? Relation = null); +} diff --git a/tests/LibTmux.UnitTests/LibTmux.UnitTests.csproj b/tests/LibTmux.UnitTests/LibTmux.UnitTests.csproj index 218fe3f..c2117d8 100644 --- a/tests/LibTmux.UnitTests/LibTmux.UnitTests.csproj +++ b/tests/LibTmux.UnitTests/LibTmux.UnitTests.csproj @@ -28,6 +28,7 @@ + diff --git a/tests/LibTmux.UnitTests/Query/QueryJsonTests.cs b/tests/LibTmux.UnitTests/Query/QueryJsonTests.cs index c65c2b4..44e33b5 100644 --- a/tests/LibTmux.UnitTests/Query/QueryJsonTests.cs +++ b/tests/LibTmux.UnitTests/Query/QueryJsonTests.cs @@ -110,6 +110,55 @@ public void The_wire_matches_the_retained_regex_golden() Assert.Equal(document, QueryJson.Deserialize(expected)); } + [Fact] + public void The_schema_field_manifest_matches_the_runtime_catalog() + { + using Stream stream = typeof(QueryJsonTests).Assembly + .GetManifestResourceStream("LibTmux.UnitTests.QuerySchema.json") + ?? throw new InvalidOperationException("Missing embedded query schema."); + using JsonDocument schema = JsonDocument.Parse(stream); + JsonElement definitions = schema.RootElement.GetProperty("$defs"); + + Assert.Equal( + QueryFieldCatalog.WireNames.Order(StringComparer.Ordinal), + DirectEnumValues(definitions.GetProperty("field"), "wireName")); + AssertKind(definitions, "booleanField", QueryValueKind.Boolean); + AssertKind(definitions, "stringField", QueryValueKind.String); + AssertKind(definitions, "int64Field", QueryValueKind.Int64); + Assert.Equal( + QueryFieldCatalog.WireNames.Where( + name => QueryFieldCatalog.TryGetKind(name, out QueryValueKind actual) + && actual == QueryValueKind.TypedId) + .Order(StringComparer.Ordinal), + ConstFieldValues( + definitions, + "sessionIdField", + "windowIdField", + "paneIdField", + "clientIdField")); + Assert.Equal( + QueryFieldCatalog.WireNames.Where(QueryFieldCatalog.IsRelation) + .Order(StringComparer.Ordinal), + ConstrainedEnumValues(definitions.GetProperty("relationField"), "wireName")); + + JsonElement targetCases = definitions.GetProperty("field") + .GetProperty("allOf")[0] + .GetProperty("oneOf"); + foreach (JsonElement targetCase in targetCases.EnumerateArray()) + { + JsonElement properties = targetCase.GetProperty("properties"); + QueryTarget target = Enum.Parse( + properties.GetProperty("target").GetProperty("const").GetString()!, + ignoreCase: true); + Assert.Equal( + QueryFieldCatalog.WireNames.Where( + name => QueryFieldCatalog.TryGetTarget(name, out QueryTarget actual) + && actual == target) + .Order(StringComparer.Ordinal), + DirectEnumValues(targetCase, "wireName")); + } + } + [Fact] public void Limits_may_tighten_the_frozen_ceilings_but_never_widen_them() { @@ -257,4 +306,44 @@ private static QueryDocument Document( QueryDocument.CurrentVersion, target, predicate); + + private static void AssertKind( + JsonElement definitions, + string definition, + QueryValueKind kind) => + Assert.Equal( + QueryFieldCatalog.WireNames.Where( + name => QueryFieldCatalog.TryGetKind(name, out QueryValueKind actual) + && actual == kind) + .Order(StringComparer.Ordinal), + ConstrainedEnumValues(definitions.GetProperty(definition), "wireName")); + + private static string[] ConstrainedEnumValues( + JsonElement definition, + string property) => + DirectEnumValues(definition.GetProperty("allOf")[1], property); + + private static string[] ConstFieldValues( + JsonElement definitions, + params string[] definitionNames) => + [ + .. definitionNames.Select( + name => definitions.GetProperty(name) + .GetProperty("allOf")[1] + .GetProperty("properties") + .GetProperty("wireName") + .GetProperty("const") + .GetString()!) + .Order(StringComparer.Ordinal), + ]; + + private static string[] DirectEnumValues(JsonElement definition, string property) => + [ + .. definition.GetProperty("properties") + .GetProperty(property) + .GetProperty("enum") + .EnumerateArray() + .Select(static value => value.GetString()!) + .Order(StringComparer.Ordinal), + ]; } From 1bf33f180d85db5c8f4d7c687d2c79ff71787e88 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 10:54:33 -0500 Subject: [PATCH 049/129] Window(fix[layout]): Validate custom prefix why: A leading hex digit was enough to bypass named-layout checks, but older tmux parsers advance past a full checksum prefix and can read beyond truncated input. what: - require four ASCII hex digits, a comma, and a layout body - prove malformed custom layouts never dispatch - retain named and valid custom layout behavior across tmux 3.2a --- src/LibTmux/Window.Topology.cs | 10 ++++++++- .../CompositeMutationDispatchTests.cs | 22 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/LibTmux/Window.Topology.cs b/src/LibTmux/Window.Topology.cs index 2228c1e..936d697 100644 --- a/src/LibTmux/Window.Topology.cs +++ b/src/LibTmux/Window.Topology.cs @@ -1009,7 +1009,7 @@ private void ValidateLayout(string layout) // A layout tmux dumped begins with a four-digit hexadecimal checksum, // and every version parses those. Named layouts are checked against the // set the running tmux knows. - if (Uri.IsHexDigit(layout[0]) + if (HasCustomLayoutPrefix(layout) || UniversalLayouts.Contains(layout, StringComparer.Ordinal)) { return; @@ -1028,6 +1028,14 @@ private void ValidateLayout(string layout) _id); } + private static bool HasCustomLayoutPrefix(string layout) => + layout.Length > 5 + && layout[4] == ',' + && char.IsAsciiHexDigit(layout[0]) + && char.IsAsciiHexDigit(layout[1]) + && char.IsAsciiHexDigit(layout[2]) + && char.IsAsciiHexDigit(layout[3]); + private string Target => _id.ToString(); // A bare window id lets tmux choose which link it means, so any operation diff --git a/tests/LibTmux.UnitTests/Entities/CompositeMutationDispatchTests.cs b/tests/LibTmux.UnitTests/Entities/CompositeMutationDispatchTests.cs index 90744a3..779457d 100644 --- a/tests/LibTmux.UnitTests/Entities/CompositeMutationDispatchTests.cs +++ b/tests/LibTmux.UnitTests/Entities/CompositeMutationDispatchTests.cs @@ -73,6 +73,28 @@ public async Task Layout_first_failure_keeps_not_dispatched() Assert.Equal("layout was not dispatched", failure.Message); } + [Theory] + [InlineData("0")] + [InlineData("0000")] + [InlineData("0000x")] + [InlineData("0000,")] + public async Task Truncated_custom_layouts_are_refused_before_dispatch(string layout) + { + int dispatches = 0; + Window window = CreateWindow((request, _) => + { + Interlocked.Increment(ref dispatches); + return Task.FromResult(Success(request)); + }); + + await Assert.ThrowsAsync(() => + window.SelectLayoutAsync( + new SelectLayoutRequest(layout), + TestContext.Current.CancellationToken)); + + Assert.Equal(0, Volatile.Read(ref dispatches)); + } + [Fact] public async Task Reset_second_mutation_failure_is_unknown() { From d6644d8511e60f081958b38116bbc552e9baac13 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 10:55:33 -0500 Subject: [PATCH 050/129] ControlMode(fix[limits]): Count before rendering why: Newline-heavy argv can expand fourfold, so rendering before the request-size check defeated the allocation bound and could exhaust memory. what: - defer ordinary command rendering until after the exact request budget check - preserve the pre-rendered generation probe path - prove oversized rejection stays below the source-token allocation size --- src/LibTmux/ControlMode/ControlModeSession.cs | 15 ++++++----- .../ControlModeCorrelationTests.cs | 26 +++++++++++++++++++ 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/src/LibTmux/ControlMode/ControlModeSession.cs b/src/LibTmux/ControlMode/ControlModeSession.cs index 5e72cb9..e983605 100644 --- a/src/LibTmux/ControlMode/ControlModeSession.cs +++ b/src/LibTmux/ControlMode/ControlModeSession.cs @@ -147,7 +147,7 @@ internal async Task VerifyAttachedGenerationAsync(CancellationToken cancellation try { - IReadOnlyList output = await SendRenderedAsync( + IReadOnlyList output = await SendCoreAsync( mismatchCommand, renderedProbe, Encoding.UTF8.GetByteCount(renderedProbe), @@ -175,17 +175,17 @@ public Task> SendAsync( { ArgumentNullException.ThrowIfNull(command); ValidateGeneration(command); - string renderedCommand = ControlModeCommandRenderer.Render(command); - return SendRenderedAsync( + // Newlines expand fourfold, so enforce the byte budget before rendering. + return SendCoreAsync( command, - renderedCommand, + renderedCommand: null, ControlModeCommandRenderer.GetRenderedByteCount(command), cancellationToken); } - private Task> SendRenderedAsync( + private Task> SendCoreAsync( TmuxCommand command, - string renderedCommand, + string? renderedCommand, long renderedByteCount, CancellationToken cancellationToken) { @@ -211,7 +211,7 @@ private Task> SendRenderedAsync( private async Task> SendAdmittedAsync( TmuxCommand command, - string renderedCommand, + string? renderedCommand, long renderedByteCount, CancellationToken cancellationToken) { @@ -239,6 +239,7 @@ private async Task> SendAdmittedAsync( nameof(command)); } + renderedCommand ??= ControlModeCommandRenderer.Render(command); var pending = new PendingControlModeCommand(command, sentinel); Task> transaction = DispatchAndWaitAsync( renderedCommand, diff --git a/tests/LibTmux.UnitTests/ControlMode/ControlModeCorrelationTests.cs b/tests/LibTmux.UnitTests/ControlMode/ControlModeCorrelationTests.cs index 4fa664c..4bc774f 100644 --- a/tests/LibTmux.UnitTests/ControlMode/ControlModeCorrelationTests.cs +++ b/tests/LibTmux.UnitTests/ControlMode/ControlModeCorrelationTests.cs @@ -133,6 +133,32 @@ public async Task A_request_beyond_its_byte_limit_is_not_dispatched() Assert.Empty(await following); } + [Fact] + public async Task An_oversized_newline_request_is_counted_before_it_is_rendered() + { + CancellationToken token = TestContext.Current.CancellationToken; + const int TokenLength = 256 * 1024; + var process = new ScriptedProcess(expectedWrites: 0); + await using var session = new ControlModeSession( + process, + sentinelFactory: () => "f", + limits: new ControlModeLimits(maxRequestBytes: 8)); + await session.WaitForReadyAsync(token); + TmuxCommand command = TmuxCommand.Create( + "display-message", + new string('\n', TokenLength)); + + long before = GC.GetAllocatedBytesForCurrentThread(); + Task> send = session.SendAsync(command, token); + long allocated = GC.GetAllocatedBytesForCurrentThread() - before; + + await Assert.ThrowsAsync(async () => await send); + Assert.True( + allocated < TokenLength, + $"Rejecting the request allocated {allocated} bytes before returning."); + Assert.Empty(process.Writes); + } + [Fact] public async Task A_canceled_request_keeps_its_slot_until_its_fence() { From 8893e9ecae581a0f0d40407caf0a7a3f29b15602 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 10:56:39 -0500 Subject: [PATCH 051/129] Query(fix[json]): Bound structural traversal first why: Writer-side semantic validation recursed through public ASTs before enforcing the frozen depth and node ceilings, allowing a stack overflow ahead of rejection. what: - let the bounded wire walk reject excessive structure before semantic recursion - retain semantic validation before serialized bytes are returned - prove depth and node failures win over malformed leaf semantics --- .../QueryDocumentJsonConverter.cs | 20 +++++++++------- .../LibTmux.UnitTests/Query/QueryJsonTests.cs | 24 +++++++++++++++++++ 2 files changed, 35 insertions(+), 9 deletions(-) diff --git a/src/LibTmux.Query.Json/QueryDocumentJsonConverter.cs b/src/LibTmux.Query.Json/QueryDocumentJsonConverter.cs index 90e6a5e..1ca2601 100644 --- a/src/LibTmux.Query.Json/QueryDocumentJsonConverter.cs +++ b/src/LibTmux.Query.Json/QueryDocumentJsonConverter.cs @@ -29,15 +29,6 @@ public override void Write( { ArgumentNullException.ThrowIfNull(writer); ArgumentNullException.ThrowIfNull(value); - try - { - QueryDocumentValidator.Validate(value); - } - catch (UnsupportedQueryExpressionException exception) - { - throw new JsonException(exception.Message, exception); - } - if (!string.Equals(value.Schema, QueryDocument.CurrentSchema, StringComparison.Ordinal)) { throw new JsonException( @@ -59,6 +50,17 @@ public override void Write( writer.WritePropertyName("predicate"); WriteNode(writer, value.Predicate, depth: 1); writer.WriteEndObject(); + + // The bounded walk must run first so semantic validation cannot recurse + // beyond the v1 depth or node ceilings. + try + { + QueryDocumentValidator.Validate(value); + } + catch (UnsupportedQueryExpressionException exception) + { + throw new JsonException(exception.Message, exception); + } } private static string Wire(QueryTarget target) => target switch diff --git a/tests/LibTmux.UnitTests/Query/QueryJsonTests.cs b/tests/LibTmux.UnitTests/Query/QueryJsonTests.cs index 44e33b5..abed83b 100644 --- a/tests/LibTmux.UnitTests/Query/QueryJsonTests.cs +++ b/tests/LibTmux.UnitTests/Query/QueryJsonTests.cs @@ -202,6 +202,30 @@ public void A_document_at_the_maximum_logical_depth_round_trips() () => QueryJson.Serialize(Document(new NotNode(predicate)))); } + [Fact] + public void Serialization_applies_structural_budgets_before_semantic_validation() + { + QueryNode tooDeep = SessionName; + for (int depth = 0; depth < QueryJsonLimits.V1.MaximumDepth; depth++) + { + tooDeep = new NotNode(tooDeep); + } + + JsonException depthFailure = Assert.Throws( + () => QueryJson.Serialize(Document(tooDeep))); + Assert.Contains("maximum nesting depth", depthFailure.Message, StringComparison.Ordinal); + + QueryNode[] tooMany = + [ + .. Enumerable.Repeat( + SessionName, + QueryJsonLimits.V1.MaximumNodes), + ]; + JsonException nodeFailure = Assert.Throws( + () => QueryJson.Serialize(Document(new OrNode(tooMany)))); + Assert.Contains("maximum node count", nodeFailure.Message, StringComparison.Ordinal); + } + [Fact] public void An_unknown_node_kind_is_refused_rather_than_guessed() { From 48a65e95db22d9aa8c823034144bcd53a6d188cc Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 10:57:36 -0500 Subject: [PATCH 052/129] ControlMode(fix[events]): Surface pump failures why: Events-only consumers saw normal completion after malformed protocol or transport failure even though sends and disposal observed the fault. what: - complete the bounded event stream with the original pump exception - drain buffered events before rethrowing with preserved exception identity - document and prove normal completion versus failed completion --- src/LibTmux/ControlMode/ControlModeEventBuffer.cs | 10 +++++++++- src/LibTmux/ControlMode/ControlModeSession.cs | 2 +- src/LibTmux/ControlMode/IControlModeSession.cs | 13 ++++++++----- .../ControlMode/ControlModeSessionFailureTests.cs | 4 +++- 4 files changed, 21 insertions(+), 8 deletions(-) diff --git a/src/LibTmux/ControlMode/ControlModeEventBuffer.cs b/src/LibTmux/ControlMode/ControlModeEventBuffer.cs index 8a44004..566df40 100644 --- a/src/LibTmux/ControlMode/ControlModeEventBuffer.cs +++ b/src/LibTmux/ControlMode/ControlModeEventBuffer.cs @@ -1,4 +1,5 @@ using System.Runtime.CompilerServices; +using System.Runtime.ExceptionServices; namespace LibTmux.Internal; @@ -12,6 +13,7 @@ internal sealed class ControlModeEventBuffer private TaskCompletionSource _changed = NewSignal(); private long _dropped; private long _reported; + private ExceptionDispatchInfo? _completionError; private bool _completed; internal ControlModeEventBuffer(int capacity, Action? afterDequeue = null) @@ -51,7 +53,7 @@ internal bool TryWrite(TmuxEvent item) return true; } - internal void Complete() + internal void Complete(Exception? error = null) { TaskCompletionSource? changed = null; lock (_gate) @@ -59,6 +61,9 @@ internal void Complete() if (!_completed) { _completed = true; + _completionError = error is null + ? null + : ExceptionDispatchInfo.Capture(error); changed = _changed; } } @@ -76,6 +81,7 @@ internal async IAsyncEnumerable ReadAllAsync( Task? wait = null; long dropped = 0; long totalDropped = 0; + ExceptionDispatchInfo? completionError = null; bool completed = false; lock (_gate) { @@ -90,6 +96,7 @@ internal async IAsyncEnumerable ReadAllAsync( else if (_completed) { completed = true; + completionError = _completionError; } else { @@ -99,6 +106,7 @@ internal async IAsyncEnumerable ReadAllAsync( if (completed) { + completionError?.Throw(); yield break; } diff --git a/src/LibTmux/ControlMode/ControlModeSession.cs b/src/LibTmux/ControlMode/ControlModeSession.cs index e983605..ed2421a 100644 --- a/src/LibTmux/ControlMode/ControlModeSession.cs +++ b/src/LibTmux/ControlMode/ControlModeSession.cs @@ -481,7 +481,7 @@ private async Task PumpAsync() finally { _events.TryWrite(new TmuxExitEvent(exitReason)); - _events.Complete(); + _events.Complete(pumpFailure); Exception terminalFailure = pumpFailure ?? new InvalidOperationException( WithStandardError( "The tmux control client exited before it finished attaching.")); diff --git a/src/LibTmux/ControlMode/IControlModeSession.cs b/src/LibTmux/ControlMode/IControlModeSession.cs index 373d5b4..3591f02 100644 --- a/src/LibTmux/ControlMode/IControlModeSession.cs +++ b/src/LibTmux/ControlMode/IControlModeSession.cs @@ -10,17 +10,20 @@ namespace LibTmux; /// /// /// Disposing ends the client. Everything a caller reads comes through -/// , which completes after the client exits. +/// , which completes after a normal exit and faults after +/// buffered events when the control stream fails. /// /// public interface IControlModeSession : IAsyncDisposable { /// Reads what tmux reports for as long as the client runs. /// - /// The sequence completes after . It may be - /// enumerated once; a second enumeration reads only what has not already - /// been taken. A slow reader receives - /// instead of silently missing data when the bounded buffer overflows. + /// The sequence completes after on a normal + /// exit and faults after buffered events when the control stream fails. It + /// may be enumerated once; a second enumeration reads only what has not + /// already been taken. A slow reader receives + /// instead of silently missing data + /// when the bounded buffer overflows. /// public IAsyncEnumerable Events { get; } diff --git a/tests/LibTmux.UnitTests/ControlMode/ControlModeSessionFailureTests.cs b/tests/LibTmux.UnitTests/ControlMode/ControlModeSessionFailureTests.cs index 226f159..ffd6c1b 100644 --- a/tests/LibTmux.UnitTests/ControlMode/ControlModeSessionFailureTests.cs +++ b/tests/LibTmux.UnitTests/ControlMode/ControlModeSessionFailureTests.cs @@ -172,9 +172,11 @@ public async Task Terminal_fault_rejects_commands_when_the_process_still_claims_ await session.WaitForReadyAsync(token); Task eventsCompleted = DrainEventsAsync(session.Events, token); process.EndOutput(pumpFailure); - await eventsCompleted.WaitAsync(token); + IOException eventFailure = await Assert.ThrowsAsync( + async () => await eventsCompleted.WaitAsync(token)); Assert.False(session.IsRunning); + Assert.Same(pumpFailure, eventFailure); await Assert.ThrowsAsync( () => session.SendAsync( TmuxCommand.Create("display-message", "-p", "too-late"), From 0204f41877f70b4fb37a27958b8f6c2ca9391f56 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 10:58:52 -0500 Subject: [PATCH 053/129] Docs(fix[claims]): Match current proof boundaries why: Contributor guidance overstated package-wide NativeAOT support, and the API decision mixed its historical approval boundary with current implementation status. what: - limit NativeAOT claims to the proven core package and Linux smoke - describe required Linux and advisory macOS compatibility lanes accurately - distinguish ADR approval-time criteria from current production state --- .github/CONTRIBUTING.md | 8 +++-- .github/workflows/dotnet.yml | 8 ++--- docs/decisions/0004-public-api-approval.md | 35 +++++++++++----------- 3 files changed, 26 insertions(+), 25 deletions(-) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index b9820eb..cdbdf7e 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -485,8 +485,12 @@ hour. ## Compatibility -tmux **3.2a through 3.7b**, on **net8.0** and **net10.0**, on Linux and macOS. -Windows is unsupported. The packages are trim- and ahead-of-time-safe. +Stable tmux **3.2a and newer**, on **net8.0** and **net10.0**. The required +Linux matrix covers 3.2a through 3.7b; the advisory macOS lane uses the current +Homebrew tmux. Windows is unsupported. The `LibTmux` core package is trim- and +ahead-of-time-analyzer gated and has a Linux NativeAOT execution smoke test. +Optional packages make narrower compatibility claims in their project files +and package READMEs. During alpha the public API can change in any release with no deprecation period, so a consumer pins an exact version. Widening the supported range means diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 5e09b0d..7625f84 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -211,12 +211,8 @@ jobs: tmux -V - name: Restore - # Locked mode is off here, and omitting --locked-mode is not enough to - # do that: Directory.Build.props turns RestoreLockedMode on whenever CI - # is set. The lock files are generated for the Linux runtime identifier - # this repository publishes ahead of time, so a macOS restore resolves - # osx-arm64 and NU1004s against them -- which is the lock files being - # right about Linux, not a dependency problem. + # Directory.Build.props enables locked mode in CI. This advisory macOS + # lane opts out; the required Linux lanes remain the lock-file gate. run: dotnet restore LibTmux.slnx -p:RestoreLockedMode=false - name: Build diff --git a/docs/decisions/0004-public-api-approval.md b/docs/decisions/0004-public-api-approval.md index 8f82e58..d11d8be 100644 --- a/docs/decisions/0004-public-api-approval.md +++ b/docs/decisions/0004-public-api-approval.md @@ -9,17 +9,18 @@ canonical API and parity documents retain the durable contract. ADR 0006 supersedes this decision's exact capability-profile selection rule and closed stable-version support boundary. -This decision approves names, signatures, ownership, package placement, and -parity destinations. It does not claim that production code or behavioral -evidence exists. Every parity row remains `implementationStatus=not_started` -and `evidenceStatus=none` until its production component passes its full gate. +This decision originally approved names, signatures, ownership, package +placement, and parity destinations without claiming that production code or +behavioral evidence existed. That approval boundary is historical: production +is implemented, and current status belongs to the parity ledger and release +evidence. ## Context The Python public inventory contains 626 source-grounded rows. Decisions 0001, 0002, and 0003 select the raw-byte process transport, immutable hierarchy, and -closed query catalog. A production port still needs one idiomatic -.NET surface rather than a transliteration of Python implementation details. +closed query catalog. The production port keeps one idiomatic .NET surface +rather than a transliteration of Python implementation details. The canonical contract is `docs/public-api.json`. `docs/public-api.md` is a deterministic human review generated from that @@ -246,31 +247,31 @@ portable APIs and pure tests remain supported. ## Consequences -Production work implements the canonical JSON contract rather than inventing -signatures component by component. Each of the 18 production components owns -an exhaustive set of ledger rows and updates implementation and evidence state -only after its behavioral and platform gates pass. +Production follows the canonical JSON contract rather than inventing signatures +component by component. Each of the 18 production components owns an exhaustive +set of ledger rows, with implementation and evidence state updated only after +its behavioral and platform gates pass. The immutable hierarchy requires callers to retain mutation results. In exchange, captured state is safe for concurrent reads and I/O stays visible. Named owned scopes add a small amount of ceremony but make destructive cleanup intent explicit. -The generated query catalog adds build machinery, but its implementation -details do not leak into consumer signatures. The two-package boundary keeps -JSON optional without introducing a second query model. +The closed query catalog remains internal and does not leak its implementation +details into consumer signatures. The two-package boundary keeps JSON optional +without introducing a second query model. -## Approval criteria +## Approval criteria at decision time -The public contract is accepted only while all of the following remain true: +The public contract was accepted only while all of the following were true: - deterministic Markdown rendering matches the canonical JSON; - every approved or internalized parity row names an exact member; - every exclusion states its reason and replacement; - all 626 rows belong to exactly one component numbered 1 through 18; -- implementation and evidence remain absent at this approval boundary; +- implementation and evidence were absent at this approval boundary; - public member IDs and overloads are unique; - async, cancellation, ownership, platform, query, ID, and exception rules validate mechanically; and -- at the approval boundary, the production plan owned every row once and named +- the production plan owned every row once and named the full completion gates. From 7e0d6e655ea0b3d04d370ba70270f39a03efbd06 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 10:59:26 -0500 Subject: [PATCH 054/129] ControlMode(test[argv]): Use a portable literal oracle why: tmux 3.4 rewrites dollar signs in display-message output, so the compatibility test reported an argv failure that also occurs through the native CLI. what: - round-trip the typed value through a named buffer and saved bytes - avoid display and control-output serialization differences across supported tmux releases --- .../ControlMode/ControlModeSessionTests.cs | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/tests/LibTmux.IntegrationTests/ControlMode/ControlModeSessionTests.cs b/tests/LibTmux.IntegrationTests/ControlMode/ControlModeSessionTests.cs index 8b8a25f..b1bc1ad 100644 --- a/tests/LibTmux.IntegrationTests/ControlMode/ControlModeSessionTests.cs +++ b/tests/LibTmux.IntegrationTests/ControlMode/ControlModeSessionTests.cs @@ -144,12 +144,28 @@ public async Task Typed_arguments_are_literal_tmux_arguments() await using IControlModeSession control = await server.EnterControlModeAsync( cancellationToken: token); const string Value = "space ' ; $HOME \\ π"; + const string BufferName = "libtmux-control-literal"; + string path = Path.Combine( + Path.GetTempPath(), + $"libtmux-control-literal-{Guid.NewGuid():N}"); - IReadOnlyList output = await control.SendAsync( - TmuxCommand.Create("display-message", "-p", Value), - token); - - Assert.Equal([Value], output); + try + { + IReadOnlyList written = await control.SendAsync( + TmuxCommand.Create("set-buffer", "-b", BufferName, Value), + token); + IReadOnlyList saved = await control.SendAsync( + TmuxCommand.Create("save-buffer", "-b", BufferName, path), + token); + + Assert.Empty(written); + Assert.Empty(saved); + Assert.Equal(Value, await File.ReadAllTextAsync(path, token)); + } + finally + { + File.Delete(path); + } } [UnixFact] From 947207a9c4692f15437ffcbf8ae3fafbb1530b58 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 11:00:11 -0500 Subject: [PATCH 055/129] Pane(fix[input]): Keep text out of key mode why: SendTextAsync let tmux interpret key-like words as actions instead of typing them. what: - force literal mode for SendTextAsync - cover exact argv and real tmux behavior --- src/LibTmux/Pane.Operations.cs | 2 +- .../Hierarchy/PaneOperationsTests.cs | 4 ++++ .../Entities/PaneSendKeysDispatchTests.cs | 21 +++++++++++++++++++ 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/LibTmux/Pane.Operations.cs b/src/LibTmux/Pane.Operations.cs index 0cf7140..25efea9 100644 --- a/src/LibTmux/Pane.Operations.cs +++ b/src/LibTmux/Pane.Operations.cs @@ -185,7 +185,7 @@ public Task SendTextAsync( string text, bool enter = true, CancellationToken cancellationToken = default) => - SendKeysAsync(new SendKeysRequest(text, enter), cancellationToken); + SendKeysAsync(new SendKeysRequest(text, enter, literal: true), cancellationToken); /// Sends the configured prefix key to the pane. /// Whether the secondary prefix is sent. diff --git a/tests/LibTmux.IntegrationTests/Hierarchy/PaneOperationsTests.cs b/tests/LibTmux.IntegrationTests/Hierarchy/PaneOperationsTests.cs index e556957..86649d6 100644 --- a/tests/LibTmux.IntegrationTests/Hierarchy/PaneOperationsTests.cs +++ b/tests/LibTmux.IntegrationTests/Hierarchy/PaneOperationsTests.cs @@ -36,6 +36,10 @@ await pane.SendKeysAsync( Assert.Contains("LITERALPAYLOAD", afterText, StringComparison.Ordinal); Assert.DoesNotContain("echo LITERALPAYLOADEnter", afterText, StringComparison.Ordinal); + await pane.SendTextAsync("Enter", enter: false, token); + Assert.Contains("Enter", await ReadPaneAsync(pane, "Enter", token), StringComparison.Ordinal); + await pane.SendKeysAsync(new SendKeysRequest("C-u"), token); + // Keeping a line out of shell history is a leading space, not a flag. await pane.SendKeysAsync( new SendKeysRequest("echo hidden", suppressHistory: true, enter: false), diff --git a/tests/LibTmux.UnitTests/Entities/PaneSendKeysDispatchTests.cs b/tests/LibTmux.UnitTests/Entities/PaneSendKeysDispatchTests.cs index 89acda6..8225f2d 100644 --- a/tests/LibTmux.UnitTests/Entities/PaneSendKeysDispatchTests.cs +++ b/tests/LibTmux.UnitTests/Entities/PaneSendKeysDispatchTests.cs @@ -10,6 +10,27 @@ public sealed class PaneSendKeysDispatchTests { private static readonly ServerGeneration Generation = new(91, 901); + [Fact] + public async Task Send_text_uses_literal_mode() + { + var dispatched = new ConcurrentQueue(); + Pane pane = CreatePane((request, _) => + { + dispatched.Enqueue([.. request.LogicalArguments]); + return Task.FromResult(Success(request.LogicalArguments)); + }); + + await pane.SendTextAsync( + "Enter", + enter: false, + TestContext.Current.CancellationToken); + + string[] sent = Assert.Single(dispatched); + int commandStart = Array.IndexOf(sent, "send-keys"); + Assert.NotEqual(-1, commandStart); + Assert.Equal(["send-keys", "-t", "%1", "-l", "Enter"], sent[commandStart..]); + } + [Fact] public async Task Enter_not_dispatched_after_text_is_reported_as_unknown() { From 2912a7afe234a1e315cdf0c5ce70c950c45c993e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 11:03:46 -0500 Subject: [PATCH 056/129] Workspace(fix[startup]): Wait for shell acknowledgement why: The builder could report success before a new pane shell was able to execute its first workspace command. what: - gate command-bearing panes with a bounded in-band acknowledgement - preserve caller cancellation and fail before sending user commands - document and prove the readiness timeout against real tmux --- src/LibTmux.Workspace/PublicAPI.Unshipped.txt | 2 +- src/LibTmux.Workspace/README.md | 8 +- src/LibTmux.Workspace/WorkspaceBuilder.cs | 65 +++++++++++++++- .../Workspace/WorkspaceBuilderTests.cs | 78 ++++++++++++++++++- 4 files changed, 146 insertions(+), 7 deletions(-) diff --git a/src/LibTmux.Workspace/PublicAPI.Unshipped.txt b/src/LibTmux.Workspace/PublicAPI.Unshipped.txt index 2ba5372..726ae8f 100644 --- a/src/LibTmux.Workspace/PublicAPI.Unshipped.txt +++ b/src/LibTmux.Workspace/PublicAPI.Unshipped.txt @@ -1,7 +1,7 @@ #nullable enable LibTmux.Workspace.WorkspaceBuilder LibTmux.Workspace.WorkspaceBuilder.BuildAsync(LibTmux.Workspace.WorkspaceFile! workspace, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! -LibTmux.Workspace.WorkspaceBuilder.WorkspaceBuilder(LibTmux.Server! server) -> void +LibTmux.Workspace.WorkspaceBuilder.WorkspaceBuilder(LibTmux.Server! server, System.TimeSpan? shellReadyTimeout = null) -> void LibTmux.Workspace.WorkspaceFile LibTmux.Workspace.WorkspaceFile.Options.get -> System.Collections.Generic.IReadOnlyDictionary! LibTmux.Workspace.WorkspaceFile.SessionName.get -> string? diff --git a/src/LibTmux.Workspace/README.md b/src/LibTmux.Workspace/README.md index f214320..847ecc0 100644 --- a/src/LibTmux.Workspace/README.md +++ b/src/LibTmux.Workspace/README.md @@ -68,8 +68,12 @@ rebased to the directory containing `session.yaml`. contains only layouts that tmux rejected; those windows remain usable. Other tmux failures throw and can leave a partially built session. The builder -is not transactional. A missing session name or empty window list raises -`WorkspaceFormatException` before creating anything. +is not transactional. Before sending the first workspace command to a pane, it +waits up to ten seconds for that pane's shell to acknowledge input. Pass a +different timeout to the `WorkspaceBuilder` constructor when startup needs a +different budget. An expired wait raises `TmuxWaitTimeoutException` before a +workspace command reaches that pane. A missing session name or empty window +list raises `WorkspaceFormatException` before creating anything. ## What is in scope diff --git a/src/LibTmux.Workspace/WorkspaceBuilder.cs b/src/LibTmux.Workspace/WorkspaceBuilder.cs index d6e4544..8bbd265 100644 --- a/src/LibTmux.Workspace/WorkspaceBuilder.cs +++ b/src/LibTmux.Workspace/WorkspaceBuilder.cs @@ -6,14 +6,28 @@ namespace LibTmux.Workspace; [UnsupportedOSPlatform("windows")] public sealed class WorkspaceBuilder { + private static readonly TimeSpan DefaultShellReadyTimeout = TimeSpan.FromSeconds(10); private readonly Server _server; + private readonly TimeSpan _shellReadyTimeout; /// Initializes a builder against one server. /// The server the session is built on. - public WorkspaceBuilder(Server server) + /// + /// How long a pane may take to acknowledge shell input, or null for ten seconds. + /// + public WorkspaceBuilder(Server server, TimeSpan? shellReadyTimeout = null) { ArgumentNullException.ThrowIfNull(server); + if (shellReadyTimeout is TimeSpan timeout && timeout <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException( + nameof(shellReadyTimeout), + shellReadyTimeout, + "A shell needs time to become ready."); + } + _server = server; + _shellReadyTimeout = shellReadyTimeout ?? DefaultShellReadyTimeout; } /// Builds a session from a workspace. @@ -21,6 +35,9 @@ public WorkspaceBuilder(Server server) /// Cancels the tmux commands. /// What was built, and what could not be. /// The workspace describes no session. + /// + /// A pane did not acknowledge shell input before its readiness timeout. + /// public async Task BuildAsync( WorkspaceFile workspace, CancellationToken cancellationToken = default) @@ -114,7 +131,7 @@ private static async Task SelectFocusedAsync( } } - private static async Task FillAsync( + private async Task FillAsync( Window window, WorkspaceWindow described, WorkspaceFile workspace, @@ -139,6 +156,11 @@ private static async Task FillAsync( cancellationToken) .ConfigureAwait(false); + if (pane.ShellCommands.Count > 0) + { + await WaitForShellAsync(target, cancellationToken).ConfigureAwait(false); + } + foreach (string command in pane.ShellCommands) { await target.SendTextAsync(command, cancellationToken: cancellationToken) @@ -191,4 +213,43 @@ await made[index].SelectAsync(cancellationToken: cancellationToken) return await window.RefreshAsync(cancellationToken).ConfigureAwait(false); } + + private async Task WaitForShellAsync( + Pane pane, + CancellationToken cancellationToken) + { + string channel = $"libtmux-workspace-ready-{Guid.NewGuid():N}"; + string binary = ShellQuote(pane.Server.ConnectionOptions.TmuxBinaryPath); + string signal = $"{binary} wait-for -S {channel}"; + using CancellationTokenSource timeout = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken); + timeout.CancelAfter(_shellReadyTimeout); + + try + { + await pane.SendKeysAsync( + new SendKeysRequest( + text: signal, + suppressHistory: true, + literal: true), + timeout.Token) + .ConfigureAwait(false); + await pane.Server.WaitForAsync( + new WaitForRequest(channel, TmuxWaitMode.Wait), + timeout.Token) + .ConfigureAwait(false); + } + catch (OperationCanceledException failure) when ( + timeout.IsCancellationRequested && !cancellationToken.IsCancellationRequested) + { + throw new TmuxWaitTimeoutException( + $"Pane {pane.Id} did not accept shell input within " + + $"{_shellReadyTimeout.TotalSeconds:0.###} seconds.", + _shellReadyTimeout, + failure); + } + } + + private static string ShellQuote(string value) => + $"'{value.Replace("'", "'\"'\"'", StringComparison.Ordinal)}'"; } diff --git a/tests/LibTmux.IntegrationTests/Workspace/WorkspaceBuilderTests.cs b/tests/LibTmux.IntegrationTests/Workspace/WorkspaceBuilderTests.cs index ba7a060..72102e3 100644 --- a/tests/LibTmux.IntegrationTests/Workspace/WorkspaceBuilderTests.cs +++ b/tests/LibTmux.IntegrationTests/Workspace/WorkspaceBuilderTests.cs @@ -32,6 +32,17 @@ public sealed class WorkspaceBuilderTests - echo command-two """; + [Fact] + public void Shell_ready_timeout_must_be_positive() + { + Server server = Server.Open(); + + Assert.Throws( + () => new WorkspaceBuilder(server, TimeSpan.Zero)); + Assert.Throws( + () => new WorkspaceBuilder(server, TimeSpan.FromTicks(-1))); + } + [UnixFact] public async Task A_workspace_file_becomes_a_session() { @@ -143,6 +154,63 @@ await pane.CaptureAsync(cancellationToken: cancellation)) Assert.False(receivedBlankLine); } + [UnixFact] + public async Task A_shell_that_consumes_the_probe_times_out_before_user_commands() + { + CancellationToken token = TestContext.Current.CancellationToken; + string directory = Directory.CreateTempSubdirectory("libtmux-workspace-timeout").FullName; + string received = Path.Combine(directory, "received"); + + try + { + string shell = Path.Combine(directory, "sh"); + await File.WriteAllTextAsync( + shell, + $$""" + #!/bin/sh + set -eu + IFS= read -r first + printf '%s\n' "$first" > {{ShellQuote(received)}} + exec /bin/sh + """, + token); + File.SetUnixFileMode( + shell, + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + TmuxTestFactory factory = new(); + await using TemporaryServerScope scope = await factory.CreateServerAsync( + HarnessOptions(shell), + token); + WorkspaceFile workspace = WorkspaceFile.Parse(""" + session_name: libtmux-shell-timeout + windows: + - panes: + - shell_command: echo WORKSPACE_USER_COMMAND + """); + + TmuxWaitTimeoutException failure = await Assert.ThrowsAsync( + () => new WorkspaceBuilder(scope.Server, TimeSpan.FromSeconds(1)) + .BuildAsync(workspace, token)); + + Assert.Equal(TimeSpan.FromSeconds(1), failure.Timeout); + string firstInput = await File.ReadAllTextAsync(received, token); + Assert.Contains("wait-for -S libtmux-workspace-ready-", firstInput, StringComparison.Ordinal); + Assert.DoesNotContain("WORKSPACE_USER_COMMAND", firstInput, StringComparison.Ordinal); + Server server = await scope.Server.ConnectAsync(token); + Session session = Assert.Single(await server.GetSessionsAsync(token)); + Window window = Assert.Single(await session.GetWindowsAsync(token)); + Pane pane = Assert.Single(await window.GetPanesAsync(token)); + string captured = string.Join( + '\n', + await pane.CaptureAsync(cancellationToken: token)); + Assert.DoesNotContain("WORKSPACE_USER_COMMAND", captured, StringComparison.Ordinal); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + [UnixFact] public async Task First_pane_directory_controls_window_creation() { @@ -226,9 +294,15 @@ public void A_file_that_is_not_a_workspace_is_refused() Assert.Single(nameless.Windows); } - private static TmuxTestOptions HarnessOptions() => + private static TmuxTestOptions HarnessOptions(string? shell = null) => new(new ServerConnectionOptions( tmuxBinaryPath: Environment.GetEnvironmentVariable("LIBTMUX_TMUX") ?? "tmux", socketName: $"ltw-{Guid.NewGuid():N}"[..20], - configurationFile: "/dev/null")); + configurationFile: "/dev/null", + childEnvironment: shell is null + ? null + : new Dictionary { ["SHELL"] = shell })); + + private static string ShellQuote(string value) => + $"'{value.Replace("'", "'\"'\"'", StringComparison.Ordinal)}'"; } From 1eea9029fc4938cbea6c4e30c8c1b872144c3f84 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 11:09:20 -0500 Subject: [PATCH 057/129] Query(fix[execution]): Honor aggregate cancellation why: Matching bounded individual regex calls but could not stop a received document between source elements or predicate nodes. what: - add cancellable matching for translated documents - preserve caller cancellation when regex timeout races it - document the aggregate and per-match boundaries --- docs/api/README.md | 1 + docs/public-api.json | 34 +++++++++ docs/public-api.md | 1 + docs/quality-bar.md | 2 +- src/LibTmux.Query.Json/README.md | 10 ++- src/LibTmux/PublicAPI.Unshipped.txt | 1 + src/LibTmux/Query/QueryExtensions.cs | 49 +++++++++++++ src/LibTmux/Query/QueryInterpreter.cs | 71 ++++++++++++++----- .../Query/QuerySemanticsTests.cs | 67 ++++++++++++++++- 9 files changed, 217 insertions(+), 19 deletions(-) diff --git a/docs/api/README.md b/docs/api/README.md index 7cbf3ee..4b70621 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -284,6 +284,7 @@ modes differ. | `LibTmux.Query.QueryEdgeParser.ParseNameContains(LibTmux.Query.QueryTarget,System.String)` | Parses a name__contains lookup into a query document. | | ```LibTmux.Query.QueryExtensions.Compile``1(LibTmux.Query.QueryDocument)``` | Compiles a document into an in-memory predicate. | | ```LibTmux.Query.QueryExtensions.Matching``1(System.Collections.Generic.IEnumerable{``0},LibTmux.Query.QueryDocument)``` | Filters a snapshot with an already translated document. | +| ```LibTmux.Query.QueryExtensions.Matching``1(System.Collections.Generic.IEnumerable{``0},LibTmux.Query.QueryDocument,System.Threading.CancellationToken)``` | Filters a snapshot with a cancellable translated document. | | ```LibTmux.Query.QueryExtensions.Matching``1(System.Collections.Generic.IEnumerable{``0},System.Linq.Expressions.Expression{System.Func{``0,System.Boolean}})``` | Filters a snapshot with a declarative predicate. | | ```LibTmux.Query.QueryExtensions.Translate``1(System.Linq.Expressions.Expression{System.Func{``0,System.Boolean}})``` | Translates an expression into a wire document. | | `LibTmux.Query.RegexNode.#ctor(LibTmux.Query.QueryNode,System.String,System.String,System.Text.RegularExpressions.RegexOptions)` | A constant-pattern regular expression match. | diff --git a/docs/public-api.json b/docs/public-api.json index 28c8e11..8fc26c3 100644 --- a/docs/public-api.json +++ b/docs/public-api.json @@ -8820,6 +8820,40 @@ "platformAnnotations": [], "summary": "Evaluates one canonical query document against an explicit snapshot." }, + { + "id": "M:LibTmux.Query.QueryExtensions.Matching``1(IEnumerable,QueryDocument,CancellationToken)", + "declaringType": "T:LibTmux.Query.QueryExtensions", + "name": "Matching", + "kind": "method", + "visibility": "public", + "package": "LibTmux", + "static": true, + "genericParameters": [ + "T" + ], + "returnType": "IReadOnlyList", + "parameters": [ + { + "name": "source", + "type": "IEnumerable", + "modifier": "this" + }, + { + "name": "document", + "type": "QueryDocument" + }, + { + "name": "cancellationToken", + "type": "CancellationToken" + } + ], + "signature": "IReadOnlyList LibTmux.Query.QueryExtensions.Matching(this IEnumerable source, QueryDocument document, CancellationToken cancellationToken)", + "performsIO": false, + "processBacked": false, + "portable": true, + "platformAnnotations": [], + "summary": "Evaluates one canonical query document with cooperative cancellation." + }, { "id": "M:LibTmux.Query.QueryExtensions.Translate``1(Expression>)", "declaringType": "T:LibTmux.Query.QueryExtensions", diff --git a/docs/public-api.md b/docs/public-api.md index 1604c54..a9d9932 100644 --- a/docs/public-api.md +++ b/docs/public-api.md @@ -1249,6 +1249,7 @@ internal static class Program | ``M:LibTmux.Query.QueryExtensions.Compile``1(QueryDocument)`` | `static Func LibTmux.Query.QueryExtensions.Compile(this QueryDocument document)` | Public | Yes | Portable | Compiles the canonical direct interpreter for a query document. | | ``M:LibTmux.Query.QueryExtensions.Matching``1(IEnumerable,Expression>)`` | `static IReadOnlyList LibTmux.Query.QueryExtensions.Matching(this IEnumerable source, Expression> predicate)` | Public | Yes | Portable | Translates and evaluates a supported predicate against an explicit snapshot. | | ``M:LibTmux.Query.QueryExtensions.Matching``1(IEnumerable,QueryDocument)`` | `static IReadOnlyList LibTmux.Query.QueryExtensions.Matching(this IEnumerable source, QueryDocument document)` | Public | Yes | Portable | Evaluates one canonical query document against an explicit snapshot. | +| ``M:LibTmux.Query.QueryExtensions.Matching``1(IEnumerable,QueryDocument,CancellationToken)`` | `static IReadOnlyList LibTmux.Query.QueryExtensions.Matching(this IEnumerable source, QueryDocument document, CancellationToken cancellationToken)` | Public | Yes | Portable | Evaluates one canonical query document with cooperative cancellation. | | ``M:LibTmux.Query.QueryExtensions.Translate``1(Expression>)`` | `static QueryDocument LibTmux.Query.QueryExtensions.Translate(Expression> predicate)` | Public | Yes | Portable | Translates a supported expression into the canonical query document. | ### `T:LibTmux.Query.QueryQuantifier` diff --git a/docs/quality-bar.md b/docs/quality-bar.md index 26fc171..d3d187e 100644 --- a/docs/quality-bar.md +++ b/docs/quality-bar.md @@ -56,7 +56,7 @@ target, but it is a constraint worth naming. | Compatibility range is proven, not claimed | 7 tmux versions built from source and run against the full suite on every commit, behind a `compatibility` required check | | Version differences are modelled | Capability model; every difference has a row in [`version-deltas.json`](parity/version-deltas.json) naming the test that proves it | | Hostile output cannot crash a parser | 8,000 [fuzz cases](../tests/LibTmux.UnitTests/Fuzzing/ParserFuzzTests.cs) plus a corpus; refusal required, crash and hang forbidden | -| A query document is input, not instructions | Schema and version must be v1; string, pattern, dialect and regex-option limits enforced on **read**; matching bounded at 1s; a field resolves only through the catalog ([tests](../tests/LibTmux.UnitTests/Query/QueryJsonTrustBoundaryTests.cs)) | +| A query document is input, not instructions | Schema and version must be v1; string, pattern, dialect and regex-option limits enforced on **read**; each regex match is capped at 1s and aggregate matching accepts caller cancellation between nodes; a field resolves only through the catalog ([wire tests](../tests/LibTmux.UnitTests/Query/QueryJsonTrustBoundaryTests.cs), [execution tests](../tests/LibTmux.UnitTests/Query/QuerySemanticsTests.cs)) | | A control session survives its consumer | Bounded event channel, bounded disposal that kills the client and not the server beneath it, and a waiter whose command never reached tmux is skipped rather than handed the next reply | | A stale handle cannot hit a live server | Generation guard on one-shot **and** chained entity commands; a chain mixing servers is refused before it runs ([tests](../tests/LibTmux.IntegrationTests/Chaining/ChainGenerationTests.cs)) | | All three transports | One-shot, control mode, and chaining, each measured and each working on every supported tmux | diff --git a/src/LibTmux.Query.Json/README.md b/src/LibTmux.Query.Json/README.md index d3f216c..e840498 100644 --- a/src/LibTmux.Query.Json/README.md +++ b/src/LibTmux.Query.Json/README.md @@ -78,8 +78,12 @@ The same document filters what you already hold, wherever it was written: string received = QueryJson.Serialize(QueryExtensions.Translate( session => session.Name.StartsWith("build", StringComparison.Ordinal))); +using var queryBudget = CancellationTokenSource.CreateLinkedTokenSource(ct); +queryBudget.CancelAfter(TimeSpan.FromSeconds(1)); IReadOnlyList sessions = await server.GetSessionsAsync(ct); -IReadOnlyList matched = sessions.Matching(QueryJson.Deserialize(received)); +IReadOnlyList matched = sessions.Matching( + QueryJson.Deserialize(received), + queryBudget.Token); Console.WriteLine(matched.Count); ``` @@ -93,6 +97,10 @@ in the package as `libtmux-query-v1.schema.json`. Evaluating the result with `Compile` or `Matching` resolves public properties by name. Those methods warn trimmed callers to preserve that metadata. +For a document received from another trust boundary, use the cancellable +`Matching` overload with a deadline. It checks between source elements and +predicate nodes; a regex already running still has its separate one-second +match ceiling. ```csharp run Console.WriteLine($"depth {QueryJsonLimits.V1.MaximumDepth}, nodes {QueryJsonLimits.V1.MaximumNodes}"); diff --git a/src/LibTmux/PublicAPI.Unshipped.txt b/src/LibTmux/PublicAPI.Unshipped.txt index 09a13e9..bc350fc 100644 --- a/src/LibTmux/PublicAPI.Unshipped.txt +++ b/src/LibTmux/PublicAPI.Unshipped.txt @@ -1740,6 +1740,7 @@ static LibTmux.Query.QueryDocument.operator ==(LibTmux.Query.QueryDocument? left static LibTmux.Query.QueryEdgeParser.ParseNameContains(LibTmux.Query.QueryTarget target, string! value) -> LibTmux.Query.QueryDocument! static LibTmux.Query.QueryExtensions.Compile(this LibTmux.Query.QueryDocument! document) -> System.Func! static LibTmux.Query.QueryExtensions.Matching(this System.Collections.Generic.IEnumerable! source, LibTmux.Query.QueryDocument! document) -> System.Collections.Generic.IReadOnlyList! +static LibTmux.Query.QueryExtensions.Matching(this System.Collections.Generic.IEnumerable! source, LibTmux.Query.QueryDocument! document, System.Threading.CancellationToken cancellationToken) -> System.Collections.Generic.IReadOnlyList! static LibTmux.Query.QueryExtensions.Matching(this System.Collections.Generic.IEnumerable! source, System.Linq.Expressions.Expression!>! predicate) -> System.Collections.Generic.IReadOnlyList! static LibTmux.Query.QueryExtensions.Translate(System.Linq.Expressions.Expression!>! predicate) -> LibTmux.Query.QueryDocument! static LibTmux.Query.QueryNode.operator !=(LibTmux.Query.QueryNode? left, LibTmux.Query.QueryNode? right) -> bool diff --git a/src/LibTmux/Query/QueryExtensions.cs b/src/LibTmux/Query/QueryExtensions.cs index 2e376de..db7771a 100644 --- a/src/LibTmux/Query/QueryExtensions.cs +++ b/src/LibTmux/Query/QueryExtensions.cs @@ -1,5 +1,6 @@ using System.Diagnostics.CodeAnalysis; using System.Linq.Expressions; +using System.Text.RegularExpressions; namespace LibTmux.Query; @@ -54,4 +55,52 @@ public static IReadOnlyList Matching( Func compiled = document.Compile(); return [.. source.Where(compiled)]; } + + /// Filters a snapshot with a cancellable translated document. + /// The filtered element type. + /// The captured elements. + /// The translated document. + /// Stops enumeration and predicate evaluation. + /// The matching elements. + /// + /// Cancellation is observed between source elements and predicate nodes. A + /// regex already running may take up to its one-second match timeout to stop. + /// + [RequiresUnreferencedCode(QueryInterpreter.TrimmingMessage)] + public static IReadOnlyList Matching( + this IEnumerable source, + QueryDocument document, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(source); + cancellationToken.ThrowIfCancellationRequested(); + Func compiled = QueryInterpreter.Compile(document, cancellationToken); + List matched = []; + using IEnumerator enumerator = source.GetEnumerator(); + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!enumerator.MoveNext()) + { + return matched; + } + + bool accepted; + try + { + accepted = compiled(enumerator.Current); + } + catch (RegexMatchTimeoutException) when (cancellationToken.IsCancellationRequested) + { + cancellationToken.ThrowIfCancellationRequested(); + throw; + } + + cancellationToken.ThrowIfCancellationRequested(); + if (accepted) + { + matched.Add(enumerator.Current); + } + } + } } diff --git a/src/LibTmux/Query/QueryInterpreter.cs b/src/LibTmux/Query/QueryInterpreter.cs index 11e72e3..15c183a 100644 --- a/src/LibTmux/Query/QueryInterpreter.cs +++ b/src/LibTmux/Query/QueryInterpreter.cs @@ -17,17 +17,39 @@ internal static class QueryInterpreter [RequiresUnreferencedCode(TrimmingMessage)] internal static Func Compile(QueryDocument document) => - Compile(document, out _); + Compile(document, out _, check: null); [RequiresUnreferencedCode(TrimmingMessage)] internal static Func Compile( QueryDocument document, - out QueryBindingMetrics metrics) + CancellationToken cancellationToken) => + Compile( + document, + out _, + cancellationToken.CanBeCanceled + ? cancellationToken.ThrowIfCancellationRequested + : null); + + [RequiresUnreferencedCode(TrimmingMessage)] + internal static Func Compile( + QueryDocument document, + out QueryBindingMetrics metrics) => + Compile(document, out metrics, check: null); + + [RequiresUnreferencedCode(TrimmingMessage)] + private static Func Compile( + QueryDocument document, + out QueryBindingMetrics metrics, + Action? check) { ArgumentNullException.ThrowIfNull(document); QueryValidationResult validation = QueryDocumentValidator.Validate(document); QueryPlanBindings bindings = new(validation); - Func predicate = BindPredicate(document.Predicate, typeof(T), bindings); + Func predicate = BindPredicate( + document.Predicate, + typeof(T), + bindings, + check); metrics = bindings.Metrics; return element => predicate(element!); } @@ -35,38 +57,52 @@ internal static Func Compile( private static Func BindPredicate( QueryNode node, Type elementType, - QueryPlanBindings bindings) => node switch + QueryPlanBindings bindings, + Action? check) + { + Func predicate = node switch { - AndNode and => BindAnd(and, elementType, bindings), - OrNode or => BindOr(or, elementType, bindings), - NotNode not => BindNot(not, elementType, bindings), + AndNode and => BindAnd(and, elementType, bindings, check), + OrNode or => BindOr(or, elementType, bindings, check), + NotNode not => BindNot(not, elementType, bindings, check), ComparisonNode comparison => BindComparison(comparison, elementType, bindings), StringNode text => BindText(text, elementType, bindings), RegexNode regex => BindRegex(regex, elementType, bindings), - QuantifierNode quantifier => BindQuantifier(quantifier, elementType, bindings), + QuantifierNode quantifier => BindQuantifier(quantifier, elementType, bindings, check), FieldNode field => BindBoolean(field, elementType, bindings), ConstantNode { Value: BooleanConstant boolean } => _ => boolean.Value, _ => throw new UnsupportedQueryExpressionException( $"Node '{node.GetType().Name}' has no interpretation."), }; + return check is null + ? predicate + : element => + { + check(); + return predicate(element); + }; + } + private static Func BindAnd( AndNode and, Type elementType, - QueryPlanBindings bindings) + QueryPlanBindings bindings, + Action? check) { Func[] operands = - [.. and.Operands.Select(operand => BindPredicate(operand, elementType, bindings))]; + [.. and.Operands.Select(operand => BindPredicate(operand, elementType, bindings, check))]; return element => AllOperands(operands, element); } private static Func BindOr( OrNode or, Type elementType, - QueryPlanBindings bindings) + QueryPlanBindings bindings, + Action? check) { Func[] operands = - [.. or.Operands.Select(operand => BindPredicate(operand, elementType, bindings))]; + [.. or.Operands.Select(operand => BindPredicate(operand, elementType, bindings, check))]; return element => AnyOperand(operands, element); } @@ -99,9 +135,10 @@ private static bool AnyOperand(Func[] operands, object element) private static Func BindNot( NotNode not, Type elementType, - QueryPlanBindings bindings) + QueryPlanBindings bindings, + Action? check) { - Func operand = BindPredicate(not.Operand, elementType, bindings); + Func operand = BindPredicate(not.Operand, elementType, bindings, check); return element => !operand(element); } @@ -237,7 +274,8 @@ private static Func BindRegex( private static Func BindQuantifier( QuantifierNode quantifier, Type elementType, - QueryPlanBindings bindings) + QueryPlanBindings bindings, + Action? check) { QueryFieldAccessor relation = bindings.Field( quantifier.Relation, @@ -249,7 +287,8 @@ private static Func BindQuantifier( Func predicate = BindPredicate( quantifier.Predicate, childType, - bindings); + bindings, + check); return quantifier.Quantifier == QueryQuantifier.Any ? element => Any(relation.Read(element), predicate) diff --git a/tests/LibTmux.UnitTests/Query/QuerySemanticsTests.cs b/tests/LibTmux.UnitTests/Query/QuerySemanticsTests.cs index d30cfb4..964c9c6 100644 --- a/tests/LibTmux.UnitTests/Query/QuerySemanticsTests.cs +++ b/tests/LibTmux.UnitTests/Query/QuerySemanticsTests.cs @@ -17,6 +17,34 @@ private sealed record SessionDoubleCountRow(string SessionName, double SessionWi private sealed record NullableRow(string? SessionName); + private sealed class CancellingRow(CancellationTokenSource cancellation) + { + public string SessionName + { + get + { + cancellation.Cancel(); + return "dev"; + } + } + + public bool SessionAttached => cancellation.IsCancellationRequested + ? throw new InvalidOperationException("Evaluation continued after cancellation.") + : true; + } + + private sealed class TimedRegexRow(CancellationTokenSource cancellation) + { + public string SessionName + { + get + { + cancellation.CancelAfter(TimeSpan.FromMilliseconds(10)); + return new string('a', 10_000); + } + } + } + private sealed record PaneIdRow(string PaneId); private sealed record WindowCountRow(string WindowName, long WindowPanes); @@ -136,6 +164,43 @@ public void Matching_translates_and_interprets_the_canonical_AST() Assert.Equal("devbox", matched[0].SessionName); } + [Fact] + public void Matching_stops_between_predicate_nodes() + { + using CancellationTokenSource cancellation = CancellationTokenSource.CreateLinkedTokenSource( + TestContext.Current.CancellationToken); + var row = new CancellingRow(cancellation); + QueryDocument document = QueryExtensions.Translate( + candidate => candidate.SessionName == "dev" && candidate.SessionAttached); + + OperationCanceledException failure = Assert.Throws( + () => new[] { row }.Matching(document, cancellation.Token)); + + Assert.Equal(cancellation.Token, failure.CancellationToken); + } + + [Fact] + public void Matching_reports_cancellation_when_a_regex_timeout_wins_the_race() + { + using CancellationTokenSource cancellation = CancellationTokenSource.CreateLinkedTokenSource( + TestContext.Current.CancellationToken); + QueryDocument document = new( + QueryDocument.CurrentSchema, + QueryDocument.CurrentVersion, + QueryTarget.Session, + new RegexNode( + new FieldNode(QueryTarget.Session, "session_name"), + QueryRegexSemantics.Dialect, + "^(a+)+z$", + RegexOptions.CultureInvariant)); + + OperationCanceledException failure = Assert.Throws( + () => new[] { new TimedRegexRow(cancellation) } + .Matching(document, cancellation.Token)); + + Assert.Equal(cancellation.Token, failure.CancellationToken); + } + [Fact] public void Translation_refuses_a_field_outside_the_closed_catalog() { @@ -242,7 +307,7 @@ .. typeof(QueryExtensions).GetMethods(BindingFlags.Public | BindingFlags.Static) .Where(method => method.Name is "Compile" or "Matching"), ]; - Assert.Equal(3, evaluationMethods.Length); + Assert.Equal(4, evaluationMethods.Length); Assert.All( evaluationMethods, method => Assert.NotNull( From b6f27adea4868f0881bce630d1a0894aabeda4f8 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 11:16:04 -0500 Subject: [PATCH 058/129] Query(refactor[constants]): Remove unreachable literal kinds why: enum and instant constants had public, validator, interpreter, and JSON machinery but no field in the closed catalog could consume them, so no valid query could use either kind. what: - remove the dead constant records and their translation, binding, validation, and wire paths - trim the phantom schema and generated API documentation entries - ratchet every retained value kind to at least one catalog field --- docs/api/README.md | 7 - docs/decisions/0003-query-bakeoff.md | 14 +- docs/public-api.json | 136 ------------------ docs/public-api.md | 17 --- .../QueryDocumentJsonConverter.cs | 9 -- .../QueryDocumentJsonReader.cs | 4 - src/LibTmux.Query.Json/QueryJsonWireRules.cs | 4 - .../libtmux-query-v1.schema.json | 35 +---- src/LibTmux/PublicAPI.Unshipped.txt | 28 ---- src/LibTmux/Query/QueryDocumentValidator.cs | 11 +- src/LibTmux/Query/QueryInterpreter.cs | 2 - src/LibTmux/Query/QueryNode.cs | 9 -- src/LibTmux/Query/QueryPlanBindings.cs | 2 - src/LibTmux/Query/QueryTranslator.cs | 2 - src/LibTmux/Query/QueryValueKind.cs | 2 - .../Query/QueryJsonTrustBoundaryTests.cs | 2 - .../Query/QuerySemanticsTests.cs | 15 ++ 17 files changed, 26 insertions(+), 273 deletions(-) diff --git a/docs/api/README.md b/docs/api/README.md index 4b70621..ad2b931 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -68,9 +68,7 @@ modes differ. | `LibTmux.Query.BooleanConstant` | A boolean literal. | | `LibTmux.Query.ComparisonNode` | An ordering or equality comparison. | | `LibTmux.Query.ConstantNode` | A literal operand. | -| `LibTmux.Query.EnumConstant` | An enumeration literal, named by type and member. | | `LibTmux.Query.FieldNode` | A tmux format field operand. | -| `LibTmux.Query.InstantConstant` | An instant literal, in whole seconds since the Unix epoch. | | `LibTmux.Query.Int64Constant` | A 64-bit integer literal. | | `LibTmux.Query.NotNode` | The negation of one predicate. | | `LibTmux.Query.NullConstant` | The absence of a value. | @@ -273,9 +271,7 @@ modes differ. | `LibTmux.Query.BooleanConstant.#ctor(System.Boolean)` | A boolean literal. | | `LibTmux.Query.ComparisonNode.#ctor(LibTmux.Query.QueryComparison,LibTmux.Query.QueryNode,LibTmux.Query.QueryNode)` | An ordering or equality comparison. | | `LibTmux.Query.ConstantNode.#ctor(LibTmux.Query.QueryConstant)` | A literal operand. | -| `LibTmux.Query.EnumConstant.#ctor(System.String,System.String)` | An enumeration literal, named by type and member. | | `LibTmux.Query.FieldNode.#ctor(LibTmux.Query.QueryTarget,System.String)` | A tmux format field operand. | -| `LibTmux.Query.InstantConstant.#ctor(System.Int64)` | An instant literal, in whole seconds since the Unix epoch. | | `LibTmux.Query.Int64Constant.#ctor(System.Int64)` | A 64-bit integer literal. | | `LibTmux.Query.NotNode.#ctor(LibTmux.Query.QueryNode)` | The negation of one predicate. | | `LibTmux.Query.OrNode.#ctor(System.Collections.Generic.IReadOnlyList{LibTmux.Query.QueryNode})` | Initializes a disjunction. | @@ -872,11 +868,8 @@ modes differ. | `LibTmux.Query.ComparisonNode.Operator` | The comparison. | | `LibTmux.Query.ComparisonNode.Right` | The right operand. | | `LibTmux.Query.ConstantNode.Value` | The literal. | -| `LibTmux.Query.EnumConstant.Type` | The enumeration type name. | -| `LibTmux.Query.EnumConstant.Value` | The member name. | | `LibTmux.Query.FieldNode.Target` | The object that owns the field. | | `LibTmux.Query.FieldNode.WireName` | The tmux format token name. | -| `LibTmux.Query.InstantConstant.UnixSeconds` | Seconds since the Unix epoch. | | `LibTmux.Query.Int64Constant.Value` | The literal value. | | `LibTmux.Query.NotNode.Operand` | The negated predicate. | | `LibTmux.Query.OrNode.Operands` | Gets the ordered operands. | diff --git a/docs/decisions/0003-query-bakeoff.md b/docs/decisions/0003-query-bakeoff.md index d2c2170..ca40d58 100644 --- a/docs/decisions/0003-query-bakeoff.md +++ b/docs/decisions/0003-query-bakeoff.md @@ -86,11 +86,13 @@ produces the same canonical AST. Approve schema `libtmux-query`, version 1, and the retained JSON Schema and four positive golden documents. The wire grammar is closed, rejects unknown members and discriminators, and uses tagged null, Boolean, signed 64-bit integer, -string, typed-ID, enum, and Unix-seconds instant constants. Custom parser limits -may tighten but not widen the v1 limits. Regex nodes use the .NET dialect, -require `CultureInvariant`, reject unsupported option bits and inline -culture-dependent case behavior, count pattern limits by Unicode scalar, and -execute with a timeout. +string, and typed-ID constants. The retained bakeoff schema also listed enum +and Unix-seconds instant constants, but no catalog field could consume either; +production removed those phantom definitions without changing the set of valid +root documents. Custom parser limits may tighten but not widen the v1 limits. +Regex nodes use the .NET dialect, require `CultureInvariant`, reject unsupported +option bits and inline culture-dependent case behavior, count pattern limits by +Unicode scalar, and execute with a timeout. Typed documents are not lowered into native tmux filters. Callers capture or list entities, then use `Matching()`; relation predicates declare the snapshot @@ -389,7 +391,7 @@ proof is added. "final public names and exhaustive Python inventory dispositions" ], "capabilities": [ - "closed sealed-record query AST and seven tagged constant kinds", + "closed sealed-record query AST and five tagged constant kinds", "pure expression translation with frozen constants and no silent fallback", "direct local interpretation and immutable Matching results", "version-one canonical JSON schema with bounded parsing and invariant regex semantics", diff --git a/docs/public-api.json b/docs/public-api.json index 8fc26c3..8cc9f99 100644 --- a/docs/public-api.json +++ b/docs/public-api.json @@ -1333,22 +1333,6 @@ "state": [], "summary": "A canonical constant query node." }, - { - "id": "T:LibTmux.Query.EnumConstant", - "namespace": "LibTmux.Query", - "name": "EnumConstant", - "kind": "record", - "package": "LibTmux", - "modifiers": [ - "public", - "sealed" - ], - "baseType": "QueryConstant", - "interfaces": [], - "ownership": "value", - "state": [], - "summary": "A canonical enum constant." - }, { "id": "T:LibTmux.Query.FieldNode", "namespace": "LibTmux.Query", @@ -1365,22 +1349,6 @@ "state": [], "summary": "A canonical field query node." }, - { - "id": "T:LibTmux.Query.InstantConstant", - "namespace": "LibTmux.Query", - "name": "InstantConstant", - "kind": "record", - "package": "LibTmux", - "modifiers": [ - "public", - "sealed" - ], - "baseType": "QueryConstant", - "interfaces": [], - "ownership": "value", - "state": [], - "summary": "A canonical instant constant." - }, { "id": "T:LibTmux.Query.Int64Constant", "namespace": "LibTmux.Query", @@ -8427,29 +8395,6 @@ "portable": true, "summary": "Creates ConstantNode." }, - { - "id": "M:LibTmux.Query.EnumConstant.#ctor(string,string)", - "declaringType": "T:LibTmux.Query.EnumConstant", - "name": ".ctor", - "kind": "constructor", - "visibility": "public", - "package": "LibTmux", - "static": false, - "returnType": "EnumConstant", - "parameters": [ - { - "name": "type", - "type": "string" - }, - { - "name": "value", - "type": "string" - } - ], - "signature": "EnumConstant(string type, string value)", - "portable": true, - "summary": "Creates EnumConstant." - }, { "id": "M:LibTmux.Query.FieldNode.#ctor(QueryTarget,string)", "declaringType": "T:LibTmux.Query.FieldNode", @@ -8473,25 +8418,6 @@ "portable": true, "summary": "Creates FieldNode." }, - { - "id": "M:LibTmux.Query.InstantConstant.#ctor(long)", - "declaringType": "T:LibTmux.Query.InstantConstant", - "name": ".ctor", - "kind": "constructor", - "visibility": "public", - "package": "LibTmux", - "static": false, - "returnType": "InstantConstant", - "parameters": [ - { - "name": "unixSeconds", - "type": "long" - } - ], - "signature": "InstantConstant(long unixSeconds)", - "portable": true, - "summary": "Creates InstantConstant." - }, { "id": "M:LibTmux.Query.Int64Constant.#ctor(long)", "declaringType": "T:LibTmux.Query.Int64Constant", @@ -19693,34 +19619,6 @@ "portable": true, "summary": "Gets Value." }, - { - "id": "P:LibTmux.Query.EnumConstant.Type", - "declaringType": "T:LibTmux.Query.EnumConstant", - "name": "Type", - "kind": "property", - "visibility": "public", - "package": "LibTmux", - "static": false, - "returnType": "string", - "parameters": [], - "signature": "string LibTmux.Query.EnumConstant.Type { get; }", - "portable": true, - "summary": "Gets Type." - }, - { - "id": "P:LibTmux.Query.EnumConstant.Value", - "declaringType": "T:LibTmux.Query.EnumConstant", - "name": "Value", - "kind": "property", - "visibility": "public", - "package": "LibTmux", - "static": false, - "returnType": "string", - "parameters": [], - "signature": "string LibTmux.Query.EnumConstant.Value { get; }", - "portable": true, - "summary": "Gets Value." - }, { "id": "P:LibTmux.Query.FieldNode.Target", "declaringType": "T:LibTmux.Query.FieldNode", @@ -19749,20 +19647,6 @@ "portable": true, "summary": "Gets WireName." }, - { - "id": "P:LibTmux.Query.InstantConstant.UnixSeconds", - "declaringType": "T:LibTmux.Query.InstantConstant", - "name": "UnixSeconds", - "kind": "property", - "visibility": "public", - "package": "LibTmux", - "static": false, - "returnType": "long", - "parameters": [], - "signature": "long LibTmux.Query.InstantConstant.UnixSeconds { get; }", - "portable": true, - "summary": "Gets UnixSeconds." - }, { "id": "P:LibTmux.Query.Int64Constant.Value", "declaringType": "T:LibTmux.Query.Int64Constant", @@ -24063,16 +23947,6 @@ "signature": "record LibTmux.Query.ConstantNode", "portable": true }, - { - "id": "T:LibTmux.Query.EnumConstant", - "declaringType": "T:LibTmux.Query.EnumConstant", - "name": "EnumConstant", - "kind": "type", - "visibility": "public", - "package": "LibTmux", - "signature": "record LibTmux.Query.EnumConstant", - "portable": true - }, { "id": "T:LibTmux.Query.FieldNode", "declaringType": "T:LibTmux.Query.FieldNode", @@ -24083,16 +23957,6 @@ "signature": "record LibTmux.Query.FieldNode", "portable": true }, - { - "id": "T:LibTmux.Query.InstantConstant", - "declaringType": "T:LibTmux.Query.InstantConstant", - "name": "InstantConstant", - "kind": "type", - "visibility": "public", - "package": "LibTmux", - "signature": "record LibTmux.Query.InstantConstant", - "portable": true - }, { "id": "T:LibTmux.Query.Int64Constant", "declaringType": "T:LibTmux.Query.Int64Constant", diff --git a/docs/public-api.md b/docs/public-api.md index a9d9932..7e9e6f4 100644 --- a/docs/public-api.md +++ b/docs/public-api.md @@ -341,9 +341,7 @@ internal static class Program | `T:LibTmux.Query.BooleanConstant` | record | `public, sealed` | None | `QueryConstant` | value | A canonical boolean constant. | `LibTmux` | | `T:LibTmux.Query.ComparisonNode` | record | `public, sealed` | None | `QueryNode` | value | A canonical comparison query node. | `LibTmux` | | `T:LibTmux.Query.ConstantNode` | record | `public, sealed` | None | `QueryNode` | value | A canonical constant query node. | `LibTmux` | -| `T:LibTmux.Query.EnumConstant` | record | `public, sealed` | None | `QueryConstant` | value | A canonical enum constant. | `LibTmux` | | `T:LibTmux.Query.FieldNode` | record | `public, sealed` | None | `QueryNode` | value | A canonical field query node. | `LibTmux` | -| `T:LibTmux.Query.InstantConstant` | record | `public, sealed` | None | `QueryConstant` | value | A canonical instant constant. | `LibTmux` | | `T:LibTmux.Query.Int64Constant` | record | `public, sealed` | None | `QueryConstant` | value | A canonical int64 constant. | `LibTmux` | | `T:LibTmux.Query.Json.QueryJson` | static class | `public, static` | None | `object` | value | Serializes and parses v1 query documents. | `LibTmux.Query.Json` | | `T:LibTmux.Query.Json.QueryJsonLimits` | record | `public, sealed` | None | `object` | value | Tightens the fixed v1 JSON resource ceilings. | `LibTmux.Query.Json` | @@ -1137,14 +1135,6 @@ internal static class Program | `M:LibTmux.Query.ConstantNode.#ctor(QueryConstant)` | `ConstantNode(QueryConstant value)` | Public | No | Portable | Creates ConstantNode. | | `P:LibTmux.Query.ConstantNode.Value` | `QueryConstant LibTmux.Query.ConstantNode.Value { get; }` | Public | No | Portable | Gets Value. | -### `T:LibTmux.Query.EnumConstant` - -| Member ID | Declaration | Visibility | Static | Platform | Notes | -| --- | --- | --- | --- | --- | --- | -| `M:LibTmux.Query.EnumConstant.#ctor(string,string)` | `EnumConstant(string type, string value)` | Public | No | Portable | Creates EnumConstant. | -| `P:LibTmux.Query.EnumConstant.Type` | `string LibTmux.Query.EnumConstant.Type { get; }` | Public | No | Portable | Gets Type. | -| `P:LibTmux.Query.EnumConstant.Value` | `string LibTmux.Query.EnumConstant.Value { get; }` | Public | No | Portable | Gets Value. | - ### `T:LibTmux.Query.FieldNode` | Member ID | Declaration | Visibility | Static | Platform | Notes | @@ -1153,13 +1143,6 @@ internal static class Program | `P:LibTmux.Query.FieldNode.Target` | `QueryTarget LibTmux.Query.FieldNode.Target { get; }` | Public | No | Portable | Gets Target. | | `P:LibTmux.Query.FieldNode.WireName` | `string LibTmux.Query.FieldNode.WireName { get; }` | Public | No | Portable | Gets WireName. | -### `T:LibTmux.Query.InstantConstant` - -| Member ID | Declaration | Visibility | Static | Platform | Notes | -| --- | --- | --- | --- | --- | --- | -| `M:LibTmux.Query.InstantConstant.#ctor(long)` | `InstantConstant(long unixSeconds)` | Public | No | Portable | Creates InstantConstant. | -| `P:LibTmux.Query.InstantConstant.UnixSeconds` | `long LibTmux.Query.InstantConstant.UnixSeconds { get; }` | Public | No | Portable | Gets UnixSeconds. | - ### `T:LibTmux.Query.Int64Constant` | Member ID | Declaration | Visibility | Static | Platform | Notes | diff --git a/src/LibTmux.Query.Json/QueryDocumentJsonConverter.cs b/src/LibTmux.Query.Json/QueryDocumentJsonConverter.cs index 1ca2601..da12529 100644 --- a/src/LibTmux.Query.Json/QueryDocumentJsonConverter.cs +++ b/src/LibTmux.Query.Json/QueryDocumentJsonConverter.cs @@ -232,15 +232,6 @@ private void WriteConstant(Utf8JsonWriter writer, QueryConstant constant) writer.WriteString("kind", "string"); WriteBoundedString(writer, "value", text.Value, "String value"); break; - case InstantConstant instant: - writer.WriteString("kind", "instant"); - writer.WriteNumber("unixSeconds", instant.UnixSeconds); - break; - case EnumConstant member: - writer.WriteString("kind", "enum"); - WriteBoundedString(writer, "type", member.Type, "Enum type"); - WriteBoundedString(writer, "token", member.Value, "Enum value"); - break; case TypedIdConstant id: writer.WriteString("kind", "typedId"); writer.WriteString("type", Wire(id.Target)); diff --git a/src/LibTmux.Query.Json/QueryDocumentJsonReader.cs b/src/LibTmux.Query.Json/QueryDocumentJsonReader.cs index 5efdcec..d97983e 100644 --- a/src/LibTmux.Query.Json/QueryDocumentJsonReader.cs +++ b/src/LibTmux.Query.Json/QueryDocumentJsonReader.cs @@ -142,10 +142,6 @@ private QueryConstant ReadConstant(JsonElement element) "boolean" => new BooleanConstant(element.GetProperty("value").GetBoolean()), "int64" => new Int64Constant(element.GetProperty("value").GetInt64()), "string" => new StringConstant(ReadBoundedString(element.GetProperty("value"))), - "instant" => new InstantConstant(element.GetProperty("unixSeconds").GetInt64()), - "enum" => new EnumConstant( - ReadBoundedString(element.GetProperty("type"), "Enum type"), - ReadBoundedString(element.GetProperty("token"), "Enum value")), "typedId" => new TypedIdConstant( ReadTarget(element.GetProperty("type")), ReadBoundedString(element.GetProperty("value"), "Typed ID value")), diff --git a/src/LibTmux.Query.Json/QueryJsonWireRules.cs b/src/LibTmux.Query.Json/QueryJsonWireRules.cs index 740b610..f4941d6 100644 --- a/src/LibTmux.Query.Json/QueryJsonWireRules.cs +++ b/src/LibTmux.Query.Json/QueryJsonWireRules.cs @@ -19,8 +19,6 @@ internal static class QueryJsonWireRules private static readonly string[] KindProperties = ["kind"]; private static readonly string[] ValueProperties = ["kind", "value"]; private static readonly string[] TypedIdProperties = ["kind", "type", "value"]; - private static readonly string[] EnumProperties = ["kind", "type", "token"]; - private static readonly string[] InstantProperties = ["kind", "unixSeconds"]; internal static void ValidateEnvelope(JsonElement element) => ValidateProperties(element, EnvelopeProperties, "query envelope"); @@ -51,8 +49,6 @@ internal static void ValidateConstant(JsonElement element, string? kind) "null" => KindProperties, "boolean" or "int64" or "string" => ValueProperties, "typedId" => TypedIdProperties, - "enum" => EnumProperties, - "instant" => InstantProperties, _ => null, }; if (allowed is not null) diff --git a/src/LibTmux.Query.Json/libtmux-query-v1.schema.json b/src/LibTmux.Query.Json/libtmux-query-v1.schema.json index f050dfb..a769162 100644 --- a/src/LibTmux.Query.Json/libtmux-query-v1.schema.json +++ b/src/LibTmux.Query.Json/libtmux-query-v1.schema.json @@ -281,46 +281,13 @@ } } }, - "enumConstant": { - "type": "object", - "additionalProperties": false, - "required": ["kind", "type", "token"], - "properties": { - "kind": { "const": "enum" }, - "type": { - "type": "string", - "maxLength": 4096, - "pattern": "^[^\\uD800-\\uDFFF]*$" - }, - "token": { - "type": "string", - "maxLength": 4096, - "pattern": "^[^\\uD800-\\uDFFF]*$" - } - } - }, - "instantConstant": { - "type": "object", - "additionalProperties": false, - "required": ["kind", "unixSeconds"], - "properties": { - "kind": { "const": "instant" }, - "unixSeconds": { - "type": "integer", - "minimum": -9223372036854775808, - "maximum": 9223372036854775807 - } - } - }, "constant": { "oneOf": [ { "$ref": "#/$defs/nullConstant" }, { "$ref": "#/$defs/booleanConstant" }, { "$ref": "#/$defs/int64Constant" }, { "$ref": "#/$defs/stringConstant" }, - { "$ref": "#/$defs/typedIdConstant" }, - { "$ref": "#/$defs/enumConstant" }, - { "$ref": "#/$defs/instantConstant" } + { "$ref": "#/$defs/typedIdConstant" } ] }, "constantNode": { diff --git a/src/LibTmux/PublicAPI.Unshipped.txt b/src/LibTmux/PublicAPI.Unshipped.txt index bc350fc..4eb04dc 100644 --- a/src/LibTmux/PublicAPI.Unshipped.txt +++ b/src/LibTmux/PublicAPI.Unshipped.txt @@ -524,14 +524,6 @@ LibTmux.Query.ConstantNode.Deconstruct(out LibTmux.Query.QueryConstant! Value) - LibTmux.Query.ConstantNode.Equals(LibTmux.Query.ConstantNode? other) -> bool LibTmux.Query.ConstantNode.Value.get -> LibTmux.Query.QueryConstant! LibTmux.Query.ConstantNode.Value.init -> void -LibTmux.Query.EnumConstant -LibTmux.Query.EnumConstant.Deconstruct(out string! Type, out string! Value) -> void -LibTmux.Query.EnumConstant.EnumConstant(string! Type, string! Value) -> void -LibTmux.Query.EnumConstant.Equals(LibTmux.Query.EnumConstant? other) -> bool -LibTmux.Query.EnumConstant.Type.get -> string! -LibTmux.Query.EnumConstant.Type.init -> void -LibTmux.Query.EnumConstant.Value.get -> string! -LibTmux.Query.EnumConstant.Value.init -> void LibTmux.Query.FieldNode LibTmux.Query.FieldNode.Deconstruct(out LibTmux.Query.QueryTarget Target, out string! WireName) -> void LibTmux.Query.FieldNode.Equals(LibTmux.Query.FieldNode? other) -> bool @@ -540,12 +532,6 @@ LibTmux.Query.FieldNode.Target.get -> LibTmux.Query.QueryTarget LibTmux.Query.FieldNode.Target.init -> void LibTmux.Query.FieldNode.WireName.get -> string! LibTmux.Query.FieldNode.WireName.init -> void -LibTmux.Query.InstantConstant -LibTmux.Query.InstantConstant.Deconstruct(out long UnixSeconds) -> void -LibTmux.Query.InstantConstant.Equals(LibTmux.Query.InstantConstant? other) -> bool -LibTmux.Query.InstantConstant.InstantConstant(long UnixSeconds) -> void -LibTmux.Query.InstantConstant.UnixSeconds.get -> long -LibTmux.Query.InstantConstant.UnixSeconds.init -> void LibTmux.Query.Int64Constant LibTmux.Query.Int64Constant.Deconstruct(out long Value) -> void LibTmux.Query.Int64Constant.Equals(LibTmux.Query.Int64Constant? other) -> bool @@ -1429,18 +1415,10 @@ override LibTmux.Query.ConstantNode.$() -> LibTmux.Query.ConstantNode! override LibTmux.Query.ConstantNode.Equals(object? obj) -> bool override LibTmux.Query.ConstantNode.GetHashCode() -> int override LibTmux.Query.ConstantNode.ToString() -> string! -override LibTmux.Query.EnumConstant.$() -> LibTmux.Query.EnumConstant! -override LibTmux.Query.EnumConstant.Equals(object? obj) -> bool -override LibTmux.Query.EnumConstant.GetHashCode() -> int -override LibTmux.Query.EnumConstant.ToString() -> string! override LibTmux.Query.FieldNode.$() -> LibTmux.Query.FieldNode! override LibTmux.Query.FieldNode.Equals(object? obj) -> bool override LibTmux.Query.FieldNode.GetHashCode() -> int override LibTmux.Query.FieldNode.ToString() -> string! -override LibTmux.Query.InstantConstant.$() -> LibTmux.Query.InstantConstant! -override LibTmux.Query.InstantConstant.Equals(object? obj) -> bool -override LibTmux.Query.InstantConstant.GetHashCode() -> int -override LibTmux.Query.InstantConstant.ToString() -> string! override LibTmux.Query.Int64Constant.$() -> LibTmux.Query.Int64Constant! override LibTmux.Query.Int64Constant.Equals(object? obj) -> bool override LibTmux.Query.Int64Constant.GetHashCode() -> int @@ -1626,9 +1604,7 @@ override sealed LibTmux.Query.AndNode.Equals(LibTmux.Query.QueryNode? other) -> override sealed LibTmux.Query.BooleanConstant.Equals(LibTmux.Query.QueryConstant? other) -> bool override sealed LibTmux.Query.ComparisonNode.Equals(LibTmux.Query.QueryNode? other) -> bool override sealed LibTmux.Query.ConstantNode.Equals(LibTmux.Query.QueryNode? other) -> bool -override sealed LibTmux.Query.EnumConstant.Equals(LibTmux.Query.QueryConstant? other) -> bool override sealed LibTmux.Query.FieldNode.Equals(LibTmux.Query.QueryNode? other) -> bool -override sealed LibTmux.Query.InstantConstant.Equals(LibTmux.Query.QueryConstant? other) -> bool override sealed LibTmux.Query.Int64Constant.Equals(LibTmux.Query.QueryConstant? other) -> bool override sealed LibTmux.Query.NotNode.Equals(LibTmux.Query.QueryNode? other) -> bool override sealed LibTmux.Query.NullConstant.Equals(LibTmux.Query.QueryConstant? other) -> bool @@ -1717,12 +1693,8 @@ static LibTmux.Query.ComparisonNode.operator !=(LibTmux.Query.ComparisonNode? le static LibTmux.Query.ComparisonNode.operator ==(LibTmux.Query.ComparisonNode? left, LibTmux.Query.ComparisonNode? right) -> bool static LibTmux.Query.ConstantNode.operator !=(LibTmux.Query.ConstantNode? left, LibTmux.Query.ConstantNode? right) -> bool static LibTmux.Query.ConstantNode.operator ==(LibTmux.Query.ConstantNode? left, LibTmux.Query.ConstantNode? right) -> bool -static LibTmux.Query.EnumConstant.operator !=(LibTmux.Query.EnumConstant? left, LibTmux.Query.EnumConstant? right) -> bool -static LibTmux.Query.EnumConstant.operator ==(LibTmux.Query.EnumConstant? left, LibTmux.Query.EnumConstant? right) -> bool static LibTmux.Query.FieldNode.operator !=(LibTmux.Query.FieldNode? left, LibTmux.Query.FieldNode? right) -> bool static LibTmux.Query.FieldNode.operator ==(LibTmux.Query.FieldNode? left, LibTmux.Query.FieldNode? right) -> bool -static LibTmux.Query.InstantConstant.operator !=(LibTmux.Query.InstantConstant? left, LibTmux.Query.InstantConstant? right) -> bool -static LibTmux.Query.InstantConstant.operator ==(LibTmux.Query.InstantConstant? left, LibTmux.Query.InstantConstant? right) -> bool static LibTmux.Query.Int64Constant.operator !=(LibTmux.Query.Int64Constant? left, LibTmux.Query.Int64Constant? right) -> bool static LibTmux.Query.Int64Constant.operator ==(LibTmux.Query.Int64Constant? left, LibTmux.Query.Int64Constant? right) -> bool static LibTmux.Query.NotNode.operator !=(LibTmux.Query.NotNode? left, LibTmux.Query.NotNode? right) -> bool diff --git a/src/LibTmux/Query/QueryDocumentValidator.cs b/src/LibTmux/Query/QueryDocumentValidator.cs index d1c8611..b5e430a 100644 --- a/src/LibTmux/Query/QueryDocumentValidator.cs +++ b/src/LibTmux/Query/QueryDocumentValidator.cs @@ -92,14 +92,12 @@ private static void ValidateComparison( case QueryComparison.LessThanOrEqual: case QueryComparison.GreaterThan: case QueryComparison.GreaterThanOrEqual: - if ((kind == QueryValueKind.Int64 && constant.Value is Int64Constant) - || (kind == QueryValueKind.Instant - && constant.Value is InstantConstant)) + if (kind == QueryValueKind.Int64 && constant.Value is Int64Constant) { return; } - throw Unsupported("Ordered comparison requires an integer or instant field."); + throw Unsupported("Ordered comparison requires an integer field."); default: throw Unsupported("Query document names an unknown comparison."); } @@ -197,11 +195,6 @@ private static void ValidateConstant( kind == QueryValueKind.TypedId && id.Target == target && QueryTextSemantics.TryCountScalars(id.Value, out _), - EnumConstant member => - kind == QueryValueKind.Enum - && QueryTextSemantics.TryCountScalars(member.Type, out _) - && QueryTextSemantics.TryCountScalars(member.Value, out _), - InstantConstant => kind == QueryValueKind.Instant, _ => false, }; if (!compatible) diff --git a/src/LibTmux/Query/QueryInterpreter.cs b/src/LibTmux/Query/QueryInterpreter.cs index 15c183a..12e3da8 100644 --- a/src/LibTmux/Query/QueryInterpreter.cs +++ b/src/LibTmux/Query/QueryInterpreter.cs @@ -378,8 +378,6 @@ operand is object value BooleanConstant boolean => boolean.Value, Int64Constant number => number.Value, StringConstant text => text.Value, - InstantConstant instant => instant.UnixSeconds, - EnumConstant member => member.Value, TypedIdConstant id => id.Value, _ => throw new UnsupportedQueryExpressionException( $"Constant '{constant.GetType().Name}' has no value."), diff --git a/src/LibTmux/Query/QueryNode.cs b/src/LibTmux/Query/QueryNode.cs index f35daa3..4784dfd 100644 --- a/src/LibTmux/Query/QueryNode.cs +++ b/src/LibTmux/Query/QueryNode.cs @@ -94,15 +94,6 @@ public sealed record Int64Constant(long Value) : QueryConstant; /// The literal value. public sealed record StringConstant(string Value) : QueryConstant; -/// An instant literal, in whole seconds since the Unix epoch. -/// Seconds since the Unix epoch. -public sealed record InstantConstant(long UnixSeconds) : QueryConstant; - -/// An enumeration literal, named by type and member. -/// The enumeration type name. -/// The member name. -public sealed record EnumConstant(string Type, string Value) : QueryConstant; - /// A typed tmux identifier literal. /// The object the identifier names. /// The identifier text. diff --git a/src/LibTmux/Query/QueryPlanBindings.cs b/src/LibTmux/Query/QueryPlanBindings.cs index 2033b59..889aadb 100644 --- a/src/LibTmux/Query/QueryPlanBindings.cs +++ b/src/LibTmux/Query/QueryPlanBindings.cs @@ -151,8 +151,6 @@ private static void RequireScalarType(FieldNode field, Type propertyType) QueryValueKind.Boolean => valueType == typeof(bool), QueryValueKind.Int64 => IsInteger(valueType), QueryValueKind.String => valueType == typeof(string), - QueryValueKind.Instant => IsInteger(valueType), - QueryValueKind.Enum => valueType == typeof(string) || valueType.IsEnum, QueryValueKind.TypedId => IsTypedId(field.Target, valueType), _ => false, }; diff --git a/src/LibTmux/Query/QueryTranslator.cs b/src/LibTmux/Query/QueryTranslator.cs index c8dc98d..753343a 100644 --- a/src/LibTmux/Query/QueryTranslator.cs +++ b/src/LibTmux/Query/QueryTranslator.cs @@ -253,8 +253,6 @@ member.DeclaringType is { } owner null => new NullConstant(), bool boolean => new BooleanConstant(boolean), string text => new StringConstant(text), - DateTimeOffset instant => new InstantConstant(instant.ToUnixTimeSeconds()), - Enum member => new EnumConstant(declared.Name, member.ToString()), SessionId id => new TypedIdConstant(QueryTarget.Session, id.ToString()), WindowId id => new TypedIdConstant(QueryTarget.Window, id.ToString()), PaneId id => new TypedIdConstant(QueryTarget.Pane, id.ToString()), diff --git a/src/LibTmux/Query/QueryValueKind.cs b/src/LibTmux/Query/QueryValueKind.cs index 6b0194b..efb4d7d 100644 --- a/src/LibTmux/Query/QueryValueKind.cs +++ b/src/LibTmux/Query/QueryValueKind.cs @@ -6,6 +6,4 @@ internal enum QueryValueKind Int64, String, TypedId, - Enum, - Instant, } diff --git a/tests/LibTmux.UnitTests/Query/QueryJsonTrustBoundaryTests.cs b/tests/LibTmux.UnitTests/Query/QueryJsonTrustBoundaryTests.cs index d49a5a5..828e83c 100644 --- a/tests/LibTmux.UnitTests/Query/QueryJsonTrustBoundaryTests.cs +++ b/tests/LibTmux.UnitTests/Query/QueryJsonTrustBoundaryTests.cs @@ -130,8 +130,6 @@ public void An_unknown_quantifier_is_refused_rather_than_treated_as_all() [Theory] [InlineData("\"kind\":\"string\",\"value\":null")] - [InlineData("\"kind\":\"enum\",\"type\":null,\"token\":\"Ready\"")] - [InlineData("\"kind\":\"enum\",\"type\":\"State\",\"token\":null")] [InlineData("\"kind\":\"typedId\",\"type\":\"session\",\"value\":null")] public void Null_constant_text_is_refused(string members) { diff --git a/tests/LibTmux.UnitTests/Query/QuerySemanticsTests.cs b/tests/LibTmux.UnitTests/Query/QuerySemanticsTests.cs index 964c9c6..3da692c 100644 --- a/tests/LibTmux.UnitTests/Query/QuerySemanticsTests.cs +++ b/tests/LibTmux.UnitTests/Query/QuerySemanticsTests.cs @@ -49,6 +49,21 @@ private sealed record PaneIdRow(string PaneId); private sealed record WindowCountRow(string WindowName, long WindowPanes); + [Fact] + public void Every_value_kind_has_a_catalog_field() + { + var catalogKinds = new HashSet(); + foreach (string wireName in QueryFieldCatalog.WireNames) + { + Assert.True(QueryFieldCatalog.TryGetKind(wireName, out QueryValueKind kind)); + catalogKinds.Add(kind); + } + + Assert.Equal( + Enum.GetValues().Order(), + catalogKinds.Order()); + } + [Fact] public void An_entity_translates_through_the_name_tmux_uses_for_the_field() { From b54949d2e988f9908358868928346b93e4b23fee Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 11:22:53 -0500 Subject: [PATCH 059/129] API(fix[surface]): Close contract exemptions why: the assembly exported four types that the approved public contract deliberately omitted, including two collection wrappers already replaced by BCL APIs. what: - require exported types to match the approved contract exactly - keep snapshot construction and relation factories internal - remove redundant keyed lookup wrappers and prove parity through ToDictionary --- .../SnapshotCollectionExtensions.cs | 40 --------- src/LibTmux/Collections/SnapshotLookup.cs | 34 -------- src/LibTmux/PublicAPI.Unshipped.txt | 18 ---- src/LibTmux/Server.Snapshots.cs | 2 - src/LibTmux/Snapshots/CapturedRelation.cs | 18 +--- src/LibTmux/Snapshots/ServerSnapshot.cs | 82 ++++--------------- .../Parity/Component05ParityTests.cs | 3 +- .../Parity/Component07ParityTests.cs | 4 +- .../Snapshots/HierarchySnapshotTests.cs | 12 +-- .../Collections/SnapshotCollectionTests.cs | 39 --------- .../Packaging/PublicApiContractTests.cs | 31 +------ 11 files changed, 30 insertions(+), 253 deletions(-) delete mode 100644 src/LibTmux/Collections/SnapshotCollectionExtensions.cs delete mode 100644 src/LibTmux/Collections/SnapshotLookup.cs delete mode 100644 tests/LibTmux.UnitTests/Collections/SnapshotCollectionTests.cs diff --git a/src/LibTmux/Collections/SnapshotCollectionExtensions.cs b/src/LibTmux/Collections/SnapshotCollectionExtensions.cs deleted file mode 100644 index 46adf96..0000000 --- a/src/LibTmux/Collections/SnapshotCollectionExtensions.cs +++ /dev/null @@ -1,40 +0,0 @@ -namespace LibTmux; - -/// Indexes captured collections without leaving memory. -/// -/// Ordinary filtering is plain LINQ over the snapshot, so this adds only what -/// LINQ cannot express as cheaply: a keyed index and a duplicate-rejecting -/// key contract. -/// -public static class SnapshotCollectionExtensions -{ - /// Indexes a captured collection by a required key. - /// The key type. - /// The captured element type. - /// The captured elements. - /// Reads one element's key. - /// The keyed index. - /// Two elements share a key. - public static SnapshotLookup ToLookupByKey( - this IEnumerable source, - Func keySelector) - where TKey : notnull - { - ArgumentNullException.ThrowIfNull(source); - ArgumentNullException.ThrowIfNull(keySelector); - var entries = new Dictionary(); - foreach (TValue value in source) - { - // A shared key means the selector does not identify an element, so - // silently keeping one of them would hide the modelling mistake. - if (!entries.TryAdd(keySelector(value), value)) - { - throw new ArgumentException( - "Two captured elements share one key.", - nameof(keySelector)); - } - } - - return new SnapshotLookup(entries); - } -} diff --git a/src/LibTmux/Collections/SnapshotLookup.cs b/src/LibTmux/Collections/SnapshotLookup.cs deleted file mode 100644 index 65940d0..0000000 --- a/src/LibTmux/Collections/SnapshotLookup.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System.Diagnostics.CodeAnalysis; - -namespace LibTmux; - -/// Indexes a captured collection by a stable key. -/// The key type. -/// The captured element type. -/// -/// Building the index is explicit because a snapshot is already in memory: -/// a caller who looks up one element once should pay a scan, not an index. -/// -public sealed class SnapshotLookup - where TKey : notnull -{ - private readonly Dictionary _entries; - - internal SnapshotLookup(Dictionary entries) => _entries = entries; - - /// Gets the number of indexed elements. - public int Count => _entries.Count; - - /// Gets the element with the given key. - /// The key to find. - /// The matching element. - /// No element carries the key. - public TValue this[TKey key] => _entries[key]; - - /// Tries to get the element with the given key. - /// The key to find. - /// The matching element, when present. - /// True when an element carries the key. - public bool TryGetValue(TKey key, [MaybeNullWhen(false)] out TValue value) => - _entries.TryGetValue(key, out value); -} diff --git a/src/LibTmux/PublicAPI.Unshipped.txt b/src/LibTmux/PublicAPI.Unshipped.txt index 4eb04dc..20a6bd4 100644 --- a/src/LibTmux/PublicAPI.Unshipped.txt +++ b/src/LibTmux/PublicAPI.Unshipped.txt @@ -40,7 +40,6 @@ LibTmux.CapturePaneRequest.PreserveTrailingSpaces.get -> bool LibTmux.CapturePaneRequest.Quiet.get -> bool LibTmux.CapturePaneRequest.StartLine.get -> LibTmux.CapturePanePosition? LibTmux.CapturePaneRequest.TrimTrailingSpaces.get -> bool -LibTmux.CapturedRelation LibTmux.CapturedRelation LibTmux.CapturedRelation.CapturedDepth.get -> LibTmux.SnapshotDepth LibTmux.CapturedRelation.Count.get -> int @@ -814,14 +813,6 @@ LibTmux.ServerGeneration.ProcessId.get -> int LibTmux.ServerGeneration.ServerGeneration() -> void LibTmux.ServerGeneration.ServerGeneration(int processId, long startTime) -> void LibTmux.ServerGeneration.StartTime.get -> long -LibTmux.ServerSnapshot -LibTmux.ServerSnapshot.Depth.get -> LibTmux.SnapshotDepth -LibTmux.ServerSnapshot.Generation.get -> LibTmux.ServerGeneration -LibTmux.ServerSnapshot.Panes.get -> LibTmux.CapturedRelation! -LibTmux.ServerSnapshot.Server.get -> LibTmux.Server! -LibTmux.ServerSnapshot.Sessions.get -> LibTmux.CapturedRelation! -LibTmux.ServerSnapshot.WindowEdges.get -> System.Collections.Generic.IReadOnlyList! -LibTmux.ServerSnapshot.Windows.get -> LibTmux.CapturedRelation! LibTmux.Session LibTmux.Session.ActivePane.get -> LibTmux.Pane! LibTmux.Session.ActiveWindow.get -> LibTmux.Window! @@ -910,16 +901,11 @@ LibTmux.ShowMessagesMode LibTmux.ShowMessagesMode.Jobs = 1 -> LibTmux.ShowMessagesMode LibTmux.ShowMessagesMode.Messages = 0 -> LibTmux.ShowMessagesMode LibTmux.ShowMessagesMode.Terminals = 2 -> LibTmux.ShowMessagesMode -LibTmux.SnapshotCollectionExtensions LibTmux.SnapshotDepth LibTmux.SnapshotDepth.Panes = 3 -> LibTmux.SnapshotDepth LibTmux.SnapshotDepth.Server = 0 -> LibTmux.SnapshotDepth LibTmux.SnapshotDepth.Sessions = 1 -> LibTmux.SnapshotDepth LibTmux.SnapshotDepth.Windows = 2 -> LibTmux.SnapshotDepth -LibTmux.SnapshotLookup -LibTmux.SnapshotLookup.Count.get -> int -LibTmux.SnapshotLookup.TryGetValue(TKey key, out TValue value) -> bool -LibTmux.SnapshotLookup.this[TKey key].get -> TValue LibTmux.SplitPaneRequest LibTmux.SplitPaneRequest.$() -> LibTmux.SplitPaneRequest! LibTmux.SplitPaneRequest.ActiveBorderStyle.get -> string? @@ -1628,8 +1614,6 @@ static LibTmux.CapturePanePosition.operator !=(LibTmux.CapturePanePosition left, static LibTmux.CapturePanePosition.operator ==(LibTmux.CapturePanePosition left, LibTmux.CapturePanePosition right) -> bool static LibTmux.CapturePaneRequest.operator !=(LibTmux.CapturePaneRequest? left, LibTmux.CapturePaneRequest? right) -> bool static LibTmux.CapturePaneRequest.operator ==(LibTmux.CapturePaneRequest? left, LibTmux.CapturePaneRequest? right) -> bool -static LibTmux.CapturedRelation.Capture(System.Collections.Generic.IEnumerable! items, string! relation, LibTmux.SnapshotDepth capturedDepth) -> LibTmux.CapturedRelation! -static LibTmux.CapturedRelation.Uncaptured(string! relation, LibTmux.SnapshotDepth capturedDepth) -> LibTmux.CapturedRelation! static LibTmux.ChooseTreeRequest.operator !=(LibTmux.ChooseTreeRequest? left, LibTmux.ChooseTreeRequest? right) -> bool static LibTmux.ChooseTreeRequest.operator ==(LibTmux.ChooseTreeRequest? left, LibTmux.ChooseTreeRequest? right) -> bool static LibTmux.Client.GetAsync(LibTmux.Server! server, string! name, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! @@ -1750,7 +1734,6 @@ static LibTmux.ServerConnectionOptions.operator !=(LibTmux.ServerConnectionOptio static LibTmux.ServerConnectionOptions.operator ==(LibTmux.ServerConnectionOptions? left, LibTmux.ServerConnectionOptions? right) -> bool static LibTmux.ServerGeneration.operator !=(LibTmux.ServerGeneration left, LibTmux.ServerGeneration right) -> bool static LibTmux.ServerGeneration.operator ==(LibTmux.ServerGeneration left, LibTmux.ServerGeneration right) -> bool -static LibTmux.ServerSnapshot.CaptureAsync(LibTmux.Server! server, LibTmux.SnapshotDepth depth = LibTmux.SnapshotDepth.Panes, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! static LibTmux.Session.FromEnvironmentAsync(System.Collections.Generic.IReadOnlyDictionary? environment = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! static LibTmux.SessionId.Parse(string! text) -> LibTmux.SessionId static LibTmux.SessionId.TryParse(string? text, out LibTmux.SessionId result) -> bool @@ -1764,7 +1747,6 @@ static LibTmux.SetHooksRequest.operator !=(LibTmux.SetHooksRequest? left, LibTmu static LibTmux.SetHooksRequest.operator ==(LibTmux.SetHooksRequest? left, LibTmux.SetHooksRequest? right) -> bool static LibTmux.SetOptionRequest.operator !=(LibTmux.SetOptionRequest? left, LibTmux.SetOptionRequest? right) -> bool static LibTmux.SetOptionRequest.operator ==(LibTmux.SetOptionRequest? left, LibTmux.SetOptionRequest? right) -> bool -static LibTmux.SnapshotCollectionExtensions.ToLookupByKey(this System.Collections.Generic.IEnumerable! source, System.Func! keySelector) -> LibTmux.SnapshotLookup! static LibTmux.SplitPaneRequest.operator !=(LibTmux.SplitPaneRequest? left, LibTmux.SplitPaneRequest? right) -> bool static LibTmux.SplitPaneRequest.operator ==(LibTmux.SplitPaneRequest? left, LibTmux.SplitPaneRequest? right) -> bool static LibTmux.SwapPaneRequest.operator !=(LibTmux.SwapPaneRequest? left, LibTmux.SwapPaneRequest? right) -> bool diff --git a/src/LibTmux/Server.Snapshots.cs b/src/LibTmux/Server.Snapshots.cs index 45dabd6..cc19372 100644 --- a/src/LibTmux/Server.Snapshots.cs +++ b/src/LibTmux/Server.Snapshots.cs @@ -74,7 +74,5 @@ public async Task CaptureSnapshotAsync( return new Server(connection, live.Generation, live.RawVersion, snapshot); } - internal ServerSnapshot? Snapshot => _snapshot; - private SnapshotDepth Depth => _snapshot?.Depth ?? SnapshotDepth.Server; } diff --git a/src/LibTmux/Snapshots/CapturedRelation.cs b/src/LibTmux/Snapshots/CapturedRelation.cs index a64384f..4914b09 100644 --- a/src/LibTmux/Snapshots/CapturedRelation.cs +++ b/src/LibTmux/Snapshots/CapturedRelation.cs @@ -49,16 +49,9 @@ internal CapturedRelation(T[]? items, string relation, SnapshotDepth capturedDep public IReadOnlyList OrEmpty() => _items ?? None; } -/// Creates captured and uncaptured relations with inferred types. -public static class CapturedRelation +internal static class CapturedRelation { - /// Creates a captured relation over a read child sequence. - /// The captured child type. - /// The children the snapshot read. - /// The relation name. - /// The depth the snapshot reached. - /// The captured relation. - public static CapturedRelation Capture( + internal static CapturedRelation Capture( IEnumerable items, string relation, SnapshotDepth capturedDepth) @@ -68,12 +61,7 @@ public static CapturedRelation Capture( return new CapturedRelation([.. items], relation, capturedDepth); } - /// Creates a relation the snapshot did not read. - /// The child type that was not captured. - /// The relation name. - /// The depth the snapshot reached. - /// The uncaptured relation. - public static CapturedRelation Uncaptured( + internal static CapturedRelation Uncaptured( string relation, SnapshotDepth capturedDepth) { diff --git a/src/LibTmux/Snapshots/ServerSnapshot.cs b/src/LibTmux/Snapshots/ServerSnapshot.cs index 00bc0be..720738f 100644 --- a/src/LibTmux/Snapshots/ServerSnapshot.cs +++ b/src/LibTmux/Snapshots/ServerSnapshot.cs @@ -4,82 +4,45 @@ namespace LibTmux; -/// Holds one point-in-time read of a tmux server's hierarchy. -/// -/// Enumerating a snapshot never runs a tmux command. Every level was read -/// during capture, so traversal is deterministic even while the live server -/// changes underneath it. -/// -/// The whole graph is built while capturing, which is why reading any of it -/// is free and works anywhere. A window knows the sessions it is linked into -/// and those sessions know their windows, so walking up and back down lands -/// on the same handles rather than on a second, emptier copy of them. -/// -public sealed class ServerSnapshot +// Builds the copy-backed hierarchy graph carried by a materialized Server. +// Sessions and windows share handles so walking down and back up preserves state. +internal sealed class ServerSnapshot { internal ServerSnapshot( - Server server, - ServerGeneration generation, SnapshotDepth depth, CapturedRelation sessions, CapturedRelation windows, - CapturedRelation panes, - IReadOnlyList windowEdges) + CapturedRelation panes) { - Server = server; - Generation = generation; Depth = depth; Sessions = sessions; Windows = windows; Panes = panes; - WindowEdges = windowEdges; } - /// Gets the server this snapshot was read from. - public Server Server { get; } - - /// Gets the generation observed during capture. - public ServerGeneration Generation { get; } - - /// Gets how far down the hierarchy the capture reached. - public SnapshotDepth Depth { get; } + internal SnapshotDepth Depth { get; } - /// Gets the captured sessions. - public CapturedRelation Sessions { get; } + internal CapturedRelation Sessions { get; } - /// Gets the captured windows, once per session they are linked into. - public CapturedRelation Windows { get; } + internal CapturedRelation Windows { get; } - /// Gets the captured panes, across every window. - public CapturedRelation Panes { get; } + internal CapturedRelation Panes { get; } - /// Gets every session-to-window edge the capture observed. - /// - /// A window linked into several sessions appears once per session, so the - /// edge list is the only place linkage is fully represented. - /// - public IReadOnlyList WindowEdges { get; } - - /// Reads one server hierarchy to the requested depth. - /// A connected server. - /// How far down to read. - /// Cancels the tmux commands. - /// The captured snapshot. [UnsupportedOSPlatform("windows")] - public static async Task CaptureAsync( + internal static async Task CaptureAsync( Server server, SnapshotDepth depth = SnapshotDepth.Panes, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(server); - ServerGeneration generation = server.Generation + _ = server.Generation ?? throw new InvalidOperationException( "The server has no live generation; connect before capturing."); var context = new MaterializationContext(server, ParseVersion(server)); var query = new MaterializationQuery(context); if (depth == SnapshotDepth.Server) { - return Empty(server, generation, depth); + return Empty(depth); } IReadOnlyList> sessionRows = @@ -88,16 +51,13 @@ await query.FetchAsync("list-sessions", null, cancellationToken) if (depth == SnapshotDepth.Sessions) { return new ServerSnapshot( - server, - generation, depth, CapturedRelation.Capture( [.. sessionRows.Select(row => RelationReader.ToSession(server, row))], "sessions", depth), CapturedRelation.Uncaptured("windows", depth), - CapturedRelation.Uncaptured("panes", depth), - []); + CapturedRelation.Uncaptured("panes", depth)); } IReadOnlyList> windowRows = @@ -108,26 +68,19 @@ await query.FetchAsync("list-windows", ["-a"], cancellationToken) ? [] : await query.FetchAsync("list-panes", ["-a"], cancellationToken) .ConfigureAwait(false); - return Build(server, generation, depth, sessionRows, windowRows, paneRows); + return Build(server, depth, sessionRows, windowRows, paneRows); } - private static ServerSnapshot Empty( - Server server, - ServerGeneration generation, - SnapshotDepth depth) => + private static ServerSnapshot Empty(SnapshotDepth depth) => new( - server, - generation, depth, CapturedRelation.Uncaptured("sessions", depth), CapturedRelation.Uncaptured("windows", depth), - CapturedRelation.Uncaptured("panes", depth), - []); + CapturedRelation.Uncaptured("panes", depth)); [UnsupportedOSPlatform("windows")] private static ServerSnapshot Build( Server server, - ServerGeneration generation, SnapshotDepth depth, IReadOnlyList> sessionRows, IReadOnlyList> windowRows, @@ -191,13 +144,10 @@ [.. panes.Where(pane => Owns(paneRows, pane, "window_id", window.Id.ToString())) } return new ServerSnapshot( - server, - generation, depth, Relation(sessions, "sessions", depth), Relation(windows, "windows", depth), - Relation(panes, "panes", depth, depth >= SnapshotDepth.Panes), - edges); + Relation(panes, "panes", depth, depth >= SnapshotDepth.Panes)); } private static CapturedRelation Relation( diff --git a/tests/LibTmux.IntegrationTests/Parity/Component05ParityTests.cs b/tests/LibTmux.IntegrationTests/Parity/Component05ParityTests.cs index 55e6333..57d67ca 100644 --- a/tests/LibTmux.IntegrationTests/Parity/Component05ParityTests.cs +++ b/tests/LibTmux.IntegrationTests/Parity/Component05ParityTests.cs @@ -46,8 +46,7 @@ public async Task Owned_parity_row_has_relation_behavior(string pythonSymbolId) Server server = await Server.ConnectAsync( options, TestContext.Current.CancellationToken); - ServerSnapshot snapshot = await ServerSnapshot.CaptureAsync( - server, + Server snapshot = await server.CaptureSnapshotAsync( SnapshotDepth.Panes, TestContext.Current.CancellationToken); Session session = snapshot.Sessions[0]; diff --git a/tests/LibTmux.IntegrationTests/Parity/Component07ParityTests.cs b/tests/LibTmux.IntegrationTests/Parity/Component07ParityTests.cs index 2c1b3c3..ea74eef 100644 --- a/tests/LibTmux.IntegrationTests/Parity/Component07ParityTests.cs +++ b/tests/LibTmux.IntegrationTests/Parity/Component07ParityTests.cs @@ -109,8 +109,8 @@ private static async Task ProvesKeyedLookupAsync( CancellationToken token) { IReadOnlyList sessions = await server.GetSessionsAsync(token); - SnapshotLookup byId = - sessions.ToLookupByKey(static session => session.Id); + Dictionary byId = + sessions.ToDictionary(static session => session.Id); return byId.Count == sessions.Count && byId.TryGetValue(sessions[0].Id, out Session? found) && found.Id == sessions[0].Id; diff --git a/tests/LibTmux.IntegrationTests/Snapshots/HierarchySnapshotTests.cs b/tests/LibTmux.IntegrationTests/Snapshots/HierarchySnapshotTests.cs index 68f7942..a008746 100644 --- a/tests/LibTmux.IntegrationTests/Snapshots/HierarchySnapshotTests.cs +++ b/tests/LibTmux.IntegrationTests/Snapshots/HierarchySnapshotTests.cs @@ -33,19 +33,21 @@ await raw.ExecuteAsync( ["link-window", "-s", windowId, "-t", "target:"], TestContext.Current.CancellationToken); - ServerSnapshot snapshot = await ServerSnapshot.CaptureAsync( - server, + Server snapshot = await server.CaptureSnapshotAsync( SnapshotDepth.Panes, TestContext.Current.CancellationToken); - SessionWindowEdge[] linked = [.. snapshot.WindowEdges.Where( - edge => edge.WindowId.ToString() == windowId)]; + SessionWindowEdge[] linked = + [ + .. snapshot.Windows.Select(static window => window.Edge).Where( + edge => edge.WindowId.ToString() == windowId), + ]; // The same window is linked into two sessions, so it must appear once // per session while remaining one window identity. Assert.Equal(2, linked.Length); Assert.Single(linked.Select(static edge => edge.WindowId).Distinct()); Assert.Equal(2, linked.Select(static edge => edge.SessionId).Distinct().Count()); - Assert.Equal(SnapshotDepth.Panes, snapshot.Depth); + Assert.True(snapshot.Panes.IsCaptured); } } diff --git a/tests/LibTmux.UnitTests/Collections/SnapshotCollectionTests.cs b/tests/LibTmux.UnitTests/Collections/SnapshotCollectionTests.cs deleted file mode 100644 index 5cb0e0e..0000000 --- a/tests/LibTmux.UnitTests/Collections/SnapshotCollectionTests.cs +++ /dev/null @@ -1,39 +0,0 @@ -namespace LibTmux.UnitTests; - -public sealed class SnapshotCollectionTests -{ - private static IReadOnlyList Captured => ["alpha", "beta", "gamma"]; - - [Fact] - public void Enumeration_is_local_and_uses_BCL_cardinality() - { - IReadOnlyList snapshot = Captured; - // A snapshot is an ordinary in-memory sequence, so BCL cardinality and - // LINQ filtering apply unchanged and never reach tmux. - Assert.Equal(3, snapshot.Count); - Assert.Contains(snapshot, static value => value.StartsWith('b')); - Assert.Equal("alpha", snapshot[0]); - Assert.Equal("beta", snapshot.Single(static value => value.StartsWith('b'))); - Assert.Null(snapshot.FirstOrDefault(static value => value.StartsWith('z'))); - Assert.Null(snapshot.SingleOrDefault(static value => value.StartsWith('z'))); - Assert.Throws( - () => snapshot.Single(static value => value.Length == 5)); - - SnapshotLookup byInitial = - snapshot.ToLookupByKey(static value => value[0]); - - Assert.Equal(3, byInitial.Count); - Assert.Equal("gamma", byInitial['g']); - Assert.True(byInitial.TryGetValue('a', out string? alpha)); - Assert.Equal("alpha", alpha); - Assert.False(byInitial.TryGetValue('z', out _)); - Assert.Throws(() => byInitial['z']); - } - - [Fact] - public void Indexing_rejects_a_key_two_elements_share() - { - Assert.Throws( - () => Captured.ToLookupByKey(static value => value.Length)); - } -} diff --git a/tests/LibTmux.UnitTests/Packaging/PublicApiContractTests.cs b/tests/LibTmux.UnitTests/Packaging/PublicApiContractTests.cs index 98fe1b6..a96997c 100644 --- a/tests/LibTmux.UnitTests/Packaging/PublicApiContractTests.cs +++ b/tests/LibTmux.UnitTests/Packaging/PublicApiContractTests.cs @@ -11,16 +11,6 @@ namespace LibTmux.UnitTests.Packaging; /// public sealed class PublicApiContractTests { - /// Types the assembly offers that the approved surface - /// deliberately omits; entries may shrink but never grow unnoticed. - private static readonly HashSet UnapprovedTypes = new(StringComparer.Ordinal) - { - "T:LibTmux.CapturedRelation", - "T:LibTmux.ServerSnapshot", - "T:LibTmux.SnapshotCollectionExtensions", - "T:LibTmux.SnapshotLookup`2", - }; - [Fact] public void Shipped_baselines_match_both_packages() { @@ -33,7 +23,7 @@ .. approved.Except(built).Order(StringComparer.Ordinal), ]; string[] extra = [ - .. built.Except(approved).Except(UnapprovedTypes).Order(StringComparer.Ordinal), + .. built.Except(approved).Order(StringComparer.Ordinal), ]; Assert.True( @@ -48,25 +38,6 @@ .. built.Except(approved).Except(UnapprovedTypes).Order(StringComparer.Ordinal), + string.Join(Environment.NewLine, extra)); } - [Fact] - public void Every_tracked_divergence_is_still_one() - { - // A list of known problems that has stopped being true is worse than - // no list, because it says the drift is understood when it is not. - HashSet approved = ReadApprovedTypes(); - HashSet built = ReadBuiltTypes(); - - foreach (string unapproved in UnapprovedTypes) - { - Assert.True( - built.Contains(unapproved), - $"{unapproved} is tracked as unapproved but the assembly no longer offers it."); - Assert.False( - approved.Contains(unapproved), - $"{unapproved} is tracked as unapproved but the contract now names it."); - } - } - [Fact] public void Every_approved_member_exists_in_the_assembly() { From 925713dd17346d8b3de57e8ef3c2fb23885467d1 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 11:54:48 -0500 Subject: [PATCH 060/129] Server(refactor[files]): Split utility families why: one 870-line partial mixed six independently navigated command families with shared capability and dispatch plumbing. what: - move keys, interaction, display, execution, administration, and buffer operations into named partial files - leave capability logging and common dispatch in the utility core - preserve every signature and command path unchanged --- src/LibTmux/Server.Administration.cs | 117 +++++ src/LibTmux/Server.Buffers.cs | 124 +++++ src/LibTmux/Server.Display.cs | 88 ++++ src/LibTmux/Server.Execution.cs | 131 +++++ src/LibTmux/Server.Interaction.cs | 224 +++++++++ src/LibTmux/Server.Keys.cs | 75 +++ src/LibTmux/Server.Utilities.cs | 716 --------------------------- 7 files changed, 759 insertions(+), 716 deletions(-) create mode 100644 src/LibTmux/Server.Administration.cs create mode 100644 src/LibTmux/Server.Buffers.cs create mode 100644 src/LibTmux/Server.Display.cs create mode 100644 src/LibTmux/Server.Execution.cs create mode 100644 src/LibTmux/Server.Interaction.cs create mode 100644 src/LibTmux/Server.Keys.cs diff --git a/src/LibTmux/Server.Administration.cs b/src/LibTmux/Server.Administration.cs new file mode 100644 index 0000000..0947e8c --- /dev/null +++ b/src/LibTmux/Server.Administration.cs @@ -0,0 +1,117 @@ +using System.Runtime.Versioning; +using LibTmux.Internal; + +namespace LibTmux; + +public sealed partial class Server +{ + /// Builds the arguments an access request sends. + /// + /// The command itself arrived in tmux 3.3, so the refusal belongs here + /// rather than beside the dispatch: a chained request that skipped it + /// would send a command older servers do not have. + /// + /// tmux is older than 3.3. + internal List BuildServerAccessArguments(ServerAccessRequest request) + { + RequireCommand(ServerUtilities.ServerAccessCapability, "server-access"); + List arguments = ["server-access"]; + ServerUtilities.AddFlag(arguments, request.AllowUser is not null, "-a"); + ServerUtilities.AddFlag(arguments, request.DenyUser is not null, "-d"); + ServerUtilities.AddFlag(arguments, request.List, "-l"); + ServerUtilities.AddFlag(arguments, request.ReadOnly, "-r"); + ServerUtilities.AddFlag(arguments, request.ReadWrite, "-w"); + if ((request.AllowUser ?? request.DenyUser) is string user) + { + arguments.Add(user); + } + + return arguments; + } + + /// Grants or withdraws another user's access to this server. + /// Who, and what they may do. + /// Cancels the tmux command. + /// The current list when it was asked for, and null otherwise. + /// tmux is older than 3.3. + [UnsupportedOSPlatform("windows")] + public async Task?> ConfigureAccessAsync( + ServerAccessRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + List arguments = BuildServerAccessArguments(request); + + IReadOnlyList lines = await ReadUtilityAsync(arguments, cancellationToken) + .ConfigureAwait(false); + return request.List ? lines : null; + } + + /// Reads a tmux configuration file. + /// The file to read. + /// Whether a missing file is passed over in silence. + /// Whether the file is checked rather than run. + /// Whether each command read is reported. + /// Cancels the tmux command. + [UnsupportedOSPlatform("windows")] + public Task SourceFileAsync( + string path, + bool quiet = false, + bool parseOnly = false, + bool verbose = false, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + List arguments = ["source-file"]; + ServerUtilities.AddFlag(arguments, quiet, "-q"); + ServerUtilities.AddFlag(arguments, parseOnly, "-n"); + ServerUtilities.AddFlag(arguments, verbose, "-v"); + arguments.Add(path); + return RunUtilityAsync(arguments, cancellationToken); + } + + /// Locks every client attached to this server. + /// Cancels the tmux command. + [UnsupportedOSPlatform("windows")] + public Task LockAsync(CancellationToken cancellationToken = default) => + RunUtilityAsync(["lock-server"], cancellationToken); + + /// Reads what the server has been logging. + /// The client to read for, or null for the server. + /// Which log to read. + /// Cancels the tmux command. + /// One line per entry. + [UnsupportedOSPlatform("windows")] + public async Task> GetMessagesAsync( + string? targetClient = null, + ShowMessagesMode mode = ShowMessagesMode.Messages, + CancellationToken cancellationToken = default) + { + List arguments = ["show-messages"]; + if (ServerUtilities.GetShowMessagesFlag(mode) is string flag) + { + arguments.Add(flag); + } + + ServerUtilities.AddValue(arguments, "-t", targetClient); + return await ReadUtilityAsync(arguments, cancellationToken).ConfigureAwait(false); + } + + /// Reads the commands this tmux knows. + /// One command to describe, or null for all of them. + /// Cancels the tmux command. + /// One line per command, giving its syntax. + [UnsupportedOSPlatform("windows")] + public async Task> GetCommandsAsync( + string? name = null, + CancellationToken cancellationToken = default) + { + List arguments = ["list-commands"]; + if (name is not null) + { + arguments.Add(name); + } + + return await ReadUtilityAsync(arguments, cancellationToken).ConfigureAwait(false); + } +} diff --git a/src/LibTmux/Server.Buffers.cs b/src/LibTmux/Server.Buffers.cs new file mode 100644 index 0000000..11dfacf --- /dev/null +++ b/src/LibTmux/Server.Buffers.cs @@ -0,0 +1,124 @@ +using System.Runtime.Versioning; +using LibTmux.Internal; + +namespace LibTmux; + +public sealed partial class Server +{ + /// Puts text into a paste buffer. + /// The text to store. + /// The buffer name, or null for a new one. + /// Whether the text joins what is already there. + /// Cancels the tmux command. + [UnsupportedOSPlatform("windows")] + public Task SetBufferAsync( + string data, + string? name = null, + bool append = false, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(data); + List arguments = ["set-buffer"]; + ServerUtilities.AddFlag(arguments, append, "-a"); + ServerUtilities.AddValue(arguments, "-b", name); + arguments.Add(data); + return RunUtilityAsync(arguments, cancellationToken); + } + + /// Puts a file's contents into a paste buffer. + /// The file to read. + /// The buffer name, or null for a new one. + /// Cancels the tmux command. + [UnsupportedOSPlatform("windows")] + public Task LoadBufferAsync( + string path, + string? name = null, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + List arguments = ["load-buffer"]; + ServerUtilities.AddValue(arguments, "-b", name); + arguments.Add(path); + return RunUtilityAsync(arguments, cancellationToken); + } + + /// Writes a paste buffer to a file. + /// The file to write. + /// The buffer to write, or null for the most recent. + /// Whether the buffer joins what the file already holds. + /// Cancels the tmux command. + [UnsupportedOSPlatform("windows")] + public Task SaveBufferAsync( + string path, + string? name = null, + bool append = false, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + List arguments = ["save-buffer"]; + ServerUtilities.AddFlag(arguments, append, "-a"); + ServerUtilities.AddValue(arguments, "-b", name); + arguments.Add(path); + return RunUtilityAsync(arguments, cancellationToken); + } + + /// Reads a paste buffer in full. + /// The buffer to read, or null for the most recent. + /// Cancels the tmux command. + /// Everything the buffer holds. + [UnsupportedOSPlatform("windows")] + public async Task GetBufferAsync( + string? name = null, + CancellationToken cancellationToken = default) + { + List arguments = ["show-buffer"]; + ServerUtilities.AddValue(arguments, "-b", name); + IReadOnlyList lines = await ReadUtilityAsync(arguments, cancellationToken) + .ConfigureAwait(false); + return string.Join('\n', lines); + } + + /// Forgets a paste buffer. + /// The buffer to forget, or null for the most recent. + /// Cancels the tmux command. + [UnsupportedOSPlatform("windows")] + public Task DeleteBufferAsync( + string? name = null, + CancellationToken cancellationToken = default) + { + List arguments = ["delete-buffer"]; + ServerUtilities.AddValue(arguments, "-b", name); + return RunUtilityAsync(arguments, cancellationToken); + } + + internal static List BuildListBuffersArguments(ListBuffersRequest? request) + { + List arguments = ["list-buffers"]; + ServerUtilities.AddValue(arguments, "-F", request?.Format); + ServerUtilities.AddValue(arguments, "-f", request?.Filter?.Value); + + return arguments; + } + + /// Reads the paste buffers. + /// Cancels the tmux command. + /// Every buffer, with its size and a sample of its contents. + [UnsupportedOSPlatform("windows")] + public async Task> GetBuffersAsync( + CancellationToken cancellationToken = default) => + ServerUtilities.ReadBuffers( + await ReadUtilityAsync(["list-buffers"], cancellationToken).ConfigureAwait(false)); + + /// Reads the paste buffers as tmux rendered them. + /// The format and filter, or null for tmux's own. + /// Cancels the tmux command. + /// One line per buffer. + [UnsupportedOSPlatform("windows")] + public async Task> GetBufferLinesAsync( + ListBuffersRequest? request = null, + CancellationToken cancellationToken = default) + { + List arguments = BuildListBuffersArguments(request); + return await ReadUtilityAsync(arguments, cancellationToken).ConfigureAwait(false); + } +} diff --git a/src/LibTmux/Server.Display.cs b/src/LibTmux/Server.Display.cs new file mode 100644 index 0000000..e33ccf4 --- /dev/null +++ b/src/LibTmux/Server.Display.cs @@ -0,0 +1,88 @@ +using System.Globalization; +using System.Runtime.Versioning; +using LibTmux.Internal; +using Microsoft.Extensions.Logging; + +namespace LibTmux; + +public sealed partial class Server +{ + /// Builds the arguments a message request sends. + /// + /// This stays on the server because two of the flags depend on which tmux + /// is answering: literal expansion arrived in 3.4, and 3.2a refuses the + /// target-client flag outright. A chained message has to be built the same + /// way a direct one is. + /// + internal List BuildDisplayMessageArguments(DisplayMessageRequest request) + { + List arguments = ["display-message"]; + ServerUtilities.AddFlag(arguments, request.ReturnText, "-p"); + ServerUtilities.AddFlag(arguments, request.AllFormats, "-a"); + ServerUtilities.AddFlag(arguments, request.Verbose, "-v"); + if (request.NoExpand + && RequiresCapability( + ServerUtilities.DisplayMessageLiteralCapability, + LogMessageLiteral)) + { + arguments.Add("-l"); + } + + ServerUtilities.AddFlag(arguments, request.Notify, "-N"); + if (request.TargetClient is not null + && RequiresCapability( + ServerUtilities.DisplayMessageClientCapability, + LogMessageClient)) + { + // tmux 3.2a prints its usage and refuses the command, even for a + // client that is really attached. Its usage text advertises the + // flag anyway, so only running it tells the truth. + ServerUtilities.AddValue(arguments, "-c", request.TargetClient); + } + ServerUtilities.AddValue( + arguments, + "-d", + request.Delay is TimeSpan delay + ? ((long)delay.TotalMilliseconds).ToString(CultureInfo.InvariantCulture) + : null); + ServerUtilities.AddValue(arguments, "-F", request.Format); + if (request.Message.Length > 0) + { + arguments.Add(request.Message); + } + + return arguments; + } + + /// Shows a message on a client. + /// What to show, and how. + /// Cancels the tmux command. + /// The rendered text when it was asked for, and null otherwise. + /// + /// tmux reports a bad format on its error stream rather than by failing, so + /// a message it would not render is logged and answered with nothing. + /// + [UnsupportedOSPlatform("windows")] + public async Task?> DisplayMessageAsync( + DisplayMessageRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + List arguments = BuildDisplayMessageArguments(request); + + TmuxCommandResult result = await _commandDispatcher + .ExecuteAsync(arguments, cancellationToken) + .ConfigureAwait(false); + if (result.ExitCode == 0) + { + return request.ReturnText ? result.StandardOutputLines : null; + } + + if (Connection?.Options.Logger is ILogger logger) + { + LogDisplayMessageRefused(logger, string.Join('\n', result.StandardErrorLines)); + } + + return null; + } +} diff --git a/src/LibTmux/Server.Execution.cs b/src/LibTmux/Server.Execution.cs new file mode 100644 index 0000000..a6fa09f --- /dev/null +++ b/src/LibTmux/Server.Execution.cs @@ -0,0 +1,131 @@ +using System.Globalization; +using System.Runtime.Versioning; +using LibTmux.Internal; + +namespace LibTmux; + +public sealed partial class Server +{ + /// Builds the arguments a shell request sends. + /// + /// Three of these flags arrived at different tmux versions, so this stays + /// on the server that knows which one is answering rather than becoming a + /// helper a caller could reach without that knowledge. + /// + internal List BuildRunShellArguments(RunShellRequest request) + { + List arguments = ["run-shell"]; + ServerUtilities.AddFlag(arguments, request.Background, "-b"); + ServerUtilities.AddFlag(arguments, request.AsTmuxCommand, "-C"); + if (request.ShowStandardError + && RequiresCapability( + ServerUtilities.RunShellStandardErrorCapability, + LogRunShellStandardError)) + { + arguments.Add("-E"); + } + + if (request.WorkingDirectory is not null + && RequiresCapability( + ServerUtilities.RunShellWorkingDirectoryCapability, + LogRunShellWorkingDirectory)) + { + ServerUtilities.AddValue(arguments, "-c", request.WorkingDirectory); + } + + ServerUtilities.AddValue( + arguments, + "-d", + request.Delay is TimeSpan delay + ? ((long)delay.TotalSeconds).ToString(CultureInfo.InvariantCulture) + : null); + ServerUtilities.AddValue(arguments, "-t", request.TargetPane); + arguments.Add(request.Command); + if (request.Arguments is { Count: > 0 } extra + && RequiresCapability( + ServerUtilities.RunShellArgumentsCapability, + LogRunShellArguments)) + { + arguments.AddRange(extra); + } + + return arguments; + } + + /// Runs a shell command and reports what it printed. + /// What to run, and how. + /// Cancels the tmux command. + /// What the command printed, or null when tmux did not wait for it. + /// + /// The directory flag arrived in tmux 3.4, the error-output flag in 3.6, + /// and passing arguments without a shell in 3.7. + /// + [UnsupportedOSPlatform("windows")] + public async Task?> RunShellAsync( + RunShellRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + List arguments = BuildRunShellArguments(request); + + TmuxCommandResult result = await _commandDispatcher + .ExecuteAsync(arguments, cancellationToken) + .ConfigureAwait(false); + TmuxCommandFailure.ThrowIfFailed(result, "run-shell"); + + // Nothing has run yet when tmux was told not to wait, so there is + // nothing it could report. + return request.Background ? null : result.StandardOutputLines; + } + + /// Runs one tmux command or another depending on a shell command. + /// What to test, and what to run either way. + /// Cancels the tmux command. + [UnsupportedOSPlatform("windows")] + public Task IfShellAsync(IfShellRequest request, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + return RunUtilityAsync(BuildIfShellArguments(request), cancellationToken); + } + + internal static List BuildIfShellArguments(IfShellRequest request) + { + List arguments = ["if-shell"]; + ServerUtilities.AddFlag(arguments, request.Background, "-b"); + ServerUtilities.AddValue(arguments, "-t", request.TargetPane); + arguments.Add(request.ShellCommand); + arguments.Add(string.Join(' ', request.ThenCommand)); + if (request.ElseCommand is { Count: > 0 } otherwise) + { + arguments.Add(string.Join(' ', otherwise)); + } + + return arguments; + } + + /// Waits on, signals, or locks a tmux channel. + /// Which channel, and what to do with it. + /// Cancels the tmux command. + /// + /// Waiting blocks until something else signals the channel, so a call that + /// waits does not return on its own. + /// + [UnsupportedOSPlatform("windows")] + public Task WaitForAsync(WaitForRequest request, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + return RunUtilityAsync(BuildWaitForArguments(request), cancellationToken); + } + + internal static List BuildWaitForArguments(WaitForRequest request) + { + List arguments = ["wait-for"]; + if (ServerUtilities.GetWaitModeFlag(request.Mode) is string flag) + { + arguments.Add(flag); + } + + arguments.Add(request.Channel); + return arguments; + } +} diff --git a/src/LibTmux/Server.Interaction.cs b/src/LibTmux/Server.Interaction.cs new file mode 100644 index 0000000..f70144c --- /dev/null +++ b/src/LibTmux/Server.Interaction.cs @@ -0,0 +1,224 @@ +using System.Runtime.Versioning; +using LibTmux.Internal; + +namespace LibTmux; + +public sealed partial class Server +{ + /// Builds the arguments a prompt request sends. + /// + /// The refusal below 3.3 belongs here rather than beside the dispatch, + /// because tmux 3.2a reads the type flag as a pair of booleans meaning + /// something else: a chained prompt that skipped the check would ask a + /// different question rather than fail. + /// + /// + /// The request asks for a format or a prompt type and tmux is older than 3.3. + /// + internal List BuildCommandPromptArguments(CommandPromptRequest request) + { + // tmux 3.2a spells the type flag as a pair of booleans meaning + // something else, so sending one there would ask a different question + // rather than fail. Nothing is sent instead. + if ((request.ExpandFormat || request.Type is not null) + && !Supports(ServerUtilities.CommandPromptBackgroundCapability)) + { + throw new TmuxVersionTooLowException( + "Expanding a command prompt as a format, or naming what it asks for, requires tmux 3.3a.", + TmuxVersion.Parse("3.3a"), + Version ?? default); + } + + List arguments = ["command-prompt"]; + ServerUtilities.AddFlag(arguments, request.OneKey, "-1"); + ServerUtilities.AddFlag(arguments, request.Numeric, "-N"); + ServerUtilities.AddFlag(arguments, request.OnInputChange, "-i"); + ServerUtilities.AddFlag(arguments, request.KeyOnly, "-k"); + ServerUtilities.AddFlag(arguments, request.ExpandFormat, "-F"); + if (request.Literal + && RequiresCapability(ServerUtilities.CommandPromptLiteralCapability, LogPromptLiteral)) + { + arguments.Add("-l"); + } + + if (request.BackspaceExits + && RequiresCapability(ServerUtilities.CommandPrompt37Capability, LogPrompt37)) + { + arguments.Add("-e"); + } + + if (request.NoFreeze + && RequiresCapability(ServerUtilities.CommandPrompt37Capability, LogPrompt37)) + { + arguments.Add("-C"); + } + + ServerUtilities.AddValue(arguments, "-I", request.Inputs); + ServerUtilities.AddValue(arguments, "-p", request.Prompt); + ServerUtilities.AddValue(arguments, "-t", request.TargetClient); + ServerUtilities.AddValue( + arguments, + "-T", + request.Type is PromptType type ? ServerUtilities.GetPromptTypeName(type) : null); + arguments.Add(request.Template); + + return arguments; + } + + /// Asks a client for input and runs a command with the answer. + /// What to ask, and how. + /// Cancels the tmux command. + /// + /// The request asks for a format or a prompt type and tmux is older than 3.3. + /// + [UnsupportedOSPlatform("windows")] + public Task ShowCommandPromptAsync( + CommandPromptRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + List arguments = BuildCommandPromptArguments(request); + return RunUtilityAsync(arguments, cancellationToken); + } + + /// Forgets what has been typed at command prompts. + /// Which history to clear, or null for all of them. + /// Cancels the tmux command. + /// tmux is older than 3.3. + [UnsupportedOSPlatform("windows")] + public Task ClearPromptHistoryAsync( + PromptType? type = null, + CancellationToken cancellationToken = default) + { + RequireCommand( + ServerUtilities.ClearPromptHistoryCapability, + "clear-prompt-history"); + List arguments = ["clear-prompt-history"]; + ServerUtilities.AddValue( + arguments, + "-T", + type is PromptType value ? ServerUtilities.GetPromptTypeName(value) : null); + return RunUtilityAsync(arguments, cancellationToken); + } + + /// Reads what has been typed at command prompts. + /// Which history to read, or null for all of them. + /// Cancels the tmux command. + /// One line per remembered entry. + /// tmux is older than 3.3. + [UnsupportedOSPlatform("windows")] + public async Task> GetPromptHistoryAsync( + PromptType? type = null, + CancellationToken cancellationToken = default) + { + RequireCommand(ServerUtilities.ShowPromptHistoryCapability, "show-prompt-history"); + List arguments = ["show-prompt-history"]; + ServerUtilities.AddValue( + arguments, + "-T", + type is PromptType value ? ServerUtilities.GetPromptTypeName(value) : null); + return await ReadUtilityAsync(arguments, cancellationToken).ConfigureAwait(false); + } + + /// Builds the arguments a confirmation request sends. + /// + /// Naming the accepting key, and defaulting to yes, arrived in tmux 3.4, + /// so this stays on the server that knows which one is answering. + /// + internal List BuildConfirmBeforeArguments(ConfirmBeforeRequest request) + { + List arguments = ["confirm-before"]; + if (request.DefaultYes + && RequiresCapability( + ServerUtilities.ConfirmBeforeAcceptanceCapability, + LogConfirmAcceptance)) + { + arguments.Add("-y"); + } + + if (request.ConfirmKey is not null + && RequiresCapability( + ServerUtilities.ConfirmBeforeAcceptanceCapability, + LogConfirmAcceptance)) + { + ServerUtilities.AddValue(arguments, "-c", request.ConfirmKey); + } + + ServerUtilities.AddValue(arguments, "-p", request.Prompt); + ServerUtilities.AddValue(arguments, "-t", request.TargetClient); + arguments.AddRange(request.Command); + + return arguments; + } + + /// Asks a client to confirm before running a command. + /// What to run, and how to ask. + /// Cancels the tmux command. + /// Naming the key, and defaulting to yes, arrived in tmux 3.4. + [UnsupportedOSPlatform("windows")] + public Task ConfirmBeforeAsync( + ConfirmBeforeRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + List arguments = BuildConfirmBeforeArguments(request); + return RunUtilityAsync(arguments, cancellationToken); + } + + /// Builds the arguments a menu request sends. + /// + /// The style flags arrived in tmux 3.4 and the mouse flag in 3.5, so this + /// stays on the server that knows which one is answering. + /// + internal List BuildDisplayMenuArguments(DisplayMenuRequest request) + { + List arguments = ["display-menu"]; + ServerUtilities.AddFlag(arguments, request.StayOpen, "-O"); + if (request.Mouse + && RequiresCapability(ServerUtilities.DisplayMenuMouseCapability, LogMenuMouse)) + { + arguments.Add("-M"); + } + + if (SupportsMenuStyles()) + { + ServerUtilities.AddValue(arguments, "-b", request.BorderLines); + ServerUtilities.AddValue(arguments, "-C", request.StartingChoice); + ServerUtilities.AddValue(arguments, "-H", request.SelectedStyle); + ServerUtilities.AddValue(arguments, "-s", request.Style); + ServerUtilities.AddValue(arguments, "-S", request.BorderStyle); + } + + ServerUtilities.AddValue(arguments, "-c", request.TargetClient); + ServerUtilities.AddValue(arguments, "-t", request.TargetPane); + ServerUtilities.AddValue(arguments, "-T", request.Title); + ServerUtilities.AddValue(arguments, "-x", request.X); + ServerUtilities.AddValue(arguments, "-y", request.Y); + foreach (TmuxMenuItem item in request.Items) + { + arguments.Add(item.Name); + arguments.Add(item.Key); + arguments.Add(item.Command); + } + + return arguments; + } + + /// Shows a menu on a client. + /// What the menu offers, and how it looks. + /// Cancels the tmux command. + /// + /// The style flags arrived in tmux 3.4 and the mouse flag in 3.5. Older + /// servers are shown the same menu without them. + /// + [UnsupportedOSPlatform("windows")] + public Task ShowMenuAsync( + DisplayMenuRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + List arguments = BuildDisplayMenuArguments(request); + return RunUtilityAsync(arguments, cancellationToken); + } +} diff --git a/src/LibTmux/Server.Keys.cs b/src/LibTmux/Server.Keys.cs new file mode 100644 index 0000000..74e243c --- /dev/null +++ b/src/LibTmux/Server.Keys.cs @@ -0,0 +1,75 @@ +using System.Runtime.Versioning; +using LibTmux.Internal; + +namespace LibTmux; + +public sealed partial class Server +{ + /// Binds a key to a tmux command. + /// Which key, to what, and in which table. + /// Cancels the tmux command. + [UnsupportedOSPlatform("windows")] + public Task BindKeyAsync(BindKeyRequest request, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + return RunUtilityAsync(BuildBindKeyArguments(request), cancellationToken); + } + + internal static List BuildBindKeyArguments(BindKeyRequest request) + { + List arguments = ["bind-key"]; + ServerUtilities.AddFlag(arguments, request.Repeat, "-r"); + ServerUtilities.AddValue(arguments, "-T", request.KeyTable); + ServerUtilities.AddValue(arguments, "-N", request.Note); + arguments.Add(request.Key); + arguments.AddRange(request.Command); + return arguments; + } + + /// Removes a key binding. + /// Which key, or every key in a table. + /// Cancels the tmux command. + [UnsupportedOSPlatform("windows")] + public Task UnbindKeyAsync( + UnbindKeyRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + return RunUtilityAsync(BuildUnbindKeyArguments(request), cancellationToken); + } + + internal static List BuildUnbindKeyArguments(UnbindKeyRequest request) + { + List arguments = ["unbind-key"]; + ServerUtilities.AddFlag(arguments, request.All, "-a"); + ServerUtilities.AddFlag(arguments, request.Quiet, "-q"); + ServerUtilities.AddValue(arguments, "-T", request.KeyTable); + + // tmux still wants a key after the all flag, and takes any one. + arguments.Add(request.Key ?? "-a"); + return arguments; + } + + /// Reads the key bindings. + /// The table to read, or null for every table. + /// The tmux format each binding is rendered with. + /// Cancels the tmux command. + /// One line per binding, as tmux rendered it. + /// Rendering with a format arrived in tmux 3.7. + [UnsupportedOSPlatform("windows")] + public async Task> GetKeysAsync( + string? keyTable = null, + string? format = null, + CancellationToken cancellationToken = default) + { + List arguments = ["list-keys"]; + ServerUtilities.AddValue(arguments, "-T", keyTable); + if (format is not null + && RequiresCapability(ServerUtilities.ListKeysFormatCapability, LogListKeysFormat)) + { + ServerUtilities.AddValue(arguments, "-F", format); + } + + return await ReadUtilityAsync(arguments, cancellationToken).ConfigureAwait(false); + } +} diff --git a/src/LibTmux/Server.Utilities.cs b/src/LibTmux/Server.Utilities.cs index 56dc004..aa047d4 100644 --- a/src/LibTmux/Server.Utilities.cs +++ b/src/LibTmux/Server.Utilities.cs @@ -1,4 +1,3 @@ -using System.Globalization; using System.Runtime.Versioning; using LibTmux.Internal; using Microsoft.Extensions.Logging; @@ -17,725 +16,10 @@ public enum ShowMessagesMode /// What the server knows about attached terminals. Terminals, } - // Server utilities omit unsupported commands and warn when optional flags must // be downgraded. public sealed partial class Server { - /// Binds a key to a tmux command. - /// Which key, to what, and in which table. - /// Cancels the tmux command. - [UnsupportedOSPlatform("windows")] - public Task BindKeyAsync(BindKeyRequest request, CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(request); - return RunUtilityAsync(BuildBindKeyArguments(request), cancellationToken); - } - - internal static List BuildBindKeyArguments(BindKeyRequest request) - { - List arguments = ["bind-key"]; - ServerUtilities.AddFlag(arguments, request.Repeat, "-r"); - ServerUtilities.AddValue(arguments, "-T", request.KeyTable); - ServerUtilities.AddValue(arguments, "-N", request.Note); - arguments.Add(request.Key); - arguments.AddRange(request.Command); - return arguments; - } - - /// Removes a key binding. - /// Which key, or every key in a table. - /// Cancels the tmux command. - [UnsupportedOSPlatform("windows")] - public Task UnbindKeyAsync( - UnbindKeyRequest request, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(request); - return RunUtilityAsync(BuildUnbindKeyArguments(request), cancellationToken); - } - - internal static List BuildUnbindKeyArguments(UnbindKeyRequest request) - { - List arguments = ["unbind-key"]; - ServerUtilities.AddFlag(arguments, request.All, "-a"); - ServerUtilities.AddFlag(arguments, request.Quiet, "-q"); - ServerUtilities.AddValue(arguments, "-T", request.KeyTable); - - // tmux still wants a key after the all flag, and takes any one. - arguments.Add(request.Key ?? "-a"); - return arguments; - } - - /// Reads the key bindings. - /// The table to read, or null for every table. - /// The tmux format each binding is rendered with. - /// Cancels the tmux command. - /// One line per binding, as tmux rendered it. - /// Rendering with a format arrived in tmux 3.7. - [UnsupportedOSPlatform("windows")] - public async Task> GetKeysAsync( - string? keyTable = null, - string? format = null, - CancellationToken cancellationToken = default) - { - List arguments = ["list-keys"]; - ServerUtilities.AddValue(arguments, "-T", keyTable); - if (format is not null - && RequiresCapability(ServerUtilities.ListKeysFormatCapability, LogListKeysFormat)) - { - ServerUtilities.AddValue(arguments, "-F", format); - } - - return await ReadUtilityAsync(arguments, cancellationToken).ConfigureAwait(false); - } - - /// Builds the arguments a prompt request sends. - /// - /// The refusal below 3.3 belongs here rather than beside the dispatch, - /// because tmux 3.2a reads the type flag as a pair of booleans meaning - /// something else: a chained prompt that skipped the check would ask a - /// different question rather than fail. - /// - /// - /// The request asks for a format or a prompt type and tmux is older than 3.3. - /// - internal List BuildCommandPromptArguments(CommandPromptRequest request) - { - // tmux 3.2a spells the type flag as a pair of booleans meaning - // something else, so sending one there would ask a different question - // rather than fail. Nothing is sent instead. - if ((request.ExpandFormat || request.Type is not null) - && !Supports(ServerUtilities.CommandPromptBackgroundCapability)) - { - throw new TmuxVersionTooLowException( - "Expanding a command prompt as a format, or naming what it asks for, requires tmux 3.3a.", - TmuxVersion.Parse("3.3a"), - Version ?? default); - } - - List arguments = ["command-prompt"]; - ServerUtilities.AddFlag(arguments, request.OneKey, "-1"); - ServerUtilities.AddFlag(arguments, request.Numeric, "-N"); - ServerUtilities.AddFlag(arguments, request.OnInputChange, "-i"); - ServerUtilities.AddFlag(arguments, request.KeyOnly, "-k"); - ServerUtilities.AddFlag(arguments, request.ExpandFormat, "-F"); - if (request.Literal - && RequiresCapability(ServerUtilities.CommandPromptLiteralCapability, LogPromptLiteral)) - { - arguments.Add("-l"); - } - - if (request.BackspaceExits - && RequiresCapability(ServerUtilities.CommandPrompt37Capability, LogPrompt37)) - { - arguments.Add("-e"); - } - - if (request.NoFreeze - && RequiresCapability(ServerUtilities.CommandPrompt37Capability, LogPrompt37)) - { - arguments.Add("-C"); - } - - ServerUtilities.AddValue(arguments, "-I", request.Inputs); - ServerUtilities.AddValue(arguments, "-p", request.Prompt); - ServerUtilities.AddValue(arguments, "-t", request.TargetClient); - ServerUtilities.AddValue( - arguments, - "-T", - request.Type is PromptType type ? ServerUtilities.GetPromptTypeName(type) : null); - arguments.Add(request.Template); - - return arguments; - } - - /// Asks a client for input and runs a command with the answer. - /// What to ask, and how. - /// Cancels the tmux command. - /// - /// The request asks for a format or a prompt type and tmux is older than 3.3. - /// - [UnsupportedOSPlatform("windows")] - public Task ShowCommandPromptAsync( - CommandPromptRequest request, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(request); - - List arguments = BuildCommandPromptArguments(request); - return RunUtilityAsync(arguments, cancellationToken); - } - - /// Forgets what has been typed at command prompts. - /// Which history to clear, or null for all of them. - /// Cancels the tmux command. - /// tmux is older than 3.3. - [UnsupportedOSPlatform("windows")] - public Task ClearPromptHistoryAsync( - PromptType? type = null, - CancellationToken cancellationToken = default) - { - RequireCommand( - ServerUtilities.ClearPromptHistoryCapability, - "clear-prompt-history"); - List arguments = ["clear-prompt-history"]; - ServerUtilities.AddValue( - arguments, - "-T", - type is PromptType value ? ServerUtilities.GetPromptTypeName(value) : null); - return RunUtilityAsync(arguments, cancellationToken); - } - - /// Reads what has been typed at command prompts. - /// Which history to read, or null for all of them. - /// Cancels the tmux command. - /// One line per remembered entry. - /// tmux is older than 3.3. - [UnsupportedOSPlatform("windows")] - public async Task> GetPromptHistoryAsync( - PromptType? type = null, - CancellationToken cancellationToken = default) - { - RequireCommand(ServerUtilities.ShowPromptHistoryCapability, "show-prompt-history"); - List arguments = ["show-prompt-history"]; - ServerUtilities.AddValue( - arguments, - "-T", - type is PromptType value ? ServerUtilities.GetPromptTypeName(value) : null); - return await ReadUtilityAsync(arguments, cancellationToken).ConfigureAwait(false); - } - - /// Builds the arguments a confirmation request sends. - /// - /// Naming the accepting key, and defaulting to yes, arrived in tmux 3.4, - /// so this stays on the server that knows which one is answering. - /// - internal List BuildConfirmBeforeArguments(ConfirmBeforeRequest request) - { - List arguments = ["confirm-before"]; - if (request.DefaultYes - && RequiresCapability( - ServerUtilities.ConfirmBeforeAcceptanceCapability, - LogConfirmAcceptance)) - { - arguments.Add("-y"); - } - - if (request.ConfirmKey is not null - && RequiresCapability( - ServerUtilities.ConfirmBeforeAcceptanceCapability, - LogConfirmAcceptance)) - { - ServerUtilities.AddValue(arguments, "-c", request.ConfirmKey); - } - - ServerUtilities.AddValue(arguments, "-p", request.Prompt); - ServerUtilities.AddValue(arguments, "-t", request.TargetClient); - arguments.AddRange(request.Command); - - return arguments; - } - - /// Asks a client to confirm before running a command. - /// What to run, and how to ask. - /// Cancels the tmux command. - /// Naming the key, and defaulting to yes, arrived in tmux 3.4. - [UnsupportedOSPlatform("windows")] - public Task ConfirmBeforeAsync( - ConfirmBeforeRequest request, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(request); - List arguments = BuildConfirmBeforeArguments(request); - return RunUtilityAsync(arguments, cancellationToken); - } - - /// Builds the arguments a menu request sends. - /// - /// The style flags arrived in tmux 3.4 and the mouse flag in 3.5, so this - /// stays on the server that knows which one is answering. - /// - internal List BuildDisplayMenuArguments(DisplayMenuRequest request) - { - List arguments = ["display-menu"]; - ServerUtilities.AddFlag(arguments, request.StayOpen, "-O"); - if (request.Mouse - && RequiresCapability(ServerUtilities.DisplayMenuMouseCapability, LogMenuMouse)) - { - arguments.Add("-M"); - } - - if (SupportsMenuStyles()) - { - ServerUtilities.AddValue(arguments, "-b", request.BorderLines); - ServerUtilities.AddValue(arguments, "-C", request.StartingChoice); - ServerUtilities.AddValue(arguments, "-H", request.SelectedStyle); - ServerUtilities.AddValue(arguments, "-s", request.Style); - ServerUtilities.AddValue(arguments, "-S", request.BorderStyle); - } - - ServerUtilities.AddValue(arguments, "-c", request.TargetClient); - ServerUtilities.AddValue(arguments, "-t", request.TargetPane); - ServerUtilities.AddValue(arguments, "-T", request.Title); - ServerUtilities.AddValue(arguments, "-x", request.X); - ServerUtilities.AddValue(arguments, "-y", request.Y); - foreach (TmuxMenuItem item in request.Items) - { - arguments.Add(item.Name); - arguments.Add(item.Key); - arguments.Add(item.Command); - } - - return arguments; - } - - /// Shows a menu on a client. - /// What the menu offers, and how it looks. - /// Cancels the tmux command. - /// - /// The style flags arrived in tmux 3.4 and the mouse flag in 3.5. Older - /// servers are shown the same menu without them. - /// - [UnsupportedOSPlatform("windows")] - public Task ShowMenuAsync( - DisplayMenuRequest request, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(request); - List arguments = BuildDisplayMenuArguments(request); - return RunUtilityAsync(arguments, cancellationToken); - } - - /// Builds the arguments a message request sends. - /// - /// This stays on the server because two of the flags depend on which tmux - /// is answering: literal expansion arrived in 3.4, and 3.2a refuses the - /// target-client flag outright. A chained message has to be built the same - /// way a direct one is. - /// - internal List BuildDisplayMessageArguments(DisplayMessageRequest request) - { - List arguments = ["display-message"]; - ServerUtilities.AddFlag(arguments, request.ReturnText, "-p"); - ServerUtilities.AddFlag(arguments, request.AllFormats, "-a"); - ServerUtilities.AddFlag(arguments, request.Verbose, "-v"); - if (request.NoExpand - && RequiresCapability( - ServerUtilities.DisplayMessageLiteralCapability, - LogMessageLiteral)) - { - arguments.Add("-l"); - } - - ServerUtilities.AddFlag(arguments, request.Notify, "-N"); - if (request.TargetClient is not null - && RequiresCapability( - ServerUtilities.DisplayMessageClientCapability, - LogMessageClient)) - { - // tmux 3.2a prints its usage and refuses the command, even for a - // client that is really attached. Its usage text advertises the - // flag anyway, so only running it tells the truth. - ServerUtilities.AddValue(arguments, "-c", request.TargetClient); - } - ServerUtilities.AddValue( - arguments, - "-d", - request.Delay is TimeSpan delay - ? ((long)delay.TotalMilliseconds).ToString(CultureInfo.InvariantCulture) - : null); - ServerUtilities.AddValue(arguments, "-F", request.Format); - if (request.Message.Length > 0) - { - arguments.Add(request.Message); - } - - return arguments; - } - - /// Shows a message on a client. - /// What to show, and how. - /// Cancels the tmux command. - /// The rendered text when it was asked for, and null otherwise. - /// - /// tmux reports a bad format on its error stream rather than by failing, so - /// a message it would not render is logged and answered with nothing. - /// - [UnsupportedOSPlatform("windows")] - public async Task?> DisplayMessageAsync( - DisplayMessageRequest request, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(request); - List arguments = BuildDisplayMessageArguments(request); - - TmuxCommandResult result = await _commandDispatcher - .ExecuteAsync(arguments, cancellationToken) - .ConfigureAwait(false); - if (result.ExitCode == 0) - { - return request.ReturnText ? result.StandardOutputLines : null; - } - - if (Connection?.Options.Logger is ILogger logger) - { - LogDisplayMessageRefused(logger, string.Join('\n', result.StandardErrorLines)); - } - - return null; - } - - /// Builds the arguments a shell request sends. - /// - /// Three of these flags arrived at different tmux versions, so this stays - /// on the server that knows which one is answering rather than becoming a - /// helper a caller could reach without that knowledge. - /// - internal List BuildRunShellArguments(RunShellRequest request) - { - List arguments = ["run-shell"]; - ServerUtilities.AddFlag(arguments, request.Background, "-b"); - ServerUtilities.AddFlag(arguments, request.AsTmuxCommand, "-C"); - if (request.ShowStandardError - && RequiresCapability( - ServerUtilities.RunShellStandardErrorCapability, - LogRunShellStandardError)) - { - arguments.Add("-E"); - } - - if (request.WorkingDirectory is not null - && RequiresCapability( - ServerUtilities.RunShellWorkingDirectoryCapability, - LogRunShellWorkingDirectory)) - { - ServerUtilities.AddValue(arguments, "-c", request.WorkingDirectory); - } - - ServerUtilities.AddValue( - arguments, - "-d", - request.Delay is TimeSpan delay - ? ((long)delay.TotalSeconds).ToString(CultureInfo.InvariantCulture) - : null); - ServerUtilities.AddValue(arguments, "-t", request.TargetPane); - arguments.Add(request.Command); - if (request.Arguments is { Count: > 0 } extra - && RequiresCapability( - ServerUtilities.RunShellArgumentsCapability, - LogRunShellArguments)) - { - arguments.AddRange(extra); - } - - return arguments; - } - - /// Runs a shell command and reports what it printed. - /// What to run, and how. - /// Cancels the tmux command. - /// What the command printed, or null when tmux did not wait for it. - /// - /// The directory flag arrived in tmux 3.4, the error-output flag in 3.6, - /// and passing arguments without a shell in 3.7. - /// - [UnsupportedOSPlatform("windows")] - public async Task?> RunShellAsync( - RunShellRequest request, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(request); - List arguments = BuildRunShellArguments(request); - - TmuxCommandResult result = await _commandDispatcher - .ExecuteAsync(arguments, cancellationToken) - .ConfigureAwait(false); - TmuxCommandFailure.ThrowIfFailed(result, "run-shell"); - - // Nothing has run yet when tmux was told not to wait, so there is - // nothing it could report. - return request.Background ? null : result.StandardOutputLines; - } - - /// Runs one tmux command or another depending on a shell command. - /// What to test, and what to run either way. - /// Cancels the tmux command. - [UnsupportedOSPlatform("windows")] - public Task IfShellAsync(IfShellRequest request, CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(request); - return RunUtilityAsync(BuildIfShellArguments(request), cancellationToken); - } - - internal static List BuildIfShellArguments(IfShellRequest request) - { - List arguments = ["if-shell"]; - ServerUtilities.AddFlag(arguments, request.Background, "-b"); - ServerUtilities.AddValue(arguments, "-t", request.TargetPane); - arguments.Add(request.ShellCommand); - arguments.Add(string.Join(' ', request.ThenCommand)); - if (request.ElseCommand is { Count: > 0 } otherwise) - { - arguments.Add(string.Join(' ', otherwise)); - } - - return arguments; - } - - /// Waits on, signals, or locks a tmux channel. - /// Which channel, and what to do with it. - /// Cancels the tmux command. - /// - /// Waiting blocks until something else signals the channel, so a call that - /// waits does not return on its own. - /// - [UnsupportedOSPlatform("windows")] - public Task WaitForAsync(WaitForRequest request, CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(request); - return RunUtilityAsync(BuildWaitForArguments(request), cancellationToken); - } - - internal static List BuildWaitForArguments(WaitForRequest request) - { - List arguments = ["wait-for"]; - if (ServerUtilities.GetWaitModeFlag(request.Mode) is string flag) - { - arguments.Add(flag); - } - - arguments.Add(request.Channel); - return arguments; - } - - /// Builds the arguments an access request sends. - /// - /// The command itself arrived in tmux 3.3, so the refusal belongs here - /// rather than beside the dispatch: a chained request that skipped it - /// would send a command older servers do not have. - /// - /// tmux is older than 3.3. - internal List BuildServerAccessArguments(ServerAccessRequest request) - { - RequireCommand(ServerUtilities.ServerAccessCapability, "server-access"); - List arguments = ["server-access"]; - ServerUtilities.AddFlag(arguments, request.AllowUser is not null, "-a"); - ServerUtilities.AddFlag(arguments, request.DenyUser is not null, "-d"); - ServerUtilities.AddFlag(arguments, request.List, "-l"); - ServerUtilities.AddFlag(arguments, request.ReadOnly, "-r"); - ServerUtilities.AddFlag(arguments, request.ReadWrite, "-w"); - if ((request.AllowUser ?? request.DenyUser) is string user) - { - arguments.Add(user); - } - - return arguments; - } - - /// Grants or withdraws another user's access to this server. - /// Who, and what they may do. - /// Cancels the tmux command. - /// The current list when it was asked for, and null otherwise. - /// tmux is older than 3.3. - [UnsupportedOSPlatform("windows")] - public async Task?> ConfigureAccessAsync( - ServerAccessRequest request, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(request); - List arguments = BuildServerAccessArguments(request); - - IReadOnlyList lines = await ReadUtilityAsync(arguments, cancellationToken) - .ConfigureAwait(false); - return request.List ? lines : null; - } - - /// Reads a tmux configuration file. - /// The file to read. - /// Whether a missing file is passed over in silence. - /// Whether the file is checked rather than run. - /// Whether each command read is reported. - /// Cancels the tmux command. - [UnsupportedOSPlatform("windows")] - public Task SourceFileAsync( - string path, - bool quiet = false, - bool parseOnly = false, - bool verbose = false, - CancellationToken cancellationToken = default) - { - ArgumentException.ThrowIfNullOrWhiteSpace(path); - List arguments = ["source-file"]; - ServerUtilities.AddFlag(arguments, quiet, "-q"); - ServerUtilities.AddFlag(arguments, parseOnly, "-n"); - ServerUtilities.AddFlag(arguments, verbose, "-v"); - arguments.Add(path); - return RunUtilityAsync(arguments, cancellationToken); - } - - /// Locks every client attached to this server. - /// Cancels the tmux command. - [UnsupportedOSPlatform("windows")] - public Task LockAsync(CancellationToken cancellationToken = default) => - RunUtilityAsync(["lock-server"], cancellationToken); - - /// Reads what the server has been logging. - /// The client to read for, or null for the server. - /// Which log to read. - /// Cancels the tmux command. - /// One line per entry. - [UnsupportedOSPlatform("windows")] - public async Task> GetMessagesAsync( - string? targetClient = null, - ShowMessagesMode mode = ShowMessagesMode.Messages, - CancellationToken cancellationToken = default) - { - List arguments = ["show-messages"]; - if (ServerUtilities.GetShowMessagesFlag(mode) is string flag) - { - arguments.Add(flag); - } - - ServerUtilities.AddValue(arguments, "-t", targetClient); - return await ReadUtilityAsync(arguments, cancellationToken).ConfigureAwait(false); - } - - /// Reads the commands this tmux knows. - /// One command to describe, or null for all of them. - /// Cancels the tmux command. - /// One line per command, giving its syntax. - [UnsupportedOSPlatform("windows")] - public async Task> GetCommandsAsync( - string? name = null, - CancellationToken cancellationToken = default) - { - List arguments = ["list-commands"]; - if (name is not null) - { - arguments.Add(name); - } - - return await ReadUtilityAsync(arguments, cancellationToken).ConfigureAwait(false); - } - - /// Puts text into a paste buffer. - /// The text to store. - /// The buffer name, or null for a new one. - /// Whether the text joins what is already there. - /// Cancels the tmux command. - [UnsupportedOSPlatform("windows")] - public Task SetBufferAsync( - string data, - string? name = null, - bool append = false, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(data); - List arguments = ["set-buffer"]; - ServerUtilities.AddFlag(arguments, append, "-a"); - ServerUtilities.AddValue(arguments, "-b", name); - arguments.Add(data); - return RunUtilityAsync(arguments, cancellationToken); - } - - /// Puts a file's contents into a paste buffer. - /// The file to read. - /// The buffer name, or null for a new one. - /// Cancels the tmux command. - [UnsupportedOSPlatform("windows")] - public Task LoadBufferAsync( - string path, - string? name = null, - CancellationToken cancellationToken = default) - { - ArgumentException.ThrowIfNullOrWhiteSpace(path); - List arguments = ["load-buffer"]; - ServerUtilities.AddValue(arguments, "-b", name); - arguments.Add(path); - return RunUtilityAsync(arguments, cancellationToken); - } - - /// Writes a paste buffer to a file. - /// The file to write. - /// The buffer to write, or null for the most recent. - /// Whether the buffer joins what the file already holds. - /// Cancels the tmux command. - [UnsupportedOSPlatform("windows")] - public Task SaveBufferAsync( - string path, - string? name = null, - bool append = false, - CancellationToken cancellationToken = default) - { - ArgumentException.ThrowIfNullOrWhiteSpace(path); - List arguments = ["save-buffer"]; - ServerUtilities.AddFlag(arguments, append, "-a"); - ServerUtilities.AddValue(arguments, "-b", name); - arguments.Add(path); - return RunUtilityAsync(arguments, cancellationToken); - } - - /// Reads a paste buffer in full. - /// The buffer to read, or null for the most recent. - /// Cancels the tmux command. - /// Everything the buffer holds. - [UnsupportedOSPlatform("windows")] - public async Task GetBufferAsync( - string? name = null, - CancellationToken cancellationToken = default) - { - List arguments = ["show-buffer"]; - ServerUtilities.AddValue(arguments, "-b", name); - IReadOnlyList lines = await ReadUtilityAsync(arguments, cancellationToken) - .ConfigureAwait(false); - return string.Join('\n', lines); - } - - /// Forgets a paste buffer. - /// The buffer to forget, or null for the most recent. - /// Cancels the tmux command. - [UnsupportedOSPlatform("windows")] - public Task DeleteBufferAsync( - string? name = null, - CancellationToken cancellationToken = default) - { - List arguments = ["delete-buffer"]; - ServerUtilities.AddValue(arguments, "-b", name); - return RunUtilityAsync(arguments, cancellationToken); - } - - internal static List BuildListBuffersArguments(ListBuffersRequest? request) - { - List arguments = ["list-buffers"]; - ServerUtilities.AddValue(arguments, "-F", request?.Format); - ServerUtilities.AddValue(arguments, "-f", request?.Filter?.Value); - - return arguments; - } - - /// Reads the paste buffers. - /// Cancels the tmux command. - /// Every buffer, with its size and a sample of its contents. - [UnsupportedOSPlatform("windows")] - public async Task> GetBuffersAsync( - CancellationToken cancellationToken = default) => - ServerUtilities.ReadBuffers( - await ReadUtilityAsync(["list-buffers"], cancellationToken).ConfigureAwait(false)); - - /// Reads the paste buffers as tmux rendered them. - /// The format and filter, or null for tmux's own. - /// Cancels the tmux command. - /// One line per buffer. - [UnsupportedOSPlatform("windows")] - public async Task> GetBufferLinesAsync( - ListBuffersRequest? request = null, - CancellationToken cancellationToken = default) - { - List arguments = BuildListBuffersArguments(request); - return await ReadUtilityAsync(arguments, cancellationToken).ConfigureAwait(false); - } - [LoggerMessage( EventId = 21, Level = LogLevel.Warning, From 6220379ee44ccdc353c45b02a4de710c63d6184b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 12:58:54 -0500 Subject: [PATCH 061/129] Pane(refactor[files]): Split operation families why: one 1,634-line partial mixed captured state, terminal I/O, topology mutation, display modes, and shared capability plumbing. what: - move snapshot, capture, input, topology, and display operations into named partial files - keep common flags, logging, and dispatch in a small operation-support file - preserve public signatures, XML documentation, and command construction --- src/LibTmux/Pane.Capture.cs | 169 +++ src/LibTmux/Pane.Display.cs | 412 +++++++ src/LibTmux/Pane.Input.cs | 248 ++++ src/LibTmux/Pane.OperationSupport.cs | 195 +++ src/LibTmux/Pane.Operations.cs | 1634 -------------------------- src/LibTmux/Pane.Snapshot.cs | 58 + src/LibTmux/Pane.Topology.cs | 594 ++++++++++ 7 files changed, 1676 insertions(+), 1634 deletions(-) create mode 100644 src/LibTmux/Pane.Capture.cs create mode 100644 src/LibTmux/Pane.Display.cs create mode 100644 src/LibTmux/Pane.Input.cs create mode 100644 src/LibTmux/Pane.OperationSupport.cs delete mode 100644 src/LibTmux/Pane.Operations.cs create mode 100644 src/LibTmux/Pane.Snapshot.cs create mode 100644 src/LibTmux/Pane.Topology.cs diff --git a/src/LibTmux/Pane.Capture.cs b/src/LibTmux/Pane.Capture.cs new file mode 100644 index 0000000..ec817c0 --- /dev/null +++ b/src/LibTmux/Pane.Capture.cs @@ -0,0 +1,169 @@ +using System.Globalization; +using System.Runtime.Versioning; +using LibTmux.Internal; + +namespace LibTmux; + +public sealed partial class Pane +{ + /// Reads the pane's contents. + /// What to capture. + /// Cancels the tmux command. + /// The captured lines. + [UnsupportedOSPlatform("windows")] + public async Task> CaptureAsync( + CapturePaneRequest? request = null, + CancellationToken cancellationToken = default) + { + List arguments = BuildCaptureArguments(["-p"], request ?? new CapturePaneRequest()); + TmuxCommandResult result = await _commandDispatcher + .ExecuteAsync(arguments, cancellationToken) + .ConfigureAwait(false); + TmuxCommandFailure.ThrowIfFailed(result, "capture-pane"); + return result.StandardOutputLines; + } + + /// Captures the pane's contents into a tmux buffer. + /// The buffer to write. + /// What to capture. + /// Cancels the tmux command. + [UnsupportedOSPlatform("windows")] + public Task CaptureToBufferAsync( + string bufferName, + CapturePaneRequest? request = null, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(bufferName); + // tmux checks for printing before buffering and takes the first it + // finds, so a buffer name only lands when nothing asks it to print. + return RunAsync( + BuildCaptureArguments(["-b", bufferName], request ?? new CapturePaneRequest()), + cancellationToken); + } + + + /// Pipes the pane's input or output through a command. + /// What to pipe. + /// Cancels the tmux command. + [UnsupportedOSPlatform("windows")] + public Task PipeAsync( + PipePaneRequest? request = null, + CancellationToken cancellationToken = default) + { + PipePaneRequest options = request ?? new PipePaneRequest(); + List arguments = BuildPipePaneArguments(options); + return RunAsync(arguments, cancellationToken); + } + + internal List BuildPipePaneArguments(PipePaneRequest request) + { + List arguments = ["pipe-pane", "-t", Target]; + if (request.OutputOnly) + { + arguments.Add("-O"); + } + + if (request.InputOnly) + { + arguments.Add("-I"); + } + + if (request.Toggle) + { + arguments.Add("-o"); + } + + if (request.Command is not null) + { + arguments.Add(request.Command); + } + + return arguments; + } + + internal List BuildCaptureArguments(List head, CapturePaneRequest options) + { + List arguments = ["capture-pane", "-t", Target, .. head]; + AddValue(arguments, "-S", Position(options.StartLine)); + AddValue(arguments, "-E", Position(options.EndLine)); + if (options.EscapeSequences) + { + arguments.Add("-e"); + } + + if (options.EscapeNonPrintable) + { + arguments.Add("-C"); + } + + if (options.JoinWrappedLines) + { + arguments.Add("-J"); + } + + if (options.PreserveTrailingSpaces) + { + arguments.Add("-N"); + } + + if (options.TrimTrailingSpaces && Requires(CaptureTrimCapability, LogTrimUnsupported)) + { + arguments.Add("-T"); + } + + if (options.AlternateScreen) + { + arguments.Add("-a"); + } + + if (options.Quiet) + { + arguments.Add("-q"); + } + + if (options.ModeScreen && Requires(CaptureModeScreenCapability, LogModeScreenUnsupported)) + { + arguments.Add("-M"); + } + + if (options.Pending) + { + arguments.Add("-P"); + } + + AddCaptureMetadata(arguments, options); + return arguments; + + static string? Position(CapturePanePosition? position) => position is null + ? null + : position.Value.LineNumber?.ToString(CultureInfo.InvariantCulture) ?? "-"; + } + + private void AddCaptureMetadata(List arguments, CapturePaneRequest options) + { + if (!options.Hyperlinks && !options.LineNumbers && !options.LineFlags) + { + return; + } + + if (!Requires(CaptureMetadataCapability, LogCaptureMetadataUnsupported)) + { + return; + } + + if (options.Hyperlinks) + { + arguments.Add("-H"); + } + + if (options.LineNumbers) + { + arguments.Add("-L"); + } + + if (options.LineFlags) + { + arguments.Add("-F"); + } + } +} diff --git a/src/LibTmux/Pane.Display.cs b/src/LibTmux/Pane.Display.cs new file mode 100644 index 0000000..e2df438 --- /dev/null +++ b/src/LibTmux/Pane.Display.cs @@ -0,0 +1,412 @@ +using System.Globalization; +using System.Runtime.Versioning; +using LibTmux.Internal; +using Microsoft.Extensions.Logging; + +namespace LibTmux; + +public sealed partial class Pane +{ + /// Builds the arguments a popup request sends. + /// + /// Popup options arrived in tmux 3.3 and the key policy in 3.6, so this + /// stays on the pane that knows which tmux is answering. + /// + internal List BuildDisplayPopupArguments(DisplayPopupRequest request) + { + List arguments = ["display-popup", "-t", Target]; + if (request.CloseExisting) + { + arguments.Add("-C"); + } + + AddValue(arguments, "-c", request.TargetClient); + if (request.CloseMode is PopupCloseMode close) + { + // tmux reads the flag twice to mean "only on success", which is the + // one place a flag is repeated deliberately. + arguments.Add("-E"); + if (close == PopupCloseMode.SuccessfulExit) + { + arguments.Add("-E"); + } + } + + AddValue(arguments, "-w", request.Width); + AddValue(arguments, "-h", request.Height); + AddValue(arguments, "-x", request.X); + AddValue(arguments, "-y", request.Y); + AddValue(arguments, "-d", StartDirectory.Resolve(request.StartDirectory)); + AddPopupOptions(arguments, request); + AddPopupKeyPolicy(arguments, request); + if (request.Command is not null) + { + arguments.Add(request.Command); + } + + return arguments; + } + + /// Builds the arguments a chooser request sends. + /// + /// tmux 3.7 dropped the activity-time sort order and rejects it by name, + /// so this stays on the pane that knows which tmux is answering. + /// + internal List BuildChooseTreeArguments(ChooseTreeRequest request) + { + List arguments = ["choose-tree", "-t", Target]; + if (request.SessionsCollapsed) + { + arguments.Add("-s"); + } + + if (request.WindowsCollapsed) + { + arguments.Add("-w"); + } + + if (request.Zoom) + { + arguments.Add("-Z"); + } + + if (request.Reverse) + { + arguments.Add("-r"); + } + + AddValue(arguments, "-F", request.Format); + AddValue(arguments, "-f", request.NativeFilter?.Value); + // tmux 3.7 dropped the activity-time order and rejects it by name, so + // sending it there fails the whole command rather than sorting badly. + // Omitting leaves the chooser's default order, which is the same thing + // a caller who never asked would have got. + ChooseTreeSort? sort = request.Sort == ChooseTreeSort.Time + && !Requires(ChooseTreeSortTimeCapability, LogChooseTreeSortTime) + ? null + : request.Sort; + AddValue(arguments, "-O", SortOrder(sort)); + + return arguments; + } + + /// Builds the arguments a copy-mode request sends. + /// + /// Paging down on entry arrived in tmux 3.5, so this stays on the pane + /// that knows which tmux is answering. + /// + internal List BuildCopyModeArguments(CopyModeRequest request) + { + List arguments = ["copy-mode", "-t", Target]; + if (request.ScrollUp) + { + arguments.Add("-u"); + } + + if (request.ExitOnBottom) + { + arguments.Add("-e"); + } + + if (request.MouseDrag) + { + arguments.Add("-M"); + } + + if (request.PageDown && Requires(CopyModePageDownCapability, LogPageDownUnsupported)) + { + arguments.Add("-d"); + } + + AddValue(arguments, "-s", request.SourcePane); + if (request.Cancel) + { + arguments.Add("-q"); + } + + return arguments; + } + + internal List BuildFindWindowArguments(FindWindowRequest request) + { + List arguments = ["find-window", "-t", Target]; + if (request.MatchContent) + { + arguments.Add("-C"); + } + + if (request.IgnoreCase) + { + arguments.Add("-i"); + } + + if (request.MatchName) + { + arguments.Add("-N"); + } + + if (request.Regex) + { + arguments.Add("-r"); + } + + if (request.MatchTitle) + { + arguments.Add("-T"); + } + + arguments.Add(request.Pattern); + + return arguments; + } + + /// Shows a popup over the client viewing this pane. + /// What the popup shows. + /// Cancels the tmux command. + /// + /// tmux waits for the popup to close before answering, so a popup whose + /// command never exits keeps this call waiting until it is canceled. + /// + [UnsupportedOSPlatform("windows")] + public Task DisplayPopupAsync( + DisplayPopupRequest? request = null, + CancellationToken cancellationToken = default) + { + DisplayPopupRequest options = request ?? new DisplayPopupRequest(); + List arguments = BuildDisplayPopupArguments(options); + return RunAsync(arguments, cancellationToken); + } + + /// Shows the pane numbers on every client. + /// How long the numbers stay up. + /// Whether pressing a number does not select a pane. + /// Cancels the tmux command. + /// + /// tmux takes no pane here: the command's target names a client, so this + /// shows the numbers wherever the server has clients. + /// + [UnsupportedOSPlatform("windows")] + public Task DisplayPaneNumbersAsync( + TimeSpan? duration = null, + bool noSelect = false, + CancellationToken cancellationToken = default) + { + List arguments = ["display-panes"]; + AddValue( + arguments, + "-d", + duration is TimeSpan window + ? ((long)window.TotalMilliseconds).ToString(CultureInfo.InvariantCulture) + : null); + if (noSelect) + { + arguments.Add("-N"); + } + + return RunAsync(arguments, cancellationToken); + } + + /// Shows a message on the client viewing this pane. + /// The message to show. + /// Cancels the tmux command. + /// The printed lines when the request asked for them, else null. + /// + /// A message with no client to show it on is not a failure, so tmux's + /// complaint is logged rather than raised. + /// + [UnsupportedOSPlatform("windows")] + public async Task?> DisplayMessageAsync( + DisplayMessageRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + Server owner = Server; + if (request.TargetClient is not null + && owner.Version is TmuxVersion version + && version < TmuxVersion.Parse("3.3a")) + { + // tmux 3.2a declares the flag without a value, so naming a client + // there would silently address a different one. + throw new TmuxVersionTooLowException( + "Naming a display-message client requires tmux 3.3a.", + TmuxVersion.Parse("3.3a"), + owner.Version ?? default); + } + + List arguments = ["display-message", "-t", Target]; + if (request.ReturnText) + { + arguments.Add("-p"); + } + + if (request.AllFormats) + { + arguments.Add("-a"); + } + + if (request.Verbose) + { + arguments.Add("-v"); + } + + if (request.NoExpand && Requires(DisplayMessageLiteralCapability, LogLiteralUnsupported)) + { + arguments.Add("-l"); + } + + if (request.Notify) + { + arguments.Add("-N"); + } + + if (request.UpdatePane + && Requires(DisplayMessageUpdatePaneCapability, LogUpdatePaneUnsupported)) + { + arguments.Add("-C"); + } + + AddValue(arguments, "-c", request.TargetClient); + AddValue( + arguments, + "-d", + request.Delay is TimeSpan delay + ? ((long)delay.TotalMilliseconds).ToString(CultureInfo.InvariantCulture) + : null); + AddValue(arguments, "-F", request.Format); + if (request.Message.Length > 0) + { + arguments.Add(request.Message); + } + + TmuxCommandResult result = await _commandDispatcher + .ExecuteAsync(arguments, cancellationToken) + .ConfigureAwait(false); + if (result.StandardErrorLines.Count > 0 + && owner.Connection?.Options.Logger is ILogger logger) + { + LogDisplayMessageRefused(logger, string.Join('\n', result.StandardErrorLines)); + } + + return request.ReturnText ? result.StandardOutputLines : null; + } + + /// Puts the pane into copy mode. + /// How to enter it. + /// Cancels the tmux command. + [UnsupportedOSPlatform("windows")] + public Task EnterCopyModeAsync( + CopyModeRequest? request = null, + CancellationToken cancellationToken = default) + { + CopyModeRequest options = request ?? new CopyModeRequest(); + List arguments = BuildCopyModeArguments(options); + return RunAsync(arguments, cancellationToken); + } + + /// Puts the pane into clock mode. + /// Cancels the tmux command. + [UnsupportedOSPlatform("windows")] + public Task EnterClockModeAsync(CancellationToken cancellationToken = default) => + RunAsync(["clock-mode", "-t", Target], cancellationToken); + + /// Puts the pane into customize mode. + /// Cancels the tmux command. + [UnsupportedOSPlatform("windows")] + public Task EnterCustomizeModeAsync(CancellationToken cancellationToken = default) => + RunAsync(["customize-mode", "-t", Target], cancellationToken); + + /// Opens the buffer chooser in this pane. + /// Cancels the tmux command. + /// tmux does nothing, successfully, when there are no buffers. + [UnsupportedOSPlatform("windows")] + public Task ChooseBufferAsync(CancellationToken cancellationToken = default) => + RunAsync(["choose-buffer", "-t", Target], cancellationToken); + + /// Opens the client chooser in this pane. + /// Cancels the tmux command. + [UnsupportedOSPlatform("windows")] + public Task ChooseClientAsync(CancellationToken cancellationToken = default) => + RunAsync(["choose-client", "-t", Target], cancellationToken); + + /// Opens the session tree chooser in this pane. + /// How the tree is shown. + /// Cancels the tmux command. + [UnsupportedOSPlatform("windows")] + public Task ChooseTreeAsync( + ChooseTreeRequest? request = null, + CancellationToken cancellationToken = default) + { + ChooseTreeRequest options = request ?? new ChooseTreeRequest(); + List arguments = BuildChooseTreeArguments(options); + return RunAsync(arguments, cancellationToken); + } + + /// Opens the window finder in this pane. + /// What to look for. + /// Cancels the tmux command. + [UnsupportedOSPlatform("windows")] + public Task FindWindowAsync( + FindWindowRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + List arguments = BuildFindWindowArguments(request); + return RunAsync(arguments, cancellationToken); + } + + private static string? SortOrder(ChooseTreeSort? sort) => sort switch + { + ChooseTreeSort.Index => "index", + ChooseTreeSort.Name => "name", + ChooseTreeSort.Time => "time", + ChooseTreeSort.Size => "size", + _ => null, + }; + + private void AddPopupOptions(List arguments, DisplayPopupRequest options) + { + bool wants = options.Title is not null + || options.BorderLines is not null + || options.Style is not null + || options.BorderStyle is not null + || options.Environment is not null + || options.NoBorder; + if (!wants || !Requires(PopupOptionsCapability, LogPopupOptionsUnsupported)) + { + return; + } + + AddValue(arguments, "-T", options.Title); + AddValue(arguments, "-b", options.BorderLines); + AddValue(arguments, "-s", options.Style); + AddValue(arguments, "-S", options.BorderStyle); + AddEnvironment(arguments, options.Environment); + if (options.NoBorder) + { + arguments.Add("-B"); + } + } + + private void AddPopupKeyPolicy(List arguments, DisplayPopupRequest options) + { + if (!options.CloseOnAnyKey && !options.NoKeys) + { + return; + } + + if (!Requires(PopupKeyPolicyCapability, LogPopupKeyPolicyUnsupported)) + { + return; + } + + if (options.CloseOnAnyKey) + { + arguments.Add("-k"); + } + + if (options.NoKeys) + { + arguments.Add("-N"); + } + } +} diff --git a/src/LibTmux/Pane.Input.cs b/src/LibTmux/Pane.Input.cs new file mode 100644 index 0000000..c70804a --- /dev/null +++ b/src/LibTmux/Pane.Input.cs @@ -0,0 +1,248 @@ +using System.Runtime.Versioning; +using LibTmux.Internal; + +namespace LibTmux; + +public sealed partial class Pane +{ + internal List BuildSendKeysArguments(SendKeysRequest request) + { + List arguments = ["send-keys", "-t", Target]; + if (request.Reset) + { + arguments.Add("-R"); + } + + if (request.ExpandFormats) + { + arguments.Add("-F"); + } + + if (request.HexKeys) + { + arguments.Add("-H"); + } + + AddClientKeys(arguments, request); + if (request.Literal) + { + arguments.Add("-l"); + } + + AddValue(arguments, "-N", request.Repeat); + if (request.CopyModeCommand is not null) + { + arguments.Add("-X"); + arguments.Add(request.CopyModeCommand); + } + else if (request.Text is not null) + { + // There is no tmux flag for keeping a line out of shell history; + // a leading space is the shell convention that does it. + arguments.Add(request.SuppressHistory ? $" {request.Text}" : request.Text); + } + + return arguments; + } + + /// Sends keys to the pane. + /// What to send. + /// Cancels the tmux commands. + /// The request sends nothing. + /// + /// Text was sent, but a requested Enter failed. Its dispatch state is + /// unknown, so the whole request must not be retried. + /// + [UnsupportedOSPlatform("windows")] + public async Task SendKeysAsync( + SendKeysRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + if (request.Text is null + && request.CopyModeCommand is null + && !request.Reset + && request.Repeat is null) + { + throw new ArgumentException("The request sends no keys.", nameof(request)); + } + + List arguments = BuildSendKeysArguments(request); + var sequence = new TmuxMutationSequence( + "The text was sent, but Enter failed. The pane may already have " + + "acted on the text; do not retry the whole request."); + await sequence.MutateAsync(() => RunAsync(arguments, cancellationToken)) + .ConfigureAwait(false); + + // Enter rides in its own command: appended to a literal send it would + // type the five characters of its name instead of pressing the key. + if (request.CopyModeCommand is null && request.Text is not null && request.Enter) + { + await sequence.MutateAsync( + () => RunAsync(["send-keys", "-t", Target, "Enter"], cancellationToken)) + .ConfigureAwait(false); + } + } + + /// Types text into the pane. + /// The text to type. + /// Whether Enter follows the text. + /// Cancels the tmux commands. + /// + /// Text was sent, but Enter failed. Its dispatch state is unknown, so the + /// whole request must not be retried. + /// + [UnsupportedOSPlatform("windows")] + public Task SendTextAsync( + string text, + bool enter = true, + CancellationToken cancellationToken = default) => + SendKeysAsync(new SendKeysRequest(text, enter, literal: true), cancellationToken); + + /// Sends the configured prefix key to the pane. + /// Whether the secondary prefix is sent. + /// Cancels the tmux command. + [UnsupportedOSPlatform("windows")] + public Task SendPrefixAsync( + bool secondary = false, + CancellationToken cancellationToken = default) + { + List arguments = ["send-prefix", "-t", Target]; + if (secondary) + { + arguments.Add("-2"); + } + + return RunAsync(arguments, cancellationToken); + } + + /// Presses Enter in the pane. + /// Cancels the tmux command. + /// A replacement handle carrying the state afterwards. + [UnsupportedOSPlatform("windows")] + public async Task EnterAsync(CancellationToken cancellationToken = default) + { + return await TmuxMutationSequence.RunAsync( + () => RunAsync(["send-keys", "-t", Target, "Enter"], cancellationToken), + () => RefreshAsync(cancellationToken)) + .ConfigureAwait(false); + } + + /// Clears the pane by running the shell's reset. + /// Cancels the tmux commands. + /// A replacement handle carrying the state afterwards. + [UnsupportedOSPlatform("windows")] + public async Task ClearAsync(CancellationToken cancellationToken = default) + { + return await TmuxMutationSequence.RunAsync( + () => SendKeysAsync(new SendKeysRequest("reset"), cancellationToken), + () => RefreshAsync(cancellationToken)) + .ConfigureAwait(false); + } + + /// Resets the pane's terminal state and drops its history. + /// Cancels the tmux commands. + /// A replacement handle carrying the state afterwards. + /// + /// Python groups the two tmux commands so nothing runs between them. This + /// dispatches them in turn, because the transport carries one command per + /// call and a trailing separator would reach tmux as data. + /// + [UnsupportedOSPlatform("windows")] + public async Task ResetAsync(CancellationToken cancellationToken = default) + { + var sequence = new TmuxMutationSequence(); + await sequence.MutateAsync( + () => RunAsync(["send-keys", "-t", Target, "-R"], cancellationToken)) + .ConfigureAwait(false); + await sequence.MutateAsync( + () => RunAsync(["clear-history", "-t", Target], cancellationToken)) + .ConfigureAwait(false); + return await sequence.ObserveAsync(() => RefreshAsync(cancellationToken)) + .ConfigureAwait(false); + } + + /// Drops the pane's scrollback history. + /// Whether stored hyperlinks are dropped too. + /// Cancels the tmux command. + [UnsupportedOSPlatform("windows")] + public Task ClearHistoryAsync( + bool resetHyperlinks = false, + CancellationToken cancellationToken = default) + { + List arguments = ["clear-history", "-t", Target]; + if (resetHyperlinks && Requires(ClearHistoryHyperlinksCapability, LogHyperlinksUnsupported)) + { + arguments.Add("-H"); + } + + return RunAsync(arguments, cancellationToken); + } + + + /// Builds the arguments a paste request sends. + /// + /// Pasting raw bytes arrived in tmux 3.7, so this stays on the pane that + /// knows which tmux is answering. + /// + internal List BuildPasteBufferArguments(PasteBufferRequest request) + { + List arguments = ["paste-buffer", "-t", Target]; + if (request.DeleteAfter) + { + arguments.Add("-d"); + } + + if (request.UseLineFeedSeparator) + { + arguments.Add("-r"); + } + + if (request.Bracketed) + { + arguments.Add("-p"); + } + + AddValue(arguments, "-b", request.Name); + AddValue(arguments, "-s", request.Separator); + if (request.RawBytes && Requires(PasteRawBytesCapability, LogRawPasteUnsupported)) + { + arguments.Add("-S"); + } + + return arguments; + } + + /// Pastes a tmux buffer into the pane. + /// Which buffer and how. + /// Cancels the tmux command. + [UnsupportedOSPlatform("windows")] + public Task PasteBufferAsync( + PasteBufferRequest? request = null, + CancellationToken cancellationToken = default) + { + PasteBufferRequest options = request ?? new PasteBufferRequest(); + List arguments = BuildPasteBufferArguments(options); + return RunAsync(arguments, cancellationToken); + } + + private void AddClientKeys(List arguments, SendKeysRequest request) + { + if (!request.KeyName && request.TargetClient is null) + { + return; + } + + if (!Requires(SendKeysClientCapability, LogClientKeysUnsupported)) + { + return; + } + + if (request.KeyName) + { + arguments.Add("-K"); + } + + AddValue(arguments, "-c", request.TargetClient); + } +} diff --git a/src/LibTmux/Pane.OperationSupport.cs b/src/LibTmux/Pane.OperationSupport.cs new file mode 100644 index 0000000..cdcace6 --- /dev/null +++ b/src/LibTmux/Pane.OperationSupport.cs @@ -0,0 +1,195 @@ +using System.Globalization; +using System.Runtime.Versioning; +using LibTmux.Internal; +using Microsoft.Extensions.Logging; + +namespace LibTmux; + +// Pane mutations return replacements when a truthful handle remains; destructive +// or re-homing operations do not. +public sealed partial class Pane +{ + private const string CaptureTrimCapability = "capture_pane_trim_trailing"; + private const string ChooseTreeSortTimeCapability = "choose_tree_sort_time"; + private const string CaptureModeScreenCapability = "capture_pane_mode_screen"; + private const string CaptureMetadataCapability = "capture_pane_3_7_metadata"; + private const string ClearHistoryHyperlinksCapability = "clear_history_hyperlinks"; + private const string CopyModePageDownCapability = "copy_mode_page_down"; + private const string DisplayMessageLiteralCapability = "display_message_literal"; + private const string DisplayMessageUpdatePaneCapability = "display_message_update_pane"; + private const string PopupOptionsCapability = "display_popup_3_3_options"; + private const string PopupKeyPolicyCapability = "display_popup_3_6_key_policy"; + private const string PasteRawBytesCapability = "paste_buffer_no_vis"; + private const string SendKeysClientCapability = "send_keys_client_keys"; + private const string SplitAppearanceCapability = "split_window_appearance"; + private const string SplitEmptyCapability = "split_window_empty"; + private const string NewPaneCommandCapability = "new_pane_command"; + + + private static void AddValue(List arguments, string flag, string? value) + { + if (!string.IsNullOrEmpty(value)) + { + arguments.Add(flag); + arguments.Add(value); + } + } + + private static void AddValue(List arguments, string flag, int? value) + { + if (value is int cells) + { + arguments.Add(flag); + arguments.Add(cells.ToString(CultureInfo.InvariantCulture)); + } + } + + private static void AddEnvironment( + List arguments, + IReadOnlyDictionary? environment) + { + if (environment is null) + { + return; + } + + foreach ((string key, string value) in environment) + { + arguments.Add("-e"); + arguments.Add($"{key}={value}"); + } + } + + private static bool Supports(Server owner, string capability) => + owner.Version is TmuxVersion version + && TmuxCapabilities.IsSupported(version, capability); + + + [LoggerMessage( + EventId = 6, + Level = LogLevel.Warning, + Message = "trailing-space trim flag omitted, tmux {TmuxVersion} does not carry it")] + private static partial void LogTrimUnsupported(ILogger logger, string? tmuxVersion); + + [LoggerMessage( + EventId = 7, + Level = LogLevel.Warning, + Message = "mode-screen capture flag omitted, tmux {TmuxVersion} does not carry it")] + private static partial void LogModeScreenUnsupported(ILogger logger, string? tmuxVersion); + + [LoggerMessage( + EventId = 8, + Level = LogLevel.Warning, + Message = "capture metadata flags omitted, tmux {TmuxVersion} does not carry them")] + private static partial void LogCaptureMetadataUnsupported(ILogger logger, string? tmuxVersion); + + [LoggerMessage( + EventId = 9, + Level = LogLevel.Warning, + Message = "hyperlink reset flag omitted, tmux {TmuxVersion} does not carry it")] + private static partial void LogHyperlinksUnsupported(ILogger logger, string? tmuxVersion); + + [LoggerMessage( + EventId = 10, + Level = LogLevel.Warning, + Message = "copy-mode page-down flag omitted, tmux {TmuxVersion} does not carry it")] + private static partial void LogPageDownUnsupported(ILogger logger, string? tmuxVersion); + + [LoggerMessage( + EventId = 11, + Level = LogLevel.Warning, + Message = "literal message flag omitted, tmux {TmuxVersion} will expand the message")] + private static partial void LogLiteralUnsupported(ILogger logger, string? tmuxVersion); + + [LoggerMessage( + EventId = 12, + Level = LogLevel.Warning, + Message = "pane redraw flag omitted, tmux {TmuxVersion} does not carry it")] + private static partial void LogUpdatePaneUnsupported(ILogger logger, string? tmuxVersion); + + [LoggerMessage( + EventId = 13, + Level = LogLevel.Warning, + Message = "popup appearance flags omitted, tmux {TmuxVersion} does not carry them")] + private static partial void LogPopupOptionsUnsupported(ILogger logger, string? tmuxVersion); + + [LoggerMessage( + EventId = 14, + Level = LogLevel.Warning, + Message = "popup key flags omitted, tmux {TmuxVersion} does not carry them")] + private static partial void LogPopupKeyPolicyUnsupported(ILogger logger, string? tmuxVersion); + + [LoggerMessage( + EventId = 15, + Level = LogLevel.Warning, + Message = "raw paste flag omitted, tmux {TmuxVersion} already pastes raw bytes")] + private static partial void LogRawPasteUnsupported(ILogger logger, string? tmuxVersion); + + [LoggerMessage( + EventId = 16, + Level = LogLevel.Warning, + Message = "send-keys client flags omitted, tmux {TmuxVersion} does not carry them")] + private static partial void LogClientKeysUnsupported(ILogger logger, string? tmuxVersion); + + [LoggerMessage( + EventId = 17, + Level = LogLevel.Warning, + Message = "split appearance flags omitted, tmux {TmuxVersion} does not carry them")] + private static partial void LogSplitAppearanceUnsupported(ILogger logger, string? tmuxVersion); + + [LoggerMessage( + EventId = 18, + Level = LogLevel.Warning, + Message = "empty split flag omitted, tmux {TmuxVersion} will spawn a shell instead")] + private static partial void LogSplitEmptyUnsupported(ILogger logger, string? tmuxVersion); + + [LoggerMessage( + EventId = 20, + Level = LogLevel.Warning, + Message = "activity-time sort order omitted, tmux {TmuxVersion} dropped it")] + private static partial void LogChooseTreeSortTime(ILogger logger, string? tmuxVersion); + + [LoggerMessage( + EventId = 19, + Level = LogLevel.Warning, + Message = "tmux refused to display the message: {TmuxError}")] + private static partial void LogDisplayMessageRefused(ILogger logger, string tmuxError); + + // The version comes from state captured when the handle materialized, so + // gating costs no extra tmux command and the call still dispatches once. + private bool Requires(string capability, Action log) + { + Server owner = Server; + if (Supports(owner, capability)) + { + return true; + } + + if (owner.Connection?.Options.Logger is ILogger logger) + { + log(logger, owner.RawVersion); + } + + return false; + } + + private string Target => _id.ToString(); + + private int ReadCapturedInt(string wireName, string relation) => + int.TryParse( + ReadSnapshot(wireName), + NumberStyles.None, + CultureInfo.InvariantCulture, + out int value) + ? value + : throw new IncompleteSnapshotException(relation, SnapshotDepth.Server); + + [UnsupportedOSPlatform("windows")] + private async Task RunAsync(List arguments, CancellationToken cancellationToken) + { + TmuxCommandResult result = await _commandDispatcher + .ExecuteAsync(arguments, cancellationToken) + .ConfigureAwait(false); + TmuxCommandFailure.ThrowIfFailed(result, arguments[0]); + } +} diff --git a/src/LibTmux/Pane.Operations.cs b/src/LibTmux/Pane.Operations.cs deleted file mode 100644 index 25efea9..0000000 --- a/src/LibTmux/Pane.Operations.cs +++ /dev/null @@ -1,1634 +0,0 @@ -using System.Globalization; -using System.Runtime.Versioning; -using LibTmux.Internal; -using Microsoft.Extensions.Logging; - -namespace LibTmux; - -// Pane mutations return replacements when a truthful handle remains; destructive -// or re-homing operations do not. -public sealed partial class Pane -{ - private const string CaptureTrimCapability = "capture_pane_trim_trailing"; - private const string ChooseTreeSortTimeCapability = "choose_tree_sort_time"; - private const string CaptureModeScreenCapability = "capture_pane_mode_screen"; - private const string CaptureMetadataCapability = "capture_pane_3_7_metadata"; - private const string ClearHistoryHyperlinksCapability = "clear_history_hyperlinks"; - private const string CopyModePageDownCapability = "copy_mode_page_down"; - private const string DisplayMessageLiteralCapability = "display_message_literal"; - private const string DisplayMessageUpdatePaneCapability = "display_message_update_pane"; - private const string PopupOptionsCapability = "display_popup_3_3_options"; - private const string PopupKeyPolicyCapability = "display_popup_3_6_key_policy"; - private const string PasteRawBytesCapability = "paste_buffer_no_vis"; - private const string SendKeysClientCapability = "send_keys_client_keys"; - private const string SplitAppearanceCapability = "split_window_appearance"; - private const string SplitEmptyCapability = "split_window_empty"; - private const string NewPaneCommandCapability = "new_pane_command"; - - /// Gets whether the pane touches the top of its window. - public bool AtTop => ReadSnapshot("pane_at_top") == "1"; - - /// Gets whether the pane touches the bottom of its window. - public bool AtBottom => ReadSnapshot("pane_at_bottom") == "1"; - - /// Gets whether the pane touches the left of its window. - public bool AtLeft => ReadSnapshot("pane_at_left") == "1"; - - /// Gets whether the pane touches the right of its window. - public bool AtRight => ReadSnapshot("pane_at_right") == "1"; - - /// Gets the pane height captured with this handle. - /// - /// The pane was resolved by identifier rather than materialized. - /// - public int Height => ReadCapturedInt("pane_height", "height"); - - /// Gets the pane width captured with this handle. - /// - /// The pane was resolved by identifier rather than materialized. - /// - public int Width => ReadCapturedInt("pane_width", "width"); - - /// Gets the index this pane holds in its window. - /// - /// The pane was resolved by identifier rather than materialized. - /// - public int Index => ReadCapturedInt("pane_index", "index"); - - /// Gets the pane title captured with this handle. - public string? Title => ReadSnapshot("pane_title"); - - /// Reads the pane's contents. - /// What to capture. - /// Cancels the tmux command. - /// The captured lines. - [UnsupportedOSPlatform("windows")] - public async Task> CaptureAsync( - CapturePaneRequest? request = null, - CancellationToken cancellationToken = default) - { - List arguments = BuildCaptureArguments(["-p"], request ?? new CapturePaneRequest()); - TmuxCommandResult result = await _commandDispatcher - .ExecuteAsync(arguments, cancellationToken) - .ConfigureAwait(false); - TmuxCommandFailure.ThrowIfFailed(result, "capture-pane"); - return result.StandardOutputLines; - } - - /// Captures the pane's contents into a tmux buffer. - /// The buffer to write. - /// What to capture. - /// Cancels the tmux command. - [UnsupportedOSPlatform("windows")] - public Task CaptureToBufferAsync( - string bufferName, - CapturePaneRequest? request = null, - CancellationToken cancellationToken = default) - { - ArgumentException.ThrowIfNullOrWhiteSpace(bufferName); - // tmux checks for printing before buffering and takes the first it - // finds, so a buffer name only lands when nothing asks it to print. - return RunAsync( - BuildCaptureArguments(["-b", bufferName], request ?? new CapturePaneRequest()), - cancellationToken); - } - - internal List BuildSendKeysArguments(SendKeysRequest request) - { - List arguments = ["send-keys", "-t", Target]; - if (request.Reset) - { - arguments.Add("-R"); - } - - if (request.ExpandFormats) - { - arguments.Add("-F"); - } - - if (request.HexKeys) - { - arguments.Add("-H"); - } - - AddClientKeys(arguments, request); - if (request.Literal) - { - arguments.Add("-l"); - } - - AddValue(arguments, "-N", request.Repeat); - if (request.CopyModeCommand is not null) - { - arguments.Add("-X"); - arguments.Add(request.CopyModeCommand); - } - else if (request.Text is not null) - { - // There is no tmux flag for keeping a line out of shell history; - // a leading space is the shell convention that does it. - arguments.Add(request.SuppressHistory ? $" {request.Text}" : request.Text); - } - - return arguments; - } - - /// Sends keys to the pane. - /// What to send. - /// Cancels the tmux commands. - /// The request sends nothing. - /// - /// Text was sent, but a requested Enter failed. Its dispatch state is - /// unknown, so the whole request must not be retried. - /// - [UnsupportedOSPlatform("windows")] - public async Task SendKeysAsync( - SendKeysRequest request, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(request); - if (request.Text is null - && request.CopyModeCommand is null - && !request.Reset - && request.Repeat is null) - { - throw new ArgumentException("The request sends no keys.", nameof(request)); - } - - List arguments = BuildSendKeysArguments(request); - var sequence = new TmuxMutationSequence( - "The text was sent, but Enter failed. The pane may already have " - + "acted on the text; do not retry the whole request."); - await sequence.MutateAsync(() => RunAsync(arguments, cancellationToken)) - .ConfigureAwait(false); - - // Enter rides in its own command: appended to a literal send it would - // type the five characters of its name instead of pressing the key. - if (request.CopyModeCommand is null && request.Text is not null && request.Enter) - { - await sequence.MutateAsync( - () => RunAsync(["send-keys", "-t", Target, "Enter"], cancellationToken)) - .ConfigureAwait(false); - } - } - - /// Types text into the pane. - /// The text to type. - /// Whether Enter follows the text. - /// Cancels the tmux commands. - /// - /// Text was sent, but Enter failed. Its dispatch state is unknown, so the - /// whole request must not be retried. - /// - [UnsupportedOSPlatform("windows")] - public Task SendTextAsync( - string text, - bool enter = true, - CancellationToken cancellationToken = default) => - SendKeysAsync(new SendKeysRequest(text, enter, literal: true), cancellationToken); - - /// Sends the configured prefix key to the pane. - /// Whether the secondary prefix is sent. - /// Cancels the tmux command. - [UnsupportedOSPlatform("windows")] - public Task SendPrefixAsync( - bool secondary = false, - CancellationToken cancellationToken = default) - { - List arguments = ["send-prefix", "-t", Target]; - if (secondary) - { - arguments.Add("-2"); - } - - return RunAsync(arguments, cancellationToken); - } - - /// Presses Enter in the pane. - /// Cancels the tmux command. - /// A replacement handle carrying the state afterwards. - [UnsupportedOSPlatform("windows")] - public async Task EnterAsync(CancellationToken cancellationToken = default) - { - return await TmuxMutationSequence.RunAsync( - () => RunAsync(["send-keys", "-t", Target, "Enter"], cancellationToken), - () => RefreshAsync(cancellationToken)) - .ConfigureAwait(false); - } - - /// Clears the pane by running the shell's reset. - /// Cancels the tmux commands. - /// A replacement handle carrying the state afterwards. - [UnsupportedOSPlatform("windows")] - public async Task ClearAsync(CancellationToken cancellationToken = default) - { - return await TmuxMutationSequence.RunAsync( - () => SendKeysAsync(new SendKeysRequest("reset"), cancellationToken), - () => RefreshAsync(cancellationToken)) - .ConfigureAwait(false); - } - - /// Resets the pane's terminal state and drops its history. - /// Cancels the tmux commands. - /// A replacement handle carrying the state afterwards. - /// - /// Python groups the two tmux commands so nothing runs between them. This - /// dispatches them in turn, because the transport carries one command per - /// call and a trailing separator would reach tmux as data. - /// - [UnsupportedOSPlatform("windows")] - public async Task ResetAsync(CancellationToken cancellationToken = default) - { - var sequence = new TmuxMutationSequence(); - await sequence.MutateAsync( - () => RunAsync(["send-keys", "-t", Target, "-R"], cancellationToken)) - .ConfigureAwait(false); - await sequence.MutateAsync( - () => RunAsync(["clear-history", "-t", Target], cancellationToken)) - .ConfigureAwait(false); - return await sequence.ObserveAsync(() => RefreshAsync(cancellationToken)) - .ConfigureAwait(false); - } - - /// Drops the pane's scrollback history. - /// Whether stored hyperlinks are dropped too. - /// Cancels the tmux command. - [UnsupportedOSPlatform("windows")] - public Task ClearHistoryAsync( - bool resetHyperlinks = false, - CancellationToken cancellationToken = default) - { - List arguments = ["clear-history", "-t", Target]; - if (resetHyperlinks && Requires(ClearHistoryHyperlinksCapability, LogHyperlinksUnsupported)) - { - arguments.Add("-H"); - } - - return RunAsync(arguments, cancellationToken); - } - - /// Pipes the pane's input or output through a command. - /// What to pipe. - /// Cancels the tmux command. - [UnsupportedOSPlatform("windows")] - public Task PipeAsync( - PipePaneRequest? request = null, - CancellationToken cancellationToken = default) - { - PipePaneRequest options = request ?? new PipePaneRequest(); - List arguments = BuildPipePaneArguments(options); - return RunAsync(arguments, cancellationToken); - } - - /// Builds the arguments a popup request sends. - /// - /// Popup options arrived in tmux 3.3 and the key policy in 3.6, so this - /// stays on the pane that knows which tmux is answering. - /// - internal List BuildDisplayPopupArguments(DisplayPopupRequest request) - { - List arguments = ["display-popup", "-t", Target]; - if (request.CloseExisting) - { - arguments.Add("-C"); - } - - AddValue(arguments, "-c", request.TargetClient); - if (request.CloseMode is PopupCloseMode close) - { - // tmux reads the flag twice to mean "only on success", which is the - // one place a flag is repeated deliberately. - arguments.Add("-E"); - if (close == PopupCloseMode.SuccessfulExit) - { - arguments.Add("-E"); - } - } - - AddValue(arguments, "-w", request.Width); - AddValue(arguments, "-h", request.Height); - AddValue(arguments, "-x", request.X); - AddValue(arguments, "-y", request.Y); - AddValue(arguments, "-d", StartDirectory.Resolve(request.StartDirectory)); - AddPopupOptions(arguments, request); - AddPopupKeyPolicy(arguments, request); - if (request.Command is not null) - { - arguments.Add(request.Command); - } - - return arguments; - } - - internal List BuildRespawnPaneArguments(RespawnRequest request) - { - List arguments = ["respawn-pane", "-t", Target]; - if (request.KillExistingProcess) - { - arguments.Add("-k"); - } - - AddValue(arguments, "-c", StartDirectory.Resolve(request.StartDirectory)); - AddEnvironment(arguments, request.Environment); - if (request.Command is not null) - { - arguments.Add(request.Command); - } - - return arguments; - } - - /// Builds the arguments a floating-pane request sends. - /// - /// The command itself arrived in tmux 3.7, so the refusal belongs here - /// rather than beside the dispatch: a chained request that skipped it - /// would send a command older servers do not have. - /// - /// tmux is older than 3.7. - internal List BuildNewPaneArguments(NewPaneRequest request) - { - Server owner = Server; - if (!Supports(owner, NewPaneCommandCapability)) - { - throw new TmuxVersionTooLowException( - "new-pane requires tmux 3.7.", - TmuxVersion.Parse("3.7"), - owner.Version ?? default); - } - - List arguments = - [ - "new-pane", - "-P", - "-F", - "#{pane_id}", - "-t", - request.Target ?? Target, - ]; - if (!request.Attach) - { - arguments.Add("-d"); - } - - AddValue(arguments, "-x", request.Width); - AddValue(arguments, "-y", request.Height); - AddValue(arguments, "-X", request.X); - AddValue(arguments, "-Y", request.Y); - if (request.Zoom) - { - arguments.Add("-Z"); - } - - AddValue(arguments, "-c", StartDirectory.Resolve(request.StartDirectory)); - AddEnvironment(arguments, request.Environment); - if (request.Empty) - { - arguments.Add("-E"); - } - - AddValue(arguments, "-s", request.Style); - AddValue(arguments, "-S", request.ActiveBorderStyle); - AddValue(arguments, "-R", request.InactiveBorderStyle); - AddValue(arguments, "-m", request.Message); - if (request.KeepOpen) - { - arguments.Add("-k"); - } - - if (request.Command is not null) - { - arguments.Add(request.Command); - } - - return arguments; - } - - /// Builds the arguments a split request sends. - /// - /// Splitting into an empty pane and the appearance flags both arrived in - /// tmux 3.7, so this stays on the pane that knows which tmux is - /// answering. It keeps the identifier-printing flags, so a chained split - /// can say which pane it made. - /// - internal List BuildSplitArguments(SplitPaneRequest request) - { - List arguments = - [ - "split-window", - "-P", - "-F", - "#{pane_id}", - "-t", - // A pane identifier names a pane on its own; composing one with a - // sub-target would ask tmux for a window that does not exist. - request.Target ?? Target, - ]; - foreach (string flag in CommandFlagCatalog.GetPaneDirectionFlags( - request.Direction ?? PaneDirection.Below)) - { - arguments.Add(flag); - } - - // tmux 3.4 misreads the percentage flag, so a percentage rides the size - // flag instead, which every supported version accepts. - AddValue( - arguments, - "-l", - request.Percentage is int share - ? string.Create(CultureInfo.InvariantCulture, $"{share}%") - : request.Size); - if (request.FullWindow) - { - arguments.Add("-f"); - } - - if (request.Zoom) - { - arguments.Add("-Z"); - } - - if (!request.Attach) - { - arguments.Add("-d"); - } - - AddValue(arguments, "-c", StartDirectory.Resolve(request.StartDirectory)); - AddEnvironment(arguments, request.Environment); - AddSplitAppearance(arguments, request); - if (request.Command is not null) - { - arguments.Add(request.Command); - } - - return arguments; - } - - /// Builds the arguments a chooser request sends. - /// - /// tmux 3.7 dropped the activity-time sort order and rejects it by name, - /// so this stays on the pane that knows which tmux is answering. - /// - internal List BuildChooseTreeArguments(ChooseTreeRequest request) - { - List arguments = ["choose-tree", "-t", Target]; - if (request.SessionsCollapsed) - { - arguments.Add("-s"); - } - - if (request.WindowsCollapsed) - { - arguments.Add("-w"); - } - - if (request.Zoom) - { - arguments.Add("-Z"); - } - - if (request.Reverse) - { - arguments.Add("-r"); - } - - AddValue(arguments, "-F", request.Format); - AddValue(arguments, "-f", request.NativeFilter?.Value); - // tmux 3.7 dropped the activity-time order and rejects it by name, so - // sending it there fails the whole command rather than sorting badly. - // Omitting leaves the chooser's default order, which is the same thing - // a caller who never asked would have got. - ChooseTreeSort? sort = request.Sort == ChooseTreeSort.Time - && !Requires(ChooseTreeSortTimeCapability, LogChooseTreeSortTime) - ? null - : request.Sort; - AddValue(arguments, "-O", SortOrder(sort)); - - return arguments; - } - - /// Builds the arguments a copy-mode request sends. - /// - /// Paging down on entry arrived in tmux 3.5, so this stays on the pane - /// that knows which tmux is answering. - /// - internal List BuildCopyModeArguments(CopyModeRequest request) - { - List arguments = ["copy-mode", "-t", Target]; - if (request.ScrollUp) - { - arguments.Add("-u"); - } - - if (request.ExitOnBottom) - { - arguments.Add("-e"); - } - - if (request.MouseDrag) - { - arguments.Add("-M"); - } - - if (request.PageDown && Requires(CopyModePageDownCapability, LogPageDownUnsupported)) - { - arguments.Add("-d"); - } - - AddValue(arguments, "-s", request.SourcePane); - if (request.Cancel) - { - arguments.Add("-q"); - } - - return arguments; - } - - /// Builds the arguments a paste request sends. - /// - /// Pasting raw bytes arrived in tmux 3.7, so this stays on the pane that - /// knows which tmux is answering. - /// - internal List BuildPasteBufferArguments(PasteBufferRequest request) - { - List arguments = ["paste-buffer", "-t", Target]; - if (request.DeleteAfter) - { - arguments.Add("-d"); - } - - if (request.UseLineFeedSeparator) - { - arguments.Add("-r"); - } - - if (request.Bracketed) - { - arguments.Add("-p"); - } - - AddValue(arguments, "-b", request.Name); - AddValue(arguments, "-s", request.Separator); - if (request.RawBytes && Requires(PasteRawBytesCapability, LogRawPasteUnsupported)) - { - arguments.Add("-S"); - } - - return arguments; - } - - /// Pastes a tmux buffer into the pane. - /// Which buffer and how. - /// Cancels the tmux command. - [UnsupportedOSPlatform("windows")] - public Task PasteBufferAsync( - PasteBufferRequest? request = null, - CancellationToken cancellationToken = default) - { - PasteBufferRequest options = request ?? new PasteBufferRequest(); - List arguments = BuildPasteBufferArguments(options); - return RunAsync(arguments, cancellationToken); - } - - /// Moves this pane out into a window of its own. - /// The new window's name. - /// Whether the new window is left unselected. - /// Cancels the tmux commands. - /// The window the pane now lives in. - [UnsupportedOSPlatform("windows")] - public async Task BreakAsync( - string? windowName = null, - bool detach = true, - CancellationToken cancellationToken = default) - { - Server owner = Server; - // tmux 3.7 dereferences a null window name here and takes the whole - // server with it, so that one version always gets a name: the caller's - // if there is one, otherwise a placeholder that is renamed away after. - bool needsPlaceholder = Supports(owner, "break_pane_3_7_workaround"); - List arguments = ["break-pane", "-P", "-F", "#{window_id}"]; - if (detach) - { - arguments.Add("-d"); - } - - if (windowName is not null) - { - arguments.Add("-n"); - arguments.Add(windowName); - } - else if (needsPlaceholder) - { - arguments.Add("-n"); - arguments.Add("libtmux"); - } - - // The pane goes in -s: break-pane's -t names where the window lands. - arguments.Add("-s"); - arguments.Add(Target); - - var sequence = new TmuxMutationSequence(); - TmuxCommandResult result = await sequence.MutateAsync( - () => _commandDispatcher.ExecuteAsync(arguments, cancellationToken), - static value => TmuxCommandFailure.ThrowIfFailed(value, "break-pane")) - .ConfigureAwait(false); - WindowId created = sequence.Observe(() => - result.StandardOutputLines.Count > 0 - && WindowId.TryParse(result.StandardOutputLines[0], out WindowId parsed) - ? parsed - : throw new InvalidDataException("tmux reported no new window identifier.")); - - // On that same version tmux keeps the name it was given only some of - // the time, so a caller who asked for one gets it set explicitly. - if (windowName is not null && needsPlaceholder) - { - await sequence.MutateAsync( - () => RunAsync( - ["rename-window", "-t", created.ToString(), windowName], - cancellationToken)) - .ConfigureAwait(false); - } - - IReadOnlyList windows = await sequence - .ObserveAsync(() => owner.GetWindowsAsync(cancellationToken)) - .ConfigureAwait(false); - return sequence.Observe(() => - windows.FirstOrDefault(window => window.Id == created) - ?? throw new TmuxObjectNotFoundException( - $"tmux did not report the created window '{created}'.", - created.ToString())); - } - - /// Splits this pane. - /// How to split. - /// Cancels the tmux command. - /// The created pane. - [UnsupportedOSPlatform("windows")] - public async Task SplitAsync( - SplitPaneRequest? request = null, - CancellationToken cancellationToken = default) - { - SplitPaneRequest options = request ?? new SplitPaneRequest(); - List arguments = BuildSplitArguments(options); - - return await CreatePaneFromAsync(arguments, "split-window", cancellationToken) - .ConfigureAwait(false); - } - - /// Creates a floating pane against this one. - /// The pane to create. - /// Cancels the tmux command. - /// The created pane. - /// - /// The server predates tmux 3.7, which introduced the command. - /// - [UnsupportedOSPlatform("windows")] - public async Task CreatePaneAsync( - NewPaneRequest? request = null, - CancellationToken cancellationToken = default) - { - NewPaneRequest options = request ?? new NewPaneRequest(); - List arguments = BuildNewPaneArguments(options); - - return await CreatePaneFromAsync(arguments, "new-pane", cancellationToken) - .ConfigureAwait(false); - } - - /// Joins this pane into another window. - /// Where the pane lands. - /// Cancels the tmux command. - [UnsupportedOSPlatform("windows")] - public Task JoinAsync( - MovePaneRequest request, - CancellationToken cancellationToken = default) => - RunAsync(BuildRehomeArguments("join-pane", request), cancellationToken); - - /// Moves this pane to another position. - /// Where the pane lands. - /// Cancels the tmux command. - [UnsupportedOSPlatform("windows")] - public Task MoveAsync( - MovePaneRequest request, - CancellationToken cancellationToken = default) => - RunAsync(BuildRehomeArguments("move-pane", request), cancellationToken); - - /// Swaps this pane with another. - /// Which pane to swap with. - /// Cancels the tmux command. - [UnsupportedOSPlatform("windows")] - public Task SwapAsync( - SwapPaneRequest request, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(request); - List arguments = BuildSwapPaneArguments(request); - return RunAsync(arguments, cancellationToken); - } - - /// Stops this pane. - /// Whether every other pane in the window is stopped instead. - /// Cancels the tmux command. - [UnsupportedOSPlatform("windows")] - public Task KillAsync(bool allExcept = false, CancellationToken cancellationToken = default) - { - List arguments = ["kill-pane"]; - if (allExcept) - { - arguments.Add("-a"); - } - - arguments.Add("-t"); - arguments.Add(Target); - return RunAsync(arguments, cancellationToken); - } - - /// Restarts the command running in this pane. - /// What to respawn, or null to reuse the original. - /// Cancels the tmux command. - /// - /// tmux refuses to respawn a pane that is still running unless the request - /// kills it first. - /// - [UnsupportedOSPlatform("windows")] - public Task RespawnAsync( - RespawnRequest? request = null, - CancellationToken cancellationToken = default) - { - RespawnRequest options = request ?? new RespawnRequest(); - List arguments = BuildRespawnPaneArguments(options); - return RunAsync(arguments, cancellationToken); - } - - /// Resizes this pane. - /// The size to apply. - /// Cancels the tmux command. - /// A replacement handle carrying the new size. - /// - /// tmux clamps a size that does not fit rather than refusing it, so the - /// result may differ from what was asked for. - /// - [UnsupportedOSPlatform("windows")] - public async Task ResizeAsync( - ResizePaneRequest request, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(request); - List arguments = BuildResizePaneArguments(request); - - return await TmuxMutationSequence.RunAsync( - () => RunAsync(arguments, cancellationToken), - () => RefreshAsync(cancellationToken)) - .ConfigureAwait(false); - } - - /// Sets this pane's width. - /// The width in cells. - /// Cancels the tmux command. - /// A replacement handle carrying the new size. - [UnsupportedOSPlatform("windows")] - public Task SetWidthAsync(int width, CancellationToken cancellationToken = default) => - ResizeAsync( - new ResizePaneRequest(width: width.ToString(CultureInfo.InvariantCulture)), - cancellationToken); - - /// Sets this pane's height. - /// The height in cells. - /// Cancels the tmux command. - /// A replacement handle carrying the new size. - [UnsupportedOSPlatform("windows")] - public Task SetHeightAsync(int height, CancellationToken cancellationToken = default) => - ResizeAsync( - new ResizePaneRequest(height: height.ToString(CultureInfo.InvariantCulture)), - cancellationToken); - - /// Sets this pane's title. - /// The new title. - /// Cancels the tmux command. - /// A replacement handle carrying the new title. - [UnsupportedOSPlatform("windows")] - public async Task SetTitleAsync( - string title, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(title); - return await TmuxMutationSequence.RunAsync( - () => RunAsync(["select-pane", "-t", Target, "-T", title], cancellationToken), - () => RefreshAsync(cancellationToken)) - .ConfigureAwait(false); - } - - /// Selects this pane. - /// How to select it. - /// Cancels the tmux command. - /// A replacement handle carrying the state afterwards. - [UnsupportedOSPlatform("windows")] - public async Task SelectAsync( - SelectPaneRequest? request = null, - CancellationToken cancellationToken = default) - { - SelectPaneRequest options = request ?? new SelectPaneRequest(); - List arguments = BuildSelectPaneArguments(options); - - return await TmuxMutationSequence.RunAsync( - () => RunAsync(arguments, cancellationToken), - () => RefreshAsync(cancellationToken)) - .ConfigureAwait(false); - } - - internal List BuildPipePaneArguments(PipePaneRequest request) - { - List arguments = ["pipe-pane", "-t", Target]; - if (request.OutputOnly) - { - arguments.Add("-O"); - } - - if (request.InputOnly) - { - arguments.Add("-I"); - } - - if (request.Toggle) - { - arguments.Add("-o"); - } - - if (request.Command is not null) - { - arguments.Add(request.Command); - } - - return arguments; - } - - internal List BuildSwapPaneArguments(SwapPaneRequest request) - { - List arguments = ["swap-pane", "-t", Target]; - if (request.Detach) - { - arguments.Add("-d"); - } - - if (request.Direction is PaneSwapDirection direction) - { - arguments.Add(direction == PaneSwapDirection.Up ? "-U" : "-D"); - } - - if (request.KeepZoom) - { - arguments.Add("-Z"); - } - - AddValue(arguments, "-s", request.Target); - - return arguments; - } - - internal List BuildFindWindowArguments(FindWindowRequest request) - { - List arguments = ["find-window", "-t", Target]; - if (request.MatchContent) - { - arguments.Add("-C"); - } - - if (request.IgnoreCase) - { - arguments.Add("-i"); - } - - if (request.MatchName) - { - arguments.Add("-N"); - } - - if (request.Regex) - { - arguments.Add("-r"); - } - - if (request.MatchTitle) - { - arguments.Add("-T"); - } - - arguments.Add(request.Pattern); - - return arguments; - } - - internal List BuildResizePaneArguments(ResizePaneRequest request) - { - List arguments = ["resize-pane", "-t", Target]; - if (request.Direction is ResizeDirection direction) - { - arguments.Add(CommandFlagCatalog.GetResizeDirectionFlag(direction)); - } - - AddValue(arguments, "-x", request.Width); - AddValue(arguments, "-y", request.Height); - if (request.Zoom) - { - arguments.Add("-Z"); - } - - if (request.Mouse) - { - arguments.Add("-M"); - } - - if (request.TrimBelow) - { - arguments.Add("-T"); - } - - // tmux takes the adjustment as the trailing positional; as a flag value - // it would be read as a second argument and refused. - if (request.Adjustment is int adjustment) - { - arguments.Add(adjustment.ToString(CultureInfo.InvariantCulture)); - } - - return arguments; - } - - internal List BuildSelectPaneArguments(SelectPaneRequest options) - { - List arguments = ["select-pane", "-t", Target]; - string? directionFlag = options.Direction switch - { - PaneSelectDirection.Up => "-U", - PaneSelectDirection.Down => "-D", - PaneSelectDirection.Left => "-L", - PaneSelectDirection.Right => "-R", - PaneSelectDirection.Last => "-l", - _ => null, - }; - if (directionFlag is not null) - { - arguments.Add(directionFlag); - } - - // Asking for the last pane by direction and by flag is the same - // request, and tmux only needs telling once. - if (options.Last && directionFlag != "-l") - { - arguments.Add("-l"); - } - - if (options.KeepZoom) - { - arguments.Add("-Z"); - } - - if (options.Mark is bool mark) - { - arguments.Add(mark ? "-m" : "-M"); - } - - if (options.InputEnabled is bool input) - { - arguments.Add(input ? "-e" : "-d"); - } - - return arguments; - } - - /// Re-reads this pane from tmux. - /// Cancels the tmux command. - /// A replacement handle carrying current state. - [UnsupportedOSPlatform("windows")] - public async Task RefreshAsync(CancellationToken cancellationToken = default) - { - // Listing by -t fails loudly on a pane that is already gone, which - // would report a command failure where the pane is simply missing. - Server owner = Server; - IReadOnlyList> rows = await RelationReader - .ListAsync(owner, "list-panes", ["-a"], cancellationToken) - .ConfigureAwait(false); - return rows.Select(row => RelationReader.ToPane(owner, row)) - .FirstOrDefault(pane => pane.Id == _id) - ?? throw new TmuxObjectNotFoundException( - $"tmux no longer has pane '{_id}'.", - _id.ToString()); - } - - /// Shows a popup over the client viewing this pane. - /// What the popup shows. - /// Cancels the tmux command. - /// - /// tmux waits for the popup to close before answering, so a popup whose - /// command never exits keeps this call waiting until it is canceled. - /// - [UnsupportedOSPlatform("windows")] - public Task DisplayPopupAsync( - DisplayPopupRequest? request = null, - CancellationToken cancellationToken = default) - { - DisplayPopupRequest options = request ?? new DisplayPopupRequest(); - List arguments = BuildDisplayPopupArguments(options); - return RunAsync(arguments, cancellationToken); - } - - /// Shows the pane numbers on every client. - /// How long the numbers stay up. - /// Whether pressing a number does not select a pane. - /// Cancels the tmux command. - /// - /// tmux takes no pane here: the command's target names a client, so this - /// shows the numbers wherever the server has clients. - /// - [UnsupportedOSPlatform("windows")] - public Task DisplayPaneNumbersAsync( - TimeSpan? duration = null, - bool noSelect = false, - CancellationToken cancellationToken = default) - { - List arguments = ["display-panes"]; - AddValue( - arguments, - "-d", - duration is TimeSpan window - ? ((long)window.TotalMilliseconds).ToString(CultureInfo.InvariantCulture) - : null); - if (noSelect) - { - arguments.Add("-N"); - } - - return RunAsync(arguments, cancellationToken); - } - - /// Shows a message on the client viewing this pane. - /// The message to show. - /// Cancels the tmux command. - /// The printed lines when the request asked for them, else null. - /// - /// A message with no client to show it on is not a failure, so tmux's - /// complaint is logged rather than raised. - /// - [UnsupportedOSPlatform("windows")] - public async Task?> DisplayMessageAsync( - DisplayMessageRequest request, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(request); - Server owner = Server; - if (request.TargetClient is not null - && owner.Version is TmuxVersion version - && version < TmuxVersion.Parse("3.3a")) - { - // tmux 3.2a declares the flag without a value, so naming a client - // there would silently address a different one. - throw new TmuxVersionTooLowException( - "Naming a display-message client requires tmux 3.3a.", - TmuxVersion.Parse("3.3a"), - owner.Version ?? default); - } - - List arguments = ["display-message", "-t", Target]; - if (request.ReturnText) - { - arguments.Add("-p"); - } - - if (request.AllFormats) - { - arguments.Add("-a"); - } - - if (request.Verbose) - { - arguments.Add("-v"); - } - - if (request.NoExpand && Requires(DisplayMessageLiteralCapability, LogLiteralUnsupported)) - { - arguments.Add("-l"); - } - - if (request.Notify) - { - arguments.Add("-N"); - } - - if (request.UpdatePane - && Requires(DisplayMessageUpdatePaneCapability, LogUpdatePaneUnsupported)) - { - arguments.Add("-C"); - } - - AddValue(arguments, "-c", request.TargetClient); - AddValue( - arguments, - "-d", - request.Delay is TimeSpan delay - ? ((long)delay.TotalMilliseconds).ToString(CultureInfo.InvariantCulture) - : null); - AddValue(arguments, "-F", request.Format); - if (request.Message.Length > 0) - { - arguments.Add(request.Message); - } - - TmuxCommandResult result = await _commandDispatcher - .ExecuteAsync(arguments, cancellationToken) - .ConfigureAwait(false); - if (result.StandardErrorLines.Count > 0 - && owner.Connection?.Options.Logger is ILogger logger) - { - LogDisplayMessageRefused(logger, string.Join('\n', result.StandardErrorLines)); - } - - return request.ReturnText ? result.StandardOutputLines : null; - } - - /// Puts the pane into copy mode. - /// How to enter it. - /// Cancels the tmux command. - [UnsupportedOSPlatform("windows")] - public Task EnterCopyModeAsync( - CopyModeRequest? request = null, - CancellationToken cancellationToken = default) - { - CopyModeRequest options = request ?? new CopyModeRequest(); - List arguments = BuildCopyModeArguments(options); - return RunAsync(arguments, cancellationToken); - } - - /// Puts the pane into clock mode. - /// Cancels the tmux command. - [UnsupportedOSPlatform("windows")] - public Task EnterClockModeAsync(CancellationToken cancellationToken = default) => - RunAsync(["clock-mode", "-t", Target], cancellationToken); - - /// Puts the pane into customize mode. - /// Cancels the tmux command. - [UnsupportedOSPlatform("windows")] - public Task EnterCustomizeModeAsync(CancellationToken cancellationToken = default) => - RunAsync(["customize-mode", "-t", Target], cancellationToken); - - /// Opens the buffer chooser in this pane. - /// Cancels the tmux command. - /// tmux does nothing, successfully, when there are no buffers. - [UnsupportedOSPlatform("windows")] - public Task ChooseBufferAsync(CancellationToken cancellationToken = default) => - RunAsync(["choose-buffer", "-t", Target], cancellationToken); - - /// Opens the client chooser in this pane. - /// Cancels the tmux command. - [UnsupportedOSPlatform("windows")] - public Task ChooseClientAsync(CancellationToken cancellationToken = default) => - RunAsync(["choose-client", "-t", Target], cancellationToken); - - /// Opens the session tree chooser in this pane. - /// How the tree is shown. - /// Cancels the tmux command. - [UnsupportedOSPlatform("windows")] - public Task ChooseTreeAsync( - ChooseTreeRequest? request = null, - CancellationToken cancellationToken = default) - { - ChooseTreeRequest options = request ?? new ChooseTreeRequest(); - List arguments = BuildChooseTreeArguments(options); - return RunAsync(arguments, cancellationToken); - } - - /// Opens the window finder in this pane. - /// What to look for. - /// Cancels the tmux command. - [UnsupportedOSPlatform("windows")] - public Task FindWindowAsync( - FindWindowRequest request, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(request); - List arguments = BuildFindWindowArguments(request); - return RunAsync(arguments, cancellationToken); - } - - private static void AddValue(List arguments, string flag, string? value) - { - if (!string.IsNullOrEmpty(value)) - { - arguments.Add(flag); - arguments.Add(value); - } - } - - private static void AddValue(List arguments, string flag, int? value) - { - if (value is int cells) - { - arguments.Add(flag); - arguments.Add(cells.ToString(CultureInfo.InvariantCulture)); - } - } - - private static void AddEnvironment( - List arguments, - IReadOnlyDictionary? environment) - { - if (environment is null) - { - return; - } - - foreach ((string key, string value) in environment) - { - arguments.Add("-e"); - arguments.Add($"{key}={value}"); - } - } - - private static bool Supports(Server owner, string capability) => - owner.Version is TmuxVersion version - && TmuxCapabilities.IsSupported(version, capability); - - private static string? SortOrder(ChooseTreeSort? sort) => sort switch - { - ChooseTreeSort.Index => "index", - ChooseTreeSort.Name => "name", - ChooseTreeSort.Time => "time", - ChooseTreeSort.Size => "size", - _ => null, - }; - - [LoggerMessage( - EventId = 6, - Level = LogLevel.Warning, - Message = "trailing-space trim flag omitted, tmux {TmuxVersion} does not carry it")] - private static partial void LogTrimUnsupported(ILogger logger, string? tmuxVersion); - - [LoggerMessage( - EventId = 7, - Level = LogLevel.Warning, - Message = "mode-screen capture flag omitted, tmux {TmuxVersion} does not carry it")] - private static partial void LogModeScreenUnsupported(ILogger logger, string? tmuxVersion); - - [LoggerMessage( - EventId = 8, - Level = LogLevel.Warning, - Message = "capture metadata flags omitted, tmux {TmuxVersion} does not carry them")] - private static partial void LogCaptureMetadataUnsupported(ILogger logger, string? tmuxVersion); - - [LoggerMessage( - EventId = 9, - Level = LogLevel.Warning, - Message = "hyperlink reset flag omitted, tmux {TmuxVersion} does not carry it")] - private static partial void LogHyperlinksUnsupported(ILogger logger, string? tmuxVersion); - - [LoggerMessage( - EventId = 10, - Level = LogLevel.Warning, - Message = "copy-mode page-down flag omitted, tmux {TmuxVersion} does not carry it")] - private static partial void LogPageDownUnsupported(ILogger logger, string? tmuxVersion); - - [LoggerMessage( - EventId = 11, - Level = LogLevel.Warning, - Message = "literal message flag omitted, tmux {TmuxVersion} will expand the message")] - private static partial void LogLiteralUnsupported(ILogger logger, string? tmuxVersion); - - [LoggerMessage( - EventId = 12, - Level = LogLevel.Warning, - Message = "pane redraw flag omitted, tmux {TmuxVersion} does not carry it")] - private static partial void LogUpdatePaneUnsupported(ILogger logger, string? tmuxVersion); - - [LoggerMessage( - EventId = 13, - Level = LogLevel.Warning, - Message = "popup appearance flags omitted, tmux {TmuxVersion} does not carry them")] - private static partial void LogPopupOptionsUnsupported(ILogger logger, string? tmuxVersion); - - [LoggerMessage( - EventId = 14, - Level = LogLevel.Warning, - Message = "popup key flags omitted, tmux {TmuxVersion} does not carry them")] - private static partial void LogPopupKeyPolicyUnsupported(ILogger logger, string? tmuxVersion); - - [LoggerMessage( - EventId = 15, - Level = LogLevel.Warning, - Message = "raw paste flag omitted, tmux {TmuxVersion} already pastes raw bytes")] - private static partial void LogRawPasteUnsupported(ILogger logger, string? tmuxVersion); - - [LoggerMessage( - EventId = 16, - Level = LogLevel.Warning, - Message = "send-keys client flags omitted, tmux {TmuxVersion} does not carry them")] - private static partial void LogClientKeysUnsupported(ILogger logger, string? tmuxVersion); - - [LoggerMessage( - EventId = 17, - Level = LogLevel.Warning, - Message = "split appearance flags omitted, tmux {TmuxVersion} does not carry them")] - private static partial void LogSplitAppearanceUnsupported(ILogger logger, string? tmuxVersion); - - [LoggerMessage( - EventId = 18, - Level = LogLevel.Warning, - Message = "empty split flag omitted, tmux {TmuxVersion} will spawn a shell instead")] - private static partial void LogSplitEmptyUnsupported(ILogger logger, string? tmuxVersion); - - [LoggerMessage( - EventId = 20, - Level = LogLevel.Warning, - Message = "activity-time sort order omitted, tmux {TmuxVersion} dropped it")] - private static partial void LogChooseTreeSortTime(ILogger logger, string? tmuxVersion); - - [LoggerMessage( - EventId = 19, - Level = LogLevel.Warning, - Message = "tmux refused to display the message: {TmuxError}")] - private static partial void LogDisplayMessageRefused(ILogger logger, string tmuxError); - - // The version comes from state captured when the handle materialized, so - // gating costs no extra tmux command and the call still dispatches once. - private bool Requires(string capability, Action log) - { - Server owner = Server; - if (Supports(owner, capability)) - { - return true; - } - - if (owner.Connection?.Options.Logger is ILogger logger) - { - log(logger, owner.RawVersion); - } - - return false; - } - - internal List BuildCaptureArguments(List head, CapturePaneRequest options) - { - List arguments = ["capture-pane", "-t", Target, .. head]; - AddValue(arguments, "-S", Position(options.StartLine)); - AddValue(arguments, "-E", Position(options.EndLine)); - if (options.EscapeSequences) - { - arguments.Add("-e"); - } - - if (options.EscapeNonPrintable) - { - arguments.Add("-C"); - } - - if (options.JoinWrappedLines) - { - arguments.Add("-J"); - } - - if (options.PreserveTrailingSpaces) - { - arguments.Add("-N"); - } - - if (options.TrimTrailingSpaces && Requires(CaptureTrimCapability, LogTrimUnsupported)) - { - arguments.Add("-T"); - } - - if (options.AlternateScreen) - { - arguments.Add("-a"); - } - - if (options.Quiet) - { - arguments.Add("-q"); - } - - if (options.ModeScreen && Requires(CaptureModeScreenCapability, LogModeScreenUnsupported)) - { - arguments.Add("-M"); - } - - if (options.Pending) - { - arguments.Add("-P"); - } - - AddCaptureMetadata(arguments, options); - return arguments; - - static string? Position(CapturePanePosition? position) => position is null - ? null - : position.Value.LineNumber?.ToString(CultureInfo.InvariantCulture) ?? "-"; - } - - private void AddCaptureMetadata(List arguments, CapturePaneRequest options) - { - if (!options.Hyperlinks && !options.LineNumbers && !options.LineFlags) - { - return; - } - - if (!Requires(CaptureMetadataCapability, LogCaptureMetadataUnsupported)) - { - return; - } - - if (options.Hyperlinks) - { - arguments.Add("-H"); - } - - if (options.LineNumbers) - { - arguments.Add("-L"); - } - - if (options.LineFlags) - { - arguments.Add("-F"); - } - } - - private void AddClientKeys(List arguments, SendKeysRequest request) - { - if (!request.KeyName && request.TargetClient is null) - { - return; - } - - if (!Requires(SendKeysClientCapability, LogClientKeysUnsupported)) - { - return; - } - - if (request.KeyName) - { - arguments.Add("-K"); - } - - AddValue(arguments, "-c", request.TargetClient); - } - - private void AddSplitAppearance(List arguments, SplitPaneRequest options) - { - if (options.Empty) - { - if (Requires(SplitEmptyCapability, LogSplitEmptyUnsupported)) - { - arguments.Add("-E"); - } - } - - bool wantsAppearance = options.Style is not null - || options.ActiveBorderStyle is not null - || options.InactiveBorderStyle is not null - || options.Message is not null - || options.KeepOpen; - if (!wantsAppearance || !Requires(SplitAppearanceCapability, LogSplitAppearanceUnsupported)) - { - return; - } - - AddValue(arguments, "-s", options.Style); - AddValue(arguments, "-S", options.ActiveBorderStyle); - AddValue(arguments, "-R", options.InactiveBorderStyle); - AddValue(arguments, "-m", options.Message); - if (options.KeepOpen) - { - arguments.Add("-k"); - } - } - - private void AddPopupOptions(List arguments, DisplayPopupRequest options) - { - bool wants = options.Title is not null - || options.BorderLines is not null - || options.Style is not null - || options.BorderStyle is not null - || options.Environment is not null - || options.NoBorder; - if (!wants || !Requires(PopupOptionsCapability, LogPopupOptionsUnsupported)) - { - return; - } - - AddValue(arguments, "-T", options.Title); - AddValue(arguments, "-b", options.BorderLines); - AddValue(arguments, "-s", options.Style); - AddValue(arguments, "-S", options.BorderStyle); - AddEnvironment(arguments, options.Environment); - if (options.NoBorder) - { - arguments.Add("-B"); - } - } - - private void AddPopupKeyPolicy(List arguments, DisplayPopupRequest options) - { - if (!options.CloseOnAnyKey && !options.NoKeys) - { - return; - } - - if (!Requires(PopupKeyPolicyCapability, LogPopupKeyPolicyUnsupported)) - { - return; - } - - if (options.CloseOnAnyKey) - { - arguments.Add("-k"); - } - - if (options.NoKeys) - { - arguments.Add("-N"); - } - } - - // move-pane and join-pane both take the pane as -s and where it lands as - // -t, which is the opposite way round from every other pane command. - internal List BuildRehomeArguments(string subcommand, MovePaneRequest request) - { - ArgumentNullException.ThrowIfNull(request); - List arguments = - [ - subcommand, - request.Direction is PaneDirection.Above or PaneDirection.Below ? "-v" : "-h", - ]; - if (request.Detach) - { - arguments.Add("-d"); - } - - if (request.FullWindow) - { - arguments.Add("-f"); - } - - // A percentage flag exists but is broken from 3.4 through 3.6, so a - // size of any shape rides the one flag that works everywhere. - AddValue(arguments, "-l", request.Size); - if (request.Before || request.Direction is PaneDirection.Above or PaneDirection.Left) - { - arguments.Add("-b"); - } - - arguments.Add("-s"); - arguments.Add(Target); - arguments.Add("-t"); - arguments.Add(request.Target); - return arguments; - } - - [UnsupportedOSPlatform("windows")] - private async Task CreatePaneFromAsync( - List arguments, - string subcommand, - CancellationToken cancellationToken) - { - var sequence = new TmuxMutationSequence(); - TmuxCommandResult result = await sequence.MutateAsync( - () => _commandDispatcher.ExecuteAsync(arguments, cancellationToken), - value => TmuxCommandFailure.ThrowIfFailed(value, subcommand)) - .ConfigureAwait(false); - PaneId created = sequence.Observe(() => - result.StandardOutputLines.Count > 0 - && PaneId.TryParse(result.StandardOutputLines[0], out PaneId parsed) - ? parsed - : throw new InvalidDataException("tmux reported no new pane identifier.")); - - Server owner = sequence.Observe(() => Server); - IReadOnlyList> rows = await sequence - .ObserveAsync(() => RelationReader.ListAsync( - owner, - "list-panes", - ["-a"], - cancellationToken)) - .ConfigureAwait(false); - return sequence.Observe(() => - rows.Select(row => RelationReader.ToPane(owner, row)) - .FirstOrDefault(pane => pane.Id == created) - ?? throw new TmuxObjectNotFoundException( - $"tmux did not report the created pane '{created}'.", - created.ToString())); - } - - private string Target => _id.ToString(); - - private int ReadCapturedInt(string wireName, string relation) => - int.TryParse( - ReadSnapshot(wireName), - NumberStyles.None, - CultureInfo.InvariantCulture, - out int value) - ? value - : throw new IncompleteSnapshotException(relation, SnapshotDepth.Server); - - [UnsupportedOSPlatform("windows")] - private async Task RunAsync(List arguments, CancellationToken cancellationToken) - { - TmuxCommandResult result = await _commandDispatcher - .ExecuteAsync(arguments, cancellationToken) - .ConfigureAwait(false); - TmuxCommandFailure.ThrowIfFailed(result, arguments[0]); - } -} diff --git a/src/LibTmux/Pane.Snapshot.cs b/src/LibTmux/Pane.Snapshot.cs new file mode 100644 index 0000000..af801d0 --- /dev/null +++ b/src/LibTmux/Pane.Snapshot.cs @@ -0,0 +1,58 @@ +using System.Runtime.Versioning; + +namespace LibTmux; + +public sealed partial class Pane +{ + /// Gets whether the pane touches the top of its window. + public bool AtTop => ReadSnapshot("pane_at_top") == "1"; + + /// Gets whether the pane touches the bottom of its window. + public bool AtBottom => ReadSnapshot("pane_at_bottom") == "1"; + + /// Gets whether the pane touches the left of its window. + public bool AtLeft => ReadSnapshot("pane_at_left") == "1"; + + /// Gets whether the pane touches the right of its window. + public bool AtRight => ReadSnapshot("pane_at_right") == "1"; + + /// Gets the pane height captured with this handle. + /// + /// The pane was resolved by identifier rather than materialized. + /// + public int Height => ReadCapturedInt("pane_height", "height"); + + /// Gets the pane width captured with this handle. + /// + /// The pane was resolved by identifier rather than materialized. + /// + public int Width => ReadCapturedInt("pane_width", "width"); + + /// Gets the index this pane holds in its window. + /// + /// The pane was resolved by identifier rather than materialized. + /// + public int Index => ReadCapturedInt("pane_index", "index"); + + /// Gets the pane title captured with this handle. + public string? Title => ReadSnapshot("pane_title"); + + /// Re-reads this pane from tmux. + /// Cancels the tmux command. + /// A replacement handle carrying current state. + [UnsupportedOSPlatform("windows")] + public async Task RefreshAsync(CancellationToken cancellationToken = default) + { + // Listing by -t fails loudly on a pane that is already gone, which + // would report a command failure where the pane is simply missing. + Server owner = Server; + IReadOnlyList> rows = await RelationReader + .ListAsync(owner, "list-panes", ["-a"], cancellationToken) + .ConfigureAwait(false); + return rows.Select(row => RelationReader.ToPane(owner, row)) + .FirstOrDefault(pane => pane.Id == _id) + ?? throw new TmuxObjectNotFoundException( + $"tmux no longer has pane '{_id}'.", + _id.ToString()); + } +} diff --git a/src/LibTmux/Pane.Topology.cs b/src/LibTmux/Pane.Topology.cs new file mode 100644 index 0000000..53b0626 --- /dev/null +++ b/src/LibTmux/Pane.Topology.cs @@ -0,0 +1,594 @@ +using System.Globalization; +using System.Runtime.Versioning; +using LibTmux.Internal; + +namespace LibTmux; + +public sealed partial class Pane +{ + internal List BuildRespawnPaneArguments(RespawnRequest request) + { + List arguments = ["respawn-pane", "-t", Target]; + if (request.KillExistingProcess) + { + arguments.Add("-k"); + } + + AddValue(arguments, "-c", StartDirectory.Resolve(request.StartDirectory)); + AddEnvironment(arguments, request.Environment); + if (request.Command is not null) + { + arguments.Add(request.Command); + } + + return arguments; + } + + /// Builds the arguments a floating-pane request sends. + /// + /// The command itself arrived in tmux 3.7, so the refusal belongs here + /// rather than beside the dispatch: a chained request that skipped it + /// would send a command older servers do not have. + /// + /// tmux is older than 3.7. + internal List BuildNewPaneArguments(NewPaneRequest request) + { + Server owner = Server; + if (!Supports(owner, NewPaneCommandCapability)) + { + throw new TmuxVersionTooLowException( + "new-pane requires tmux 3.7.", + TmuxVersion.Parse("3.7"), + owner.Version ?? default); + } + + List arguments = + [ + "new-pane", + "-P", + "-F", + "#{pane_id}", + "-t", + request.Target ?? Target, + ]; + if (!request.Attach) + { + arguments.Add("-d"); + } + + AddValue(arguments, "-x", request.Width); + AddValue(arguments, "-y", request.Height); + AddValue(arguments, "-X", request.X); + AddValue(arguments, "-Y", request.Y); + if (request.Zoom) + { + arguments.Add("-Z"); + } + + AddValue(arguments, "-c", StartDirectory.Resolve(request.StartDirectory)); + AddEnvironment(arguments, request.Environment); + if (request.Empty) + { + arguments.Add("-E"); + } + + AddValue(arguments, "-s", request.Style); + AddValue(arguments, "-S", request.ActiveBorderStyle); + AddValue(arguments, "-R", request.InactiveBorderStyle); + AddValue(arguments, "-m", request.Message); + if (request.KeepOpen) + { + arguments.Add("-k"); + } + + if (request.Command is not null) + { + arguments.Add(request.Command); + } + + return arguments; + } + + /// Builds the arguments a split request sends. + /// + /// Splitting into an empty pane and the appearance flags both arrived in + /// tmux 3.7, so this stays on the pane that knows which tmux is + /// answering. It keeps the identifier-printing flags, so a chained split + /// can say which pane it made. + /// + internal List BuildSplitArguments(SplitPaneRequest request) + { + List arguments = + [ + "split-window", + "-P", + "-F", + "#{pane_id}", + "-t", + // A pane identifier names a pane on its own; composing one with a + // sub-target would ask tmux for a window that does not exist. + request.Target ?? Target, + ]; + foreach (string flag in CommandFlagCatalog.GetPaneDirectionFlags( + request.Direction ?? PaneDirection.Below)) + { + arguments.Add(flag); + } + + // tmux 3.4 misreads the percentage flag, so a percentage rides the size + // flag instead, which every supported version accepts. + AddValue( + arguments, + "-l", + request.Percentage is int share + ? string.Create(CultureInfo.InvariantCulture, $"{share}%") + : request.Size); + if (request.FullWindow) + { + arguments.Add("-f"); + } + + if (request.Zoom) + { + arguments.Add("-Z"); + } + + if (!request.Attach) + { + arguments.Add("-d"); + } + + AddValue(arguments, "-c", StartDirectory.Resolve(request.StartDirectory)); + AddEnvironment(arguments, request.Environment); + AddSplitAppearance(arguments, request); + if (request.Command is not null) + { + arguments.Add(request.Command); + } + + return arguments; + } + + /// Moves this pane out into a window of its own. + /// The new window's name. + /// Whether the new window is left unselected. + /// Cancels the tmux commands. + /// The window the pane now lives in. + [UnsupportedOSPlatform("windows")] + public async Task BreakAsync( + string? windowName = null, + bool detach = true, + CancellationToken cancellationToken = default) + { + Server owner = Server; + // tmux 3.7 dereferences a null window name here and takes the whole + // server with it, so that one version always gets a name: the caller's + // if there is one, otherwise a placeholder that is renamed away after. + bool needsPlaceholder = Supports(owner, "break_pane_3_7_workaround"); + List arguments = ["break-pane", "-P", "-F", "#{window_id}"]; + if (detach) + { + arguments.Add("-d"); + } + + if (windowName is not null) + { + arguments.Add("-n"); + arguments.Add(windowName); + } + else if (needsPlaceholder) + { + arguments.Add("-n"); + arguments.Add("libtmux"); + } + + // The pane goes in -s: break-pane's -t names where the window lands. + arguments.Add("-s"); + arguments.Add(Target); + + var sequence = new TmuxMutationSequence(); + TmuxCommandResult result = await sequence.MutateAsync( + () => _commandDispatcher.ExecuteAsync(arguments, cancellationToken), + static value => TmuxCommandFailure.ThrowIfFailed(value, "break-pane")) + .ConfigureAwait(false); + WindowId created = sequence.Observe(() => + result.StandardOutputLines.Count > 0 + && WindowId.TryParse(result.StandardOutputLines[0], out WindowId parsed) + ? parsed + : throw new InvalidDataException("tmux reported no new window identifier.")); + + // On that same version tmux keeps the name it was given only some of + // the time, so a caller who asked for one gets it set explicitly. + if (windowName is not null && needsPlaceholder) + { + await sequence.MutateAsync( + () => RunAsync( + ["rename-window", "-t", created.ToString(), windowName], + cancellationToken)) + .ConfigureAwait(false); + } + + IReadOnlyList windows = await sequence + .ObserveAsync(() => owner.GetWindowsAsync(cancellationToken)) + .ConfigureAwait(false); + return sequence.Observe(() => + windows.FirstOrDefault(window => window.Id == created) + ?? throw new TmuxObjectNotFoundException( + $"tmux did not report the created window '{created}'.", + created.ToString())); + } + + /// Splits this pane. + /// How to split. + /// Cancels the tmux command. + /// The created pane. + [UnsupportedOSPlatform("windows")] + public async Task SplitAsync( + SplitPaneRequest? request = null, + CancellationToken cancellationToken = default) + { + SplitPaneRequest options = request ?? new SplitPaneRequest(); + List arguments = BuildSplitArguments(options); + + return await CreatePaneFromAsync(arguments, "split-window", cancellationToken) + .ConfigureAwait(false); + } + + /// Creates a floating pane against this one. + /// The pane to create. + /// Cancels the tmux command. + /// The created pane. + /// + /// The server predates tmux 3.7, which introduced the command. + /// + [UnsupportedOSPlatform("windows")] + public async Task CreatePaneAsync( + NewPaneRequest? request = null, + CancellationToken cancellationToken = default) + { + NewPaneRequest options = request ?? new NewPaneRequest(); + List arguments = BuildNewPaneArguments(options); + + return await CreatePaneFromAsync(arguments, "new-pane", cancellationToken) + .ConfigureAwait(false); + } + + /// Joins this pane into another window. + /// Where the pane lands. + /// Cancels the tmux command. + [UnsupportedOSPlatform("windows")] + public Task JoinAsync( + MovePaneRequest request, + CancellationToken cancellationToken = default) => + RunAsync(BuildRehomeArguments("join-pane", request), cancellationToken); + + /// Moves this pane to another position. + /// Where the pane lands. + /// Cancels the tmux command. + [UnsupportedOSPlatform("windows")] + public Task MoveAsync( + MovePaneRequest request, + CancellationToken cancellationToken = default) => + RunAsync(BuildRehomeArguments("move-pane", request), cancellationToken); + + /// Swaps this pane with another. + /// Which pane to swap with. + /// Cancels the tmux command. + [UnsupportedOSPlatform("windows")] + public Task SwapAsync( + SwapPaneRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + List arguments = BuildSwapPaneArguments(request); + return RunAsync(arguments, cancellationToken); + } + + /// Stops this pane. + /// Whether every other pane in the window is stopped instead. + /// Cancels the tmux command. + [UnsupportedOSPlatform("windows")] + public Task KillAsync(bool allExcept = false, CancellationToken cancellationToken = default) + { + List arguments = ["kill-pane"]; + if (allExcept) + { + arguments.Add("-a"); + } + + arguments.Add("-t"); + arguments.Add(Target); + return RunAsync(arguments, cancellationToken); + } + + /// Restarts the command running in this pane. + /// What to respawn, or null to reuse the original. + /// Cancels the tmux command. + /// + /// tmux refuses to respawn a pane that is still running unless the request + /// kills it first. + /// + [UnsupportedOSPlatform("windows")] + public Task RespawnAsync( + RespawnRequest? request = null, + CancellationToken cancellationToken = default) + { + RespawnRequest options = request ?? new RespawnRequest(); + List arguments = BuildRespawnPaneArguments(options); + return RunAsync(arguments, cancellationToken); + } + + /// Resizes this pane. + /// The size to apply. + /// Cancels the tmux command. + /// A replacement handle carrying the new size. + /// + /// tmux clamps a size that does not fit rather than refusing it, so the + /// result may differ from what was asked for. + /// + [UnsupportedOSPlatform("windows")] + public async Task ResizeAsync( + ResizePaneRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + List arguments = BuildResizePaneArguments(request); + + return await TmuxMutationSequence.RunAsync( + () => RunAsync(arguments, cancellationToken), + () => RefreshAsync(cancellationToken)) + .ConfigureAwait(false); + } + + /// Sets this pane's width. + /// The width in cells. + /// Cancels the tmux command. + /// A replacement handle carrying the new size. + [UnsupportedOSPlatform("windows")] + public Task SetWidthAsync(int width, CancellationToken cancellationToken = default) => + ResizeAsync( + new ResizePaneRequest(width: width.ToString(CultureInfo.InvariantCulture)), + cancellationToken); + + /// Sets this pane's height. + /// The height in cells. + /// Cancels the tmux command. + /// A replacement handle carrying the new size. + [UnsupportedOSPlatform("windows")] + public Task SetHeightAsync(int height, CancellationToken cancellationToken = default) => + ResizeAsync( + new ResizePaneRequest(height: height.ToString(CultureInfo.InvariantCulture)), + cancellationToken); + + /// Sets this pane's title. + /// The new title. + /// Cancels the tmux command. + /// A replacement handle carrying the new title. + [UnsupportedOSPlatform("windows")] + public async Task SetTitleAsync( + string title, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(title); + return await TmuxMutationSequence.RunAsync( + () => RunAsync(["select-pane", "-t", Target, "-T", title], cancellationToken), + () => RefreshAsync(cancellationToken)) + .ConfigureAwait(false); + } + + /// Selects this pane. + /// How to select it. + /// Cancels the tmux command. + /// A replacement handle carrying the state afterwards. + [UnsupportedOSPlatform("windows")] + public async Task SelectAsync( + SelectPaneRequest? request = null, + CancellationToken cancellationToken = default) + { + SelectPaneRequest options = request ?? new SelectPaneRequest(); + List arguments = BuildSelectPaneArguments(options); + + return await TmuxMutationSequence.RunAsync( + () => RunAsync(arguments, cancellationToken), + () => RefreshAsync(cancellationToken)) + .ConfigureAwait(false); + } + + internal List BuildSwapPaneArguments(SwapPaneRequest request) + { + List arguments = ["swap-pane", "-t", Target]; + if (request.Detach) + { + arguments.Add("-d"); + } + + if (request.Direction is PaneSwapDirection direction) + { + arguments.Add(direction == PaneSwapDirection.Up ? "-U" : "-D"); + } + + if (request.KeepZoom) + { + arguments.Add("-Z"); + } + + AddValue(arguments, "-s", request.Target); + + return arguments; + } + + internal List BuildResizePaneArguments(ResizePaneRequest request) + { + List arguments = ["resize-pane", "-t", Target]; + if (request.Direction is ResizeDirection direction) + { + arguments.Add(CommandFlagCatalog.GetResizeDirectionFlag(direction)); + } + + AddValue(arguments, "-x", request.Width); + AddValue(arguments, "-y", request.Height); + if (request.Zoom) + { + arguments.Add("-Z"); + } + + if (request.Mouse) + { + arguments.Add("-M"); + } + + if (request.TrimBelow) + { + arguments.Add("-T"); + } + + // tmux takes the adjustment as the trailing positional; as a flag value + // it would be read as a second argument and refused. + if (request.Adjustment is int adjustment) + { + arguments.Add(adjustment.ToString(CultureInfo.InvariantCulture)); + } + + return arguments; + } + + internal List BuildSelectPaneArguments(SelectPaneRequest options) + { + List arguments = ["select-pane", "-t", Target]; + string? directionFlag = options.Direction switch + { + PaneSelectDirection.Up => "-U", + PaneSelectDirection.Down => "-D", + PaneSelectDirection.Left => "-L", + PaneSelectDirection.Right => "-R", + PaneSelectDirection.Last => "-l", + _ => null, + }; + if (directionFlag is not null) + { + arguments.Add(directionFlag); + } + + // Asking for the last pane by direction and by flag is the same + // request, and tmux only needs telling once. + if (options.Last && directionFlag != "-l") + { + arguments.Add("-l"); + } + + if (options.KeepZoom) + { + arguments.Add("-Z"); + } + + if (options.Mark is bool mark) + { + arguments.Add(mark ? "-m" : "-M"); + } + + if (options.InputEnabled is bool input) + { + arguments.Add(input ? "-e" : "-d"); + } + + return arguments; + } + + private void AddSplitAppearance(List arguments, SplitPaneRequest options) + { + if (options.Empty) + { + if (Requires(SplitEmptyCapability, LogSplitEmptyUnsupported)) + { + arguments.Add("-E"); + } + } + + bool wantsAppearance = options.Style is not null + || options.ActiveBorderStyle is not null + || options.InactiveBorderStyle is not null + || options.Message is not null + || options.KeepOpen; + if (!wantsAppearance || !Requires(SplitAppearanceCapability, LogSplitAppearanceUnsupported)) + { + return; + } + + AddValue(arguments, "-s", options.Style); + AddValue(arguments, "-S", options.ActiveBorderStyle); + AddValue(arguments, "-R", options.InactiveBorderStyle); + AddValue(arguments, "-m", options.Message); + if (options.KeepOpen) + { + arguments.Add("-k"); + } + } + + // move-pane and join-pane both take the pane as -s and where it lands as + // -t, which is the opposite way round from every other pane command. + internal List BuildRehomeArguments(string subcommand, MovePaneRequest request) + { + ArgumentNullException.ThrowIfNull(request); + List arguments = + [ + subcommand, + request.Direction is PaneDirection.Above or PaneDirection.Below ? "-v" : "-h", + ]; + if (request.Detach) + { + arguments.Add("-d"); + } + + if (request.FullWindow) + { + arguments.Add("-f"); + } + + // A percentage flag exists but is broken from 3.4 through 3.6, so a + // size of any shape rides the one flag that works everywhere. + AddValue(arguments, "-l", request.Size); + if (request.Before || request.Direction is PaneDirection.Above or PaneDirection.Left) + { + arguments.Add("-b"); + } + + arguments.Add("-s"); + arguments.Add(Target); + arguments.Add("-t"); + arguments.Add(request.Target); + return arguments; + } + + [UnsupportedOSPlatform("windows")] + private async Task CreatePaneFromAsync( + List arguments, + string subcommand, + CancellationToken cancellationToken) + { + var sequence = new TmuxMutationSequence(); + TmuxCommandResult result = await sequence.MutateAsync( + () => _commandDispatcher.ExecuteAsync(arguments, cancellationToken), + value => TmuxCommandFailure.ThrowIfFailed(value, subcommand)) + .ConfigureAwait(false); + PaneId created = sequence.Observe(() => + result.StandardOutputLines.Count > 0 + && PaneId.TryParse(result.StandardOutputLines[0], out PaneId parsed) + ? parsed + : throw new InvalidDataException("tmux reported no new pane identifier.")); + + Server owner = sequence.Observe(() => Server); + IReadOnlyList> rows = await sequence + .ObserveAsync(() => RelationReader.ListAsync( + owner, + "list-panes", + ["-a"], + cancellationToken)) + .ConfigureAwait(false); + return sequence.Observe(() => + rows.Select(row => RelationReader.ToPane(owner, row)) + .FirstOrDefault(pane => pane.Id == created) + ?? throw new TmuxObjectNotFoundException( + $"tmux did not report the created pane '{created}'.", + created.ToString())); + } +} From 39be17176c65e300db7a0c9834a2ca924e622716 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 13:47:26 -0500 Subject: [PATCH 062/129] Window(refactor[files]): Split operation families why: Window.Topology mixed state, lifecycle, link, layout, pane, display, and shared command concerns in one 1,072-line partial. what: - move each operation family into a focused partial file - keep cross-family argument and dispatch helpers in one support partial - preserve public signatures and tmux command behavior --- src/LibTmux/Window.Display.cs | 124 +++ src/LibTmux/Window.Layout.cs | 186 ++++ src/LibTmux/Window.Lifecycle.cs | 181 ++++ src/LibTmux/Window.Links.cs | 174 ++++ src/LibTmux/Window.OperationSupport.cs | 76 ++ src/LibTmux/Window.PaneNavigation.cs | 56 -- src/LibTmux/Window.Panes.cs | 345 ++++++++ src/LibTmux/Window.State.cs | 87 ++ src/LibTmux/Window.Topology.cs | 1072 ------------------------ 9 files changed, 1173 insertions(+), 1128 deletions(-) create mode 100644 src/LibTmux/Window.Display.cs create mode 100644 src/LibTmux/Window.Layout.cs create mode 100644 src/LibTmux/Window.Lifecycle.cs create mode 100644 src/LibTmux/Window.Links.cs create mode 100644 src/LibTmux/Window.OperationSupport.cs delete mode 100644 src/LibTmux/Window.PaneNavigation.cs create mode 100644 src/LibTmux/Window.Panes.cs create mode 100644 src/LibTmux/Window.State.cs delete mode 100644 src/LibTmux/Window.Topology.cs diff --git a/src/LibTmux/Window.Display.cs b/src/LibTmux/Window.Display.cs new file mode 100644 index 0000000..4f5a33c --- /dev/null +++ b/src/LibTmux/Window.Display.cs @@ -0,0 +1,124 @@ +using System.Globalization; +using System.Runtime.Versioning; +using Microsoft.Extensions.Logging; + +namespace LibTmux; + +// Displays messages through this window. +public sealed partial class Window +{ + private const string DisplayMessageLiteralCapability = "display_message_literal"; + + /// Shows a message on the client viewing this window. + /// The message to show. + /// Cancels the tmux command. + /// The printed lines when the request asked for them, else null. + /// + /// The request asks to redraw the pane, which only a pane can honour. + /// + /// + /// A message with no client to show it on is not a failure, so tmux's + /// complaint is logged rather than raised. + /// + [UnsupportedOSPlatform("windows")] + public async Task?> DisplayMessageAsync( + DisplayMessageRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + if (request.UpdatePane) + { + throw new ArgumentException( + "Redrawing while a message is shown is pane-scoped.", + nameof(request)); + } + + Server owner = RequireOwner("display"); + if (request.TargetClient is not null + && owner.Version is TmuxVersion version + && version < TmuxVersion.Parse("3.3a")) + { + // tmux 3.2a declares the flag without a value, so naming a client + // there would silently address a different one. + throw new TmuxVersionTooLowException( + "Naming a display-message client requires tmux 3.3a.", + TmuxVersion.Parse("3.3a"), + owner.Version ?? default); + } + + List arguments = ["display-message", "-t", Target]; + if (request.ReturnText) + { + arguments.Add("-p"); + } + + if (request.AllFormats) + { + arguments.Add("-a"); + } + + if (request.Verbose) + { + arguments.Add("-v"); + } + + if (request.NoExpand && RequireLiteralMessages(owner)) + { + arguments.Add("-l"); + } + + if (request.Notify) + { + arguments.Add("-N"); + } + + AddValue(arguments, "-c", request.TargetClient); + AddValue( + arguments, + "-d", + request.Delay is TimeSpan delay + ? ((long)delay.TotalMilliseconds).ToString(CultureInfo.InvariantCulture) + : null); + AddValue(arguments, "-F", request.Format); + if (request.Message.Length > 0) + { + arguments.Add(request.Message); + } + + TmuxCommandResult result = await _commandDispatcher + .ExecuteAsync(arguments, cancellationToken) + .ConfigureAwait(false); + if (result.StandardErrorLines.Count > 0 + && owner.Connection?.Options.Logger is ILogger logger) + { + LogDisplayMessageRefused(logger, string.Join('\n', result.StandardErrorLines)); + } + + return request.ReturnText ? result.StandardOutputLines : null; + } + + [LoggerMessage( + EventId = 2, + Level = LogLevel.Warning, + Message = "literal message flag omitted, tmux {TmuxVersion} does not carry it")] + private static partial void LogLiteralUnsupported(ILogger logger, string? tmuxVersion); + + [LoggerMessage( + EventId = 5, + Level = LogLevel.Warning, + Message = "tmux refused to display the message: {TmuxError}")] + private static partial void LogDisplayMessageRefused(ILogger logger, string tmuxError); + + // The version comes from state captured when the handle materialized, so + // gating costs no extra tmux command and the call still dispatches once. + private static bool RequireLiteralMessages(Server owner) + { + if (Supports(owner, DisplayMessageLiteralCapability)) + { + return true; + } + + Warn(owner, LogLiteralUnsupported); + return false; + } +} diff --git a/src/LibTmux/Window.Layout.cs b/src/LibTmux/Window.Layout.cs new file mode 100644 index 0000000..357abc2 --- /dev/null +++ b/src/LibTmux/Window.Layout.cs @@ -0,0 +1,186 @@ +using System.Globalization; +using System.Runtime.Versioning; +using LibTmux.Internal; + +namespace LibTmux; + +// Resizes a window and controls its pane layout. +public sealed partial class Window +{ + // tmux 3.3a crashes its entire server when layout_parse rejects a name, so + // a layout is checked here rather than by the server. These five are known + // to every supported version; the mirrored pair arrived in 3.5. + private static readonly string[] UniversalLayouts = + [ + "even-horizontal", + "even-vertical", + "main-horizontal", + "main-vertical", + "tiled", + ]; + private static readonly string[] MirroredLayouts = + [ + "main-horizontal-mirrored", + "main-vertical-mirrored", + ]; + + /// Resizes this window. + /// The size to apply. + /// Cancels the tmux command. + /// A replacement handle carrying the new size. + /// + /// Resizing switches the window's window-size option to manual, so + /// it stops following its clients. + /// + [UnsupportedOSPlatform("windows")] + public async Task ResizeAsync( + ResizeWindowRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + List arguments = BuildResizeWindowArguments(request); + + return await TmuxMutationSequence.RunAsync( + () => RunAsync(arguments, cancellationToken), + () => RefreshAsync(cancellationToken)) + .ConfigureAwait(false); + } + + internal List BuildResizeWindowArguments(ResizeWindowRequest request) + { + List arguments = ["resize-window", "-t", Target]; + if (request.Direction is ResizeDirection direction) + { + arguments.Add(CommandFlagCatalog.GetResizeDirectionFlag(direction)); + } + + AddValue(arguments, "-x", request.Width); + AddValue(arguments, "-y", request.Height); + if (request.Mode is WindowResizeMode mode) + { + arguments.Add(mode == WindowResizeMode.Expand ? "-A" : "-a"); + } + + // tmux takes the adjustment as the trailing positional; as a flag value + // it would be read as a second argument and refused. + if (request.Adjustment is int adjustment) + { + arguments.Add(adjustment.ToString(CultureInfo.InvariantCulture)); + } + + return arguments; + } + + /// Builds the arguments a layout request sends. + /// + /// This stays on the window rather than becoming a static helper because + /// validating a layout name asks the running tmux which names it knows, + /// and an unrecognised name takes the whole server down on 3.3a. A chained + /// layout has to be checked the same way a direct one is. + /// + internal List BuildSelectLayoutArguments(SelectLayoutRequest request) + { + List arguments = ["select-layout", "-t", Target]; + if (request.Mode is SelectLayoutMode mode) + { + arguments.Add(mode switch + { + SelectLayoutMode.Spread => "-E", + SelectLayoutMode.Next => "-n", + _ => "-p", + }); + } + + if (request.Layout is not null) + { + ValidateLayout(request.Layout); + arguments.Add(request.Layout); + } + + return arguments; + } + + /// Applies a layout to this window. + /// The layout to apply. + /// Cancels the tmux command. + /// A replacement handle carrying the new layout. + /// + /// The layout is one tmux may not recognise. + /// + [UnsupportedOSPlatform("windows")] + public async Task SelectLayoutAsync( + SelectLayoutRequest? request = null, + CancellationToken cancellationToken = default) + { + SelectLayoutRequest options = request ?? new SelectLayoutRequest(); + List arguments = BuildSelectLayoutArguments(options); + + return await TmuxMutationSequence.RunAsync( + () => RunAsync(arguments, cancellationToken), + () => RefreshAsync(cancellationToken)) + .ConfigureAwait(false); + } + + /// Moves to the next layout. + /// Cancels the tmux command. + /// A replacement handle carrying the new layout. + [UnsupportedOSPlatform("windows")] + public async Task SelectNextLayoutAsync( + CancellationToken cancellationToken = default) + { + return await TmuxMutationSequence.RunAsync( + () => RunAsync(["next-layout", "-t", Target], cancellationToken), + () => RefreshAsync(cancellationToken)) + .ConfigureAwait(false); + } + + /// Moves to the previous layout. + /// Cancels the tmux command. + /// A replacement handle carrying the new layout. + [UnsupportedOSPlatform("windows")] + public async Task SelectPreviousLayoutAsync( + CancellationToken cancellationToken = default) + { + return await TmuxMutationSequence.RunAsync( + () => RunAsync(["previous-layout", "-t", Target], cancellationToken), + () => RefreshAsync(cancellationToken)) + .ConfigureAwait(false); + } + + private void ValidateLayout(string layout) + { + if (layout.Length == 0) + { + throw new TmuxWindowException("A layout name cannot be empty.", _id); + } + + // A layout tmux dumped begins with a four-digit hexadecimal checksum, + // and every version parses those. Named layouts are checked against the + // set the running tmux knows. + if (HasCustomLayoutPrefix(layout) + || UniversalLayouts.Contains(layout, StringComparer.Ordinal)) + { + return; + } + + Server owner = RequireOwner("layout"); + bool mirroredKnown = owner.Version is TmuxVersion version + && version >= TmuxVersion.Parse("3.5"); + if (mirroredKnown && MirroredLayouts.Contains(layout, StringComparer.Ordinal)) + { + return; + } + + throw new TmuxWindowException( + $"tmux {owner.RawVersion} does not know the layout '{layout}'.", + _id); + } + + private static bool HasCustomLayoutPrefix(string layout) => + layout.Length > 5 + && layout[4] == ',' + && char.IsAsciiHexDigit(layout[0]) + && char.IsAsciiHexDigit(layout[1]) + && char.IsAsciiHexDigit(layout[2]) + && char.IsAsciiHexDigit(layout[3]); +} diff --git a/src/LibTmux/Window.Lifecycle.cs b/src/LibTmux/Window.Lifecycle.cs new file mode 100644 index 0000000..dd4383f --- /dev/null +++ b/src/LibTmux/Window.Lifecycle.cs @@ -0,0 +1,181 @@ +using System.Runtime.Versioning; +using LibTmux.Internal; + +namespace LibTmux; + +/// Names which way a window's panes rotate. +public enum WindowRotationDirection +{ + /// Rotate panes towards the top of the window. + Up = 0, + + /// Rotate panes towards the bottom of the window. + Down = 1, +} +// Mutates a window's lifecycle and returns a replacement when the handle remains valid. +public sealed partial class Window +{ + /// Renames this window. + /// The new name. + /// Cancels the tmux command. + /// A replacement handle carrying the new name. + /// + /// tmux expands the name as a format, so a # in it does not survive + /// verbatim. + /// + [UnsupportedOSPlatform("windows")] + public async Task RenameAsync( + string name, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + return await TmuxMutationSequence.RunAsync( + () => RunAsync(["rename-window", "-t", Target, name], cancellationToken), + () => RefreshAsync(cancellationToken)) + .ConfigureAwait(false); + } + + /// Selects this window in its session. + /// Cancels the tmux command. + /// A replacement handle carrying the state after selection. + [UnsupportedOSPlatform("windows")] + public async Task SelectAsync(CancellationToken cancellationToken = default) + { + return await TmuxMutationSequence.RunAsync( + () => RunAsync(["select-window", "-t", Target], cancellationToken), + () => RefreshAsync(cancellationToken)) + .ConfigureAwait(false); + } + + /// Stops this window. + /// Whether every other window in the session is stopped instead. + /// Cancels the tmux command. + [UnsupportedOSPlatform("windows")] + public Task KillAsync(bool allExcept = false, CancellationToken cancellationToken = default) + { + List arguments = ["kill-window"]; + if (allExcept) + { + arguments.Add("-a"); + } + + arguments.Add("-t"); + arguments.Add(Target); + return RunAsync(arguments, cancellationToken); + } + + /// Rotates the panes in this window. + /// Which way to rotate, or null for tmux's default. + /// Whether a zoomed pane stays zoomed. + /// Cancels the tmux command. + /// A replacement handle carrying the state after rotation. + [UnsupportedOSPlatform("windows")] + public async Task RotateAsync( + WindowRotationDirection? direction = null, + bool keepZoom = false, + CancellationToken cancellationToken = default) + { + List arguments = ["rotate-window", "-t", Target]; + if (direction is WindowRotationDirection rotation) + { + arguments.Add(rotation == WindowRotationDirection.Up ? "-U" : "-D"); + } + + if (keepZoom) + { + arguments.Add("-Z"); + } + + return await TmuxMutationSequence.RunAsync( + () => RunAsync(arguments, cancellationToken), + () => RefreshAsync(cancellationToken)) + .ConfigureAwait(false); + } + + /// Restarts the command running in this window. + /// What to respawn, or null to reuse the original. + /// Cancels the tmux command. + /// + /// tmux refuses to respawn a window that is still running unless the + /// request kills it first, and killing it destroys every pane but one. + /// + [UnsupportedOSPlatform("windows")] + public Task RespawnAsync( + RespawnRequest? request = null, + CancellationToken cancellationToken = default) + { + RespawnRequest options = request ?? new RespawnRequest(); + List arguments = ["respawn-window", "-t", Target]; + if (options.KillExistingProcess) + { + arguments.Add("-k"); + } + + AddValue(arguments, "-c", StartDirectory.Resolve(options.StartDirectory)); + AddEnvironment(arguments, options.Environment); + if (options.Command is not null) + { + arguments.Add(options.Command); + } + + return RunAsync(arguments, cancellationToken); + } + + /// Creates a window next to this one. + /// The window to create. + /// Cancels the tmux command. + /// The created window. + [UnsupportedOSPlatform("windows")] + public async Task CreateWindowAsync( + NewWindowRequest? request = null, + CancellationToken cancellationToken = default) + { + NewWindowRequest options = (request ?? new NewWindowRequest()).WithTargetWindow(Target); + Server owner = RequireOwner("windows"); + List arguments = ["new-window", "-P", "-F", "#{window_id}", "-t", Target]; + if (!options.Attach) + { + arguments.Add("-d"); + } + + if (options.KillExisting) + { + arguments.Add("-k"); + } + + if (options.SelectExisting) + { + arguments.Add("-S"); + } + + AddDirection(arguments, options.Direction); + AddValue(arguments, "-n", options.Name); + AddValue(arguments, "-c", StartDirectory.Resolve(options.StartDirectory)); + AddEnvironment(arguments, options.Environment); + if (options.Command is not null) + { + arguments.Add(options.Command); + } + + var sequence = new TmuxMutationSequence(); + TmuxCommandResult result = await sequence.MutateAsync( + () => _commandDispatcher.ExecuteAsync(arguments, cancellationToken), + static value => TmuxCommandFailure.ThrowIfFailed(value, "new-window")) + .ConfigureAwait(false); + + WindowId created = sequence.Observe(() => + result.StandardOutputLines.Count > 0 + && WindowId.TryParse(result.StandardOutputLines[0], out WindowId parsed) + ? parsed + : throw new InvalidDataException("tmux reported no new window identifier.")); + + IReadOnlyList windows = await sequence + .ObserveAsync(() => owner.GetWindowsAsync(cancellationToken)) + .ConfigureAwait(false); + return sequence.Observe(() => + windows.FirstOrDefault(window => window.Id == created) + ?? throw new TmuxObjectNotFoundException( + $"tmux did not report the created window '{created}'.", + created.ToString())); + } +} diff --git a/src/LibTmux/Window.Links.cs b/src/LibTmux/Window.Links.cs new file mode 100644 index 0000000..b4a5f7d --- /dev/null +++ b/src/LibTmux/Window.Links.cs @@ -0,0 +1,174 @@ +using System.Runtime.Versioning; +using LibTmux.Internal; + +namespace LibTmux; + +// Links, unlinks, moves, and swaps a window. +public sealed partial class Window +{ + /// Builds the arguments a link request sends. + /// + /// This stays on the window because the source of the link is the session + /// this handle was read through, which a window resolved by identifier + /// does not know. + /// + /// + /// The window was resolved by identifier, so its source link is unknown. + /// + internal List BuildLinkWindowArguments(LinkWindowRequest request) + { + List arguments = + [ + "link-window", + "-t", + request.TargetIndex is null + ? request.TargetSession + : $"{request.TargetSession}:{request.TargetIndex}", + ]; + if (request.ReplaceExisting) + { + arguments.Add("-k"); + } + + AddDirection(arguments, request.Direction); + if (request.Detach) + { + arguments.Add("-d"); + } + + arguments.Add("-s"); + arguments.Add(SourceLink("link source")); + + return arguments; + } + + /// Links this window into another session. + /// Where the link goes. + /// Cancels the tmux command. + /// + /// The window was resolved by identifier, so its source link is unknown. + /// + [UnsupportedOSPlatform("windows")] + public Task LinkAsync( + LinkWindowRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + List arguments = BuildLinkWindowArguments(request); + return RunAsync(arguments, cancellationToken); + } + + /// Removes this window's link to the session it was read through. + /// Whether the window dies when this was its last link. + /// Cancels the tmux command. + /// + /// tmux refuses to unlink a window that belongs to only one session unless + /// it is allowed to destroy it. + /// + [UnsupportedOSPlatform("windows")] + public Task UnlinkAsync( + bool killIfLast = false, + CancellationToken cancellationToken = default) + { + List arguments = ["unlink-window"]; + if (killIfLast) + { + arguments.Add("-k"); + } + + arguments.Add("-t"); + arguments.Add(SourceLink("unlink source")); + return RunAsync(arguments, cancellationToken); + } + + /// Builds the arguments a move request sends. + /// + /// This stays on the window because both ends come from the handle: the + /// destination defaults to the session it was read through, and so does + /// the source it moves from. + /// + internal List BuildMoveWindowArguments(MoveWindowRequest request) + { + ArgumentNullException.ThrowIfNull(request); + string session = request.Session ?? CapturedSession("move destination"); + List arguments = ["move-window", "-t", $"{session}:{request.Destination}"]; + AddDirection(arguments, request.Direction); + if (request.NoSelect) + { + arguments.Add("-d"); + } + + if (request.ReplaceExisting) + { + arguments.Add("-k"); + } + + if (request.Renumber) + { + arguments.Add("-r"); + } + + arguments.Add("-s"); + arguments.Add(SourceLink("move source")); + + return arguments; + } + + /// Moves this window to another index or session. + /// Where the window goes. + /// Cancels the tmux command. + /// A replacement handle carrying the state after the move. + [UnsupportedOSPlatform("windows")] + public async Task MoveAsync( + MoveWindowRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + List arguments = BuildMoveWindowArguments(request); + + return await TmuxMutationSequence.RunAsync( + () => RunAsync(arguments, cancellationToken), + () => RefreshAsync(cancellationToken)) + .ConfigureAwait(false); + } + + /// Swaps this window with another. + /// The window to swap with. + /// Whether the swapped window is left unselected. + /// Cancels the tmux command. + /// + /// A window linked into several sessions resolves to whichever link tmux + /// picks, because a window identifier does not name one. + /// + [UnsupportedOSPlatform("windows")] + public Task SwapAsync( + WindowId target, + bool detach = false, + CancellationToken cancellationToken = default) + { + List arguments = ["swap-window", "-t", Target]; + if (detach) + { + arguments.Add("-d"); + } + + arguments.Add("-s"); + arguments.Add(target.ToString()); + return RunAsync(arguments, cancellationToken); + } + + + // A bare window id lets tmux choose which link it means, so any operation + // that moves a link names the session it belongs to as well. + private string SourceLink(string relation) + { + string session = CapturedSession(relation); + string index = ReadSnapshot("window_index") + ?? throw new IncompleteSnapshotException(relation, SnapshotDepth.Windows); + return $"{session}:{index}"; + } + + private string CapturedSession(string relation) => + ReadSnapshot("session_id") + ?? throw new IncompleteSnapshotException(relation, SnapshotDepth.Windows); +} diff --git a/src/LibTmux/Window.OperationSupport.cs b/src/LibTmux/Window.OperationSupport.cs new file mode 100644 index 0000000..a45bf27 --- /dev/null +++ b/src/LibTmux/Window.OperationSupport.cs @@ -0,0 +1,76 @@ +using System.Globalization; +using System.Runtime.Versioning; +using LibTmux.Internal; +using Microsoft.Extensions.Logging; + +namespace LibTmux; + +// Shares command argument and dispatch plumbing across window operations. +public sealed partial class Window +{ + private static void AddDirection(List arguments, WindowDirection? direction) + { + if (direction is WindowDirection value) + { + arguments.Add(CommandFlagCatalog.GetWindowDirectionFlag(value)); + } + } + + private static void AddValue(List arguments, string flag, string? value) + { + if (!string.IsNullOrEmpty(value)) + { + arguments.Add(flag); + arguments.Add(value); + } + } + + private static void AddValue(List arguments, string flag, int? value) + { + if (value is int cells) + { + arguments.Add(flag); + arguments.Add(cells.ToString(CultureInfo.InvariantCulture)); + } + } + + private static void AddEnvironment( + List arguments, + IReadOnlyDictionary? environment) + { + if (environment is null) + { + return; + } + + foreach ((string key, string value) in environment) + { + arguments.Add("-e"); + arguments.Add($"{key}={value}"); + } + } + + private static bool Supports(Server owner, string capability) => + owner.Version is TmuxVersion version + && TmuxCapabilities.IsSupported(version, capability); + + + private static void Warn(Server owner, Action log) + { + if (owner.Connection?.Options.Logger is ILogger logger) + { + log(logger, owner.RawVersion); + } + } + + private string Target => _id.ToString(); + + [UnsupportedOSPlatform("windows")] + private async Task RunAsync(List arguments, CancellationToken cancellationToken) + { + TmuxCommandResult result = await _commandDispatcher + .ExecuteAsync(arguments, cancellationToken) + .ConfigureAwait(false); + TmuxCommandFailure.ThrowIfFailed(result, arguments[0]); + } +} diff --git a/src/LibTmux/Window.PaneNavigation.cs b/src/LibTmux/Window.PaneNavigation.cs deleted file mode 100644 index 1927b87..0000000 --- a/src/LibTmux/Window.PaneNavigation.cs +++ /dev/null @@ -1,56 +0,0 @@ -using System.Runtime.Versioning; -using LibTmux.Internal; - -namespace LibTmux; - -/// Names whether a pane accepts input. -public enum PaneInputMode -{ - /// The pane accepts input. - Enable = 0, - - /// The pane ignores input. - Disable = 1, -} - -// Moves between a window's panes. -public sealed partial class Window -{ - /// Selects the pane that was last active. - /// Whether to change the pane's input handling instead. - /// Whether a zoomed pane stays zoomed. - /// Cancels the tmux command. - /// The pane that is active afterwards, or null when none is. - /// - /// Asking for an input change makes tmux apply that to the last pane and - /// leave the active pane alone, so the handle that comes back is the pane - /// that was already active. - /// - [UnsupportedOSPlatform("windows")] - public async Task SelectLastPaneAsync( - PaneInputMode? inputMode = null, - bool keepZoom = false, - CancellationToken cancellationToken = default) - { - List arguments = ["last-pane", "-t", _id.ToString()]; - if (inputMode is PaneInputMode mode) - { - arguments.Add(mode == PaneInputMode.Enable ? "-e" : "-d"); - } - - if (keepZoom) - { - arguments.Add("-Z"); - } - - return await TmuxMutationSequence.RunAsync( - () => RunAsync(arguments, cancellationToken), - async () => - { - IReadOnlyList panes = await GetPanesAsync(cancellationToken) - .ConfigureAwait(false); - return panes.FirstOrDefault(pane => pane.Snapshot?["pane_active"] == "1"); - }) - .ConfigureAwait(false); - } -} diff --git a/src/LibTmux/Window.Panes.cs b/src/LibTmux/Window.Panes.cs new file mode 100644 index 0000000..d4b1ae5 --- /dev/null +++ b/src/LibTmux/Window.Panes.cs @@ -0,0 +1,345 @@ +using System.Globalization; +using System.Runtime.Versioning; +using LibTmux.Internal; +using Microsoft.Extensions.Logging; + +namespace LibTmux; + +/// Names whether a pane accepts input. +public enum PaneInputMode +{ + /// The pane accepts input. + Enable = 0, + + /// The pane ignores input. + Disable = 1, +} + +// Selects, creates, and searches panes in this window. +public sealed partial class Window +{ + private const string NewPaneCommandCapability = "new_pane_command"; + private const string SplitWindowEmptyCapability = "split_window_empty"; + private const string SplitWindowAppearanceCapability = "split_window_appearance"; + + /// Selects the pane that was last active. + /// Whether to change the pane's input handling instead. + /// Whether a zoomed pane stays zoomed. + /// Cancels the tmux command. + /// The pane that is active afterwards, or null when none is. + /// + /// Asking for an input change makes tmux apply that to the last pane and + /// leave the active pane alone, so the handle that comes back is the pane + /// that was already active. + /// + [UnsupportedOSPlatform("windows")] + public async Task SelectLastPaneAsync( + PaneInputMode? inputMode = null, + bool keepZoom = false, + CancellationToken cancellationToken = default) + { + List arguments = ["last-pane", "-t", _id.ToString()]; + if (inputMode is PaneInputMode mode) + { + arguments.Add(mode == PaneInputMode.Enable ? "-e" : "-d"); + } + + if (keepZoom) + { + arguments.Add("-Z"); + } + + return await TmuxMutationSequence.RunAsync( + () => RunAsync(arguments, cancellationToken), + async () => + { + IReadOnlyList panes = await GetPanesAsync(cancellationToken) + .ConfigureAwait(false); + return panes.FirstOrDefault(pane => pane.Snapshot?["pane_active"] == "1"); + }) + .ConfigureAwait(false); + } + + /// Selects a pane in this window. + /// A pane target, or a direction such as -U. + /// Cancels the tmux command. + /// The pane that is active afterwards, or null when none is. + [UnsupportedOSPlatform("windows")] + public async Task SelectPaneAsync( + string target, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(target); + // A bare pane index would resolve against the caller's current session, + // so anything that is not a direction flag is anchored to this window. + List arguments = target is "-l" or "-U" or "-D" or "-L" or "-R" + ? ["select-pane", "-t", Target, target] + : ["select-pane", "-t", $"{Target}.{target}"]; + return await TmuxMutationSequence.RunAsync( + () => RunAsync(arguments, cancellationToken), + async () => + { + IReadOnlyList panes = await GetPanesAsync(cancellationToken) + .ConfigureAwait(false); + return panes.FirstOrDefault(pane => pane.Snapshot?["pane_active"] == "1"); + }) + .ConfigureAwait(false); + } + + /// Reads one pane in this window. + /// The pane target. + /// Cancels the tmux command. + /// The pane, or null when this window has no such pane. + [UnsupportedOSPlatform("windows")] + public async Task GetPaneAsync( + string target, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(target); + IReadOnlyList panes = await GetPanesAsync(cancellationToken).ConfigureAwait(false); + return panes.FirstOrDefault(pane => + pane.Id.ToString() == target + || pane.Snapshot?["pane_index"] == target); + } + + /// Splits a pane in this window. + /// How to split. + /// Cancels the tmux command. + /// The created pane. + [UnsupportedOSPlatform("windows")] + public async Task SplitPaneAsync( + SplitPaneRequest? request = null, + CancellationToken cancellationToken = default) + { + SplitPaneRequest options = request ?? new SplitPaneRequest(); + Server owner = RequireOwner("panes"); + List arguments = + [ + "split-window", + "-P", + "-F", + "#{pane_id}", + "-t", + options.Target is null ? Target : $"{Target}.{options.Target}", + ]; + foreach (string flag in CommandFlagCatalog.GetPaneDirectionFlags( + options.Direction ?? PaneDirection.Below)) + { + arguments.Add(flag); + } + + // tmux 3.4 misreads -p, so a percentage rides the -l flag instead, + // which every supported version accepts. + AddValue( + arguments, + "-l", + options.Percentage is int share + ? string.Create(CultureInfo.InvariantCulture, $"{share}%") + : options.Size); + if (options.FullWindow) + { + arguments.Add("-f"); + } + + if (options.Zoom) + { + arguments.Add("-Z"); + } + + if (!options.Attach) + { + arguments.Add("-d"); + } + + AddValue(arguments, "-c", StartDirectory.Resolve(options.StartDirectory)); + AddEnvironment(arguments, options.Environment); + AddSplitAppearance(arguments, options); + if (options.Command is not null) + { + arguments.Add(options.Command); + } + + var sequence = new TmuxMutationSequence(); + TmuxCommandResult result = await sequence.MutateAsync( + () => _commandDispatcher.ExecuteAsync(arguments, cancellationToken), + static value => TmuxCommandFailure.ThrowIfFailed(value, "split-window")) + .ConfigureAwait(false); + PaneId created = sequence.Observe(() => + result.StandardOutputLines.Count > 0 + && PaneId.TryParse(result.StandardOutputLines[0], out PaneId parsed) + ? parsed + : throw new InvalidDataException("tmux reported no new pane identifier.")); + + IReadOnlyList panes = await sequence + .ObserveAsync(() => GetPanesAsync(cancellationToken)) + .ConfigureAwait(false); + return sequence.Observe(() => + panes.FirstOrDefault(pane => pane.Id == created) + ?? throw new TmuxObjectNotFoundException( + $"tmux did not report the created pane '{created}'.", + created.ToString())); + } + + /// Creates a floating pane in this window. + /// The pane to create. + /// Cancels the tmux command. + /// The created pane. + /// + /// The server predates tmux 3.7, which introduced the command. + /// + [UnsupportedOSPlatform("windows")] + public async Task CreatePaneAsync( + NewPaneRequest? request = null, + CancellationToken cancellationToken = default) + { + NewPaneRequest options = request ?? new NewPaneRequest(); + Server owner = RequireOwner("panes"); + // The whole command is missing before 3.7, so there is nothing to omit + // and nothing worth dispatching. + if (!Supports(owner, NewPaneCommandCapability)) + { + throw new TmuxVersionTooLowException( + "new-pane requires tmux 3.7.", + TmuxVersion.Parse("3.7"), + owner.Version ?? default); + } + + List arguments = + [ + "new-pane", + "-P", + "-F", + "#{pane_id}", + "-t", + options.Target is null ? Target : $"{Target}.{options.Target}", + ]; + if (!options.Attach) + { + arguments.Add("-d"); + } + + AddValue(arguments, "-x", options.Width); + AddValue(arguments, "-y", options.Height); + AddValue(arguments, "-X", options.X); + AddValue(arguments, "-Y", options.Y); + if (options.Zoom) + { + arguments.Add("-Z"); + } + + AddValue(arguments, "-c", StartDirectory.Resolve(options.StartDirectory)); + AddEnvironment(arguments, options.Environment); + if (options.Empty) + { + arguments.Add("-E"); + } + + AddValue(arguments, "-s", options.Style); + AddValue(arguments, "-S", options.ActiveBorderStyle); + AddValue(arguments, "-R", options.InactiveBorderStyle); + AddValue(arguments, "-m", options.Message); + if (options.KeepOpen) + { + arguments.Add("-k"); + } + + if (options.Command is not null) + { + arguments.Add(options.Command); + } + + var sequence = new TmuxMutationSequence(); + TmuxCommandResult result = await sequence.MutateAsync( + () => _commandDispatcher.ExecuteAsync(arguments, cancellationToken), + static value => TmuxCommandFailure.ThrowIfFailed(value, "new-pane")) + .ConfigureAwait(false); + PaneId created = sequence.Observe(() => + result.StandardOutputLines.Count > 0 + && PaneId.TryParse(result.StandardOutputLines[0], out PaneId parsed) + ? parsed + : throw new InvalidDataException("tmux reported no new pane identifier.")); + + IReadOnlyList panes = await sequence + .ObserveAsync(() => GetPanesAsync(cancellationToken)) + .ConfigureAwait(false); + return sequence.Observe(() => + panes.FirstOrDefault(pane => pane.Id == created) + ?? throw new TmuxObjectNotFoundException( + $"tmux did not report the created pane '{created}'.", + created.ToString())); + } + + /// Runs a tmux-side filter over this window's panes. + /// The raw tmux filter expression. + /// Cancels the tmux command. + /// The panes tmux kept. + [UnsupportedOSPlatform("windows")] + public async Task> SearchPanesAsync( + UnsafeTmuxFilter filter, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(filter); + Server owner = RequireOwner("panes"); + IReadOnlyList> rows = await RelationReader + .ListAsync( + owner, + "list-panes", + ["-t", Target, "-f", filter.Value], + cancellationToken) + .ConfigureAwait(false); + return [.. rows.Select(row => RelationReader.ToPane(owner, row))]; + } + + [LoggerMessage( + EventId = 3, + Level = LogLevel.Warning, + Message = "split appearance flags omitted, tmux {TmuxVersion} does not carry them")] + private static partial void LogSplitAppearanceUnsupported(ILogger logger, string? tmuxVersion); + + [LoggerMessage( + EventId = 4, + Level = LogLevel.Warning, + Message = "empty split flag omitted, tmux {TmuxVersion} does not carry it")] + private static partial void LogSplitEmptyUnsupported(ILogger logger, string? tmuxVersion); + + private void AddSplitAppearance(List arguments, SplitPaneRequest options) + { + Server owner = RequireOwner("panes"); + if (options.Empty) + { + if (Supports(owner, SplitWindowEmptyCapability)) + { + arguments.Add("-E"); + } + else + { + Warn(owner, LogSplitEmptyUnsupported); + } + } + + bool wantsAppearance = options.Style is not null + || options.ActiveBorderStyle is not null + || options.InactiveBorderStyle is not null + || options.Message is not null + || options.KeepOpen; + if (!wantsAppearance) + { + return; + } + + if (!Supports(owner, SplitWindowAppearanceCapability)) + { + Warn(owner, LogSplitAppearanceUnsupported); + return; + } + + AddValue(arguments, "-s", options.Style); + AddValue(arguments, "-S", options.ActiveBorderStyle); + AddValue(arguments, "-R", options.InactiveBorderStyle); + AddValue(arguments, "-m", options.Message); + if (options.KeepOpen) + { + arguments.Add("-k"); + } + } +} diff --git a/src/LibTmux/Window.State.cs b/src/LibTmux/Window.State.cs new file mode 100644 index 0000000..5e7cae1 --- /dev/null +++ b/src/LibTmux/Window.State.cs @@ -0,0 +1,87 @@ +using System.Globalization; +using System.Runtime.Versioning; + +namespace LibTmux; + +// Provides captured window state and refresh. +public sealed partial class Window +{ + /// Gets the window name captured with this handle. + /// + /// The window was resolved by identifier rather than materialized. + /// + public string Name => + ReadSnapshot("window_name") + ?? throw new IncompleteSnapshotException("name", SnapshotDepth.Windows); + + /// Gets the index this window holds in its session. + /// + /// The window was resolved by identifier rather than materialized. + /// + /// + /// A window linked into several sessions holds a different index in each, + /// so this is the index of the session this handle was read through. + /// + public int Index => ReadCapturedInt("window_index", "index"); + + /// Gets the window height captured with this handle. + /// + /// The window was resolved by identifier rather than materialized. + /// + public int Height => ReadCapturedInt("window_height", "height"); + + /// Gets the window width captured with this handle. + /// + /// The window was resolved by identifier rather than materialized. + /// + public int Width => ReadCapturedInt("window_width", "width"); + + /// Gets the server that owns this window. + /// + /// The window was resolved by identifier rather than materialized. + /// + public Server Server => RequireOwner("server"); + + /// Gets the session this window was read through. + /// + /// The window was resolved by identifier rather than materialized. + /// + [UnsupportedOSPlatform("windows")] + public Session Session => + SessionId.TryParse(ReadSnapshot("session_id"), out SessionId id) + ? new Session(RequireConnection(), _generation, id) + : throw new IncompleteSnapshotException("session", SnapshotDepth.Windows); + + /// Re-reads this window from tmux. + /// Cancels the tmux command. + /// A replacement handle carrying current state. + [UnsupportedOSPlatform("windows")] + public async Task RefreshAsync(CancellationToken cancellationToken = default) + { + // Listing by -t would return the whole session and would fail loudly on + // a window that is already gone, so the whole server is listed and the + // row is selected here. A linked window yields one row per session. + Server owner = RequireOwner("refresh"); + IReadOnlyList> rows = await RelationReader + .ListAsync(owner, "list-windows", ["-a"], cancellationToken) + .ConfigureAwait(false); + IReadOnlyList windows = [.. rows + .Select(row => RelationReader.ToWindow(owner, row)) + .Where(window => window.Id == _id)]; + string? session = ReadSnapshot("session_id"); + return windows.FirstOrDefault(window => window.ReadSnapshot("session_id") == session) + ?? (windows.Count > 0 ? windows[0] : null) + ?? throw new TmuxObjectNotFoundException( + $"tmux no longer has window '{_id}'.", + _id.ToString()); + } + + private int ReadCapturedInt(string wireName, string relation) => + int.TryParse( + ReadSnapshot(wireName), + NumberStyles.None, + CultureInfo.InvariantCulture, + out int value) + ? value + : throw new IncompleteSnapshotException(relation, SnapshotDepth.Windows); +} diff --git a/src/LibTmux/Window.Topology.cs b/src/LibTmux/Window.Topology.cs deleted file mode 100644 index 936d697..0000000 --- a/src/LibTmux/Window.Topology.cs +++ /dev/null @@ -1,1072 +0,0 @@ -using System.Globalization; -using System.Runtime.Versioning; -using LibTmux.Internal; -using Microsoft.Extensions.Logging; - -namespace LibTmux; - -/// Names which way a window's panes rotate. -public enum WindowRotationDirection -{ - /// Rotate panes towards the top of the window. - Up = 0, - - /// Rotate panes towards the bottom of the window. - Down = 1, -} - -// Window mutations return replacements when a truthful handle remains; -// destructive or re-homing operations do not. -public sealed partial class Window -{ - private const string DisplayMessageLiteralCapability = "display_message_literal"; - private const string NewPaneCommandCapability = "new_pane_command"; - private const string SplitWindowEmptyCapability = "split_window_empty"; - private const string SplitWindowAppearanceCapability = "split_window_appearance"; - - // tmux 3.3a crashes its entire server when layout_parse rejects a name, so - // a layout is checked here rather than by the server. These five are known - // to every supported version; the mirrored pair arrived in 3.5. - private static readonly string[] UniversalLayouts = - [ - "even-horizontal", - "even-vertical", - "main-horizontal", - "main-vertical", - "tiled", - ]; - private static readonly string[] MirroredLayouts = - [ - "main-horizontal-mirrored", - "main-vertical-mirrored", - ]; - - /// Gets the window name captured with this handle. - /// - /// The window was resolved by identifier rather than materialized. - /// - public string Name => - ReadSnapshot("window_name") - ?? throw new IncompleteSnapshotException("name", SnapshotDepth.Windows); - - /// Gets the index this window holds in its session. - /// - /// The window was resolved by identifier rather than materialized. - /// - /// - /// A window linked into several sessions holds a different index in each, - /// so this is the index of the session this handle was read through. - /// - public int Index => ReadCapturedInt("window_index", "index"); - - /// Gets the window height captured with this handle. - /// - /// The window was resolved by identifier rather than materialized. - /// - public int Height => ReadCapturedInt("window_height", "height"); - - /// Gets the window width captured with this handle. - /// - /// The window was resolved by identifier rather than materialized. - /// - public int Width => ReadCapturedInt("window_width", "width"); - - /// Gets the server that owns this window. - /// - /// The window was resolved by identifier rather than materialized. - /// - public Server Server => RequireOwner("server"); - - /// Gets the session this window was read through. - /// - /// The window was resolved by identifier rather than materialized. - /// - [UnsupportedOSPlatform("windows")] - public Session Session => - SessionId.TryParse(ReadSnapshot("session_id"), out SessionId id) - ? new Session(RequireConnection(), _generation, id) - : throw new IncompleteSnapshotException("session", SnapshotDepth.Windows); - - /// Re-reads this window from tmux. - /// Cancels the tmux command. - /// A replacement handle carrying current state. - [UnsupportedOSPlatform("windows")] - public async Task RefreshAsync(CancellationToken cancellationToken = default) - { - // Listing by -t would return the whole session and would fail loudly on - // a window that is already gone, so the whole server is listed and the - // row is selected here. A linked window yields one row per session. - Server owner = RequireOwner("refresh"); - IReadOnlyList> rows = await RelationReader - .ListAsync(owner, "list-windows", ["-a"], cancellationToken) - .ConfigureAwait(false); - IReadOnlyList windows = [.. rows - .Select(row => RelationReader.ToWindow(owner, row)) - .Where(window => window.Id == _id)]; - string? session = ReadSnapshot("session_id"); - return windows.FirstOrDefault(window => window.ReadSnapshot("session_id") == session) - ?? (windows.Count > 0 ? windows[0] : null) - ?? throw new TmuxObjectNotFoundException( - $"tmux no longer has window '{_id}'.", - _id.ToString()); - } - - /// Renames this window. - /// The new name. - /// Cancels the tmux command. - /// A replacement handle carrying the new name. - /// - /// tmux expands the name as a format, so a # in it does not survive - /// verbatim. - /// - [UnsupportedOSPlatform("windows")] - public async Task RenameAsync( - string name, - CancellationToken cancellationToken = default) - { - ArgumentException.ThrowIfNullOrWhiteSpace(name); - return await TmuxMutationSequence.RunAsync( - () => RunAsync(["rename-window", "-t", Target, name], cancellationToken), - () => RefreshAsync(cancellationToken)) - .ConfigureAwait(false); - } - - /// Selects this window in its session. - /// Cancels the tmux command. - /// A replacement handle carrying the state after selection. - [UnsupportedOSPlatform("windows")] - public async Task SelectAsync(CancellationToken cancellationToken = default) - { - return await TmuxMutationSequence.RunAsync( - () => RunAsync(["select-window", "-t", Target], cancellationToken), - () => RefreshAsync(cancellationToken)) - .ConfigureAwait(false); - } - - /// Stops this window. - /// Whether every other window in the session is stopped instead. - /// Cancels the tmux command. - [UnsupportedOSPlatform("windows")] - public Task KillAsync(bool allExcept = false, CancellationToken cancellationToken = default) - { - List arguments = ["kill-window"]; - if (allExcept) - { - arguments.Add("-a"); - } - - arguments.Add("-t"); - arguments.Add(Target); - return RunAsync(arguments, cancellationToken); - } - - /// Builds the arguments a link request sends. - /// - /// This stays on the window because the source of the link is the session - /// this handle was read through, which a window resolved by identifier - /// does not know. - /// - /// - /// The window was resolved by identifier, so its source link is unknown. - /// - internal List BuildLinkWindowArguments(LinkWindowRequest request) - { - List arguments = - [ - "link-window", - "-t", - request.TargetIndex is null - ? request.TargetSession - : $"{request.TargetSession}:{request.TargetIndex}", - ]; - if (request.ReplaceExisting) - { - arguments.Add("-k"); - } - - AddDirection(arguments, request.Direction); - if (request.Detach) - { - arguments.Add("-d"); - } - - arguments.Add("-s"); - arguments.Add(SourceLink("link source")); - - return arguments; - } - - /// Links this window into another session. - /// Where the link goes. - /// Cancels the tmux command. - /// - /// The window was resolved by identifier, so its source link is unknown. - /// - [UnsupportedOSPlatform("windows")] - public Task LinkAsync( - LinkWindowRequest request, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(request); - List arguments = BuildLinkWindowArguments(request); - return RunAsync(arguments, cancellationToken); - } - - /// Removes this window's link to the session it was read through. - /// Whether the window dies when this was its last link. - /// Cancels the tmux command. - /// - /// tmux refuses to unlink a window that belongs to only one session unless - /// it is allowed to destroy it. - /// - [UnsupportedOSPlatform("windows")] - public Task UnlinkAsync( - bool killIfLast = false, - CancellationToken cancellationToken = default) - { - List arguments = ["unlink-window"]; - if (killIfLast) - { - arguments.Add("-k"); - } - - arguments.Add("-t"); - arguments.Add(SourceLink("unlink source")); - return RunAsync(arguments, cancellationToken); - } - - /// Builds the arguments a move request sends. - /// - /// This stays on the window because both ends come from the handle: the - /// destination defaults to the session it was read through, and so does - /// the source it moves from. - /// - internal List BuildMoveWindowArguments(MoveWindowRequest request) - { - ArgumentNullException.ThrowIfNull(request); - string session = request.Session ?? CapturedSession("move destination"); - List arguments = ["move-window", "-t", $"{session}:{request.Destination}"]; - AddDirection(arguments, request.Direction); - if (request.NoSelect) - { - arguments.Add("-d"); - } - - if (request.ReplaceExisting) - { - arguments.Add("-k"); - } - - if (request.Renumber) - { - arguments.Add("-r"); - } - - arguments.Add("-s"); - arguments.Add(SourceLink("move source")); - - return arguments; - } - - /// Moves this window to another index or session. - /// Where the window goes. - /// Cancels the tmux command. - /// A replacement handle carrying the state after the move. - [UnsupportedOSPlatform("windows")] - public async Task MoveAsync( - MoveWindowRequest request, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(request); - List arguments = BuildMoveWindowArguments(request); - - return await TmuxMutationSequence.RunAsync( - () => RunAsync(arguments, cancellationToken), - () => RefreshAsync(cancellationToken)) - .ConfigureAwait(false); - } - - /// Swaps this window with another. - /// The window to swap with. - /// Whether the swapped window is left unselected. - /// Cancels the tmux command. - /// - /// A window linked into several sessions resolves to whichever link tmux - /// picks, because a window identifier does not name one. - /// - [UnsupportedOSPlatform("windows")] - public Task SwapAsync( - WindowId target, - bool detach = false, - CancellationToken cancellationToken = default) - { - List arguments = ["swap-window", "-t", Target]; - if (detach) - { - arguments.Add("-d"); - } - - arguments.Add("-s"); - arguments.Add(target.ToString()); - return RunAsync(arguments, cancellationToken); - } - - /// Resizes this window. - /// The size to apply. - /// Cancels the tmux command. - /// A replacement handle carrying the new size. - /// - /// Resizing switches the window's window-size option to manual, so - /// it stops following its clients. - /// - [UnsupportedOSPlatform("windows")] - public async Task ResizeAsync( - ResizeWindowRequest request, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(request); - List arguments = BuildResizeWindowArguments(request); - - return await TmuxMutationSequence.RunAsync( - () => RunAsync(arguments, cancellationToken), - () => RefreshAsync(cancellationToken)) - .ConfigureAwait(false); - } - - /// Rotates the panes in this window. - /// Which way to rotate, or null for tmux's default. - /// Whether a zoomed pane stays zoomed. - /// Cancels the tmux command. - /// A replacement handle carrying the state after rotation. - [UnsupportedOSPlatform("windows")] - public async Task RotateAsync( - WindowRotationDirection? direction = null, - bool keepZoom = false, - CancellationToken cancellationToken = default) - { - List arguments = ["rotate-window", "-t", Target]; - if (direction is WindowRotationDirection rotation) - { - arguments.Add(rotation == WindowRotationDirection.Up ? "-U" : "-D"); - } - - if (keepZoom) - { - arguments.Add("-Z"); - } - - return await TmuxMutationSequence.RunAsync( - () => RunAsync(arguments, cancellationToken), - () => RefreshAsync(cancellationToken)) - .ConfigureAwait(false); - } - - /// Restarts the command running in this window. - /// What to respawn, or null to reuse the original. - /// Cancels the tmux command. - /// - /// tmux refuses to respawn a window that is still running unless the - /// request kills it first, and killing it destroys every pane but one. - /// - [UnsupportedOSPlatform("windows")] - public Task RespawnAsync( - RespawnRequest? request = null, - CancellationToken cancellationToken = default) - { - RespawnRequest options = request ?? new RespawnRequest(); - List arguments = ["respawn-window", "-t", Target]; - if (options.KillExistingProcess) - { - arguments.Add("-k"); - } - - AddValue(arguments, "-c", StartDirectory.Resolve(options.StartDirectory)); - AddEnvironment(arguments, options.Environment); - if (options.Command is not null) - { - arguments.Add(options.Command); - } - - return RunAsync(arguments, cancellationToken); - } - - /// Creates a window next to this one. - /// The window to create. - /// Cancels the tmux command. - /// The created window. - [UnsupportedOSPlatform("windows")] - public async Task CreateWindowAsync( - NewWindowRequest? request = null, - CancellationToken cancellationToken = default) - { - NewWindowRequest options = (request ?? new NewWindowRequest()).WithTargetWindow(Target); - Server owner = RequireOwner("windows"); - List arguments = ["new-window", "-P", "-F", "#{window_id}", "-t", Target]; - if (!options.Attach) - { - arguments.Add("-d"); - } - - if (options.KillExisting) - { - arguments.Add("-k"); - } - - if (options.SelectExisting) - { - arguments.Add("-S"); - } - - AddDirection(arguments, options.Direction); - AddValue(arguments, "-n", options.Name); - AddValue(arguments, "-c", StartDirectory.Resolve(options.StartDirectory)); - AddEnvironment(arguments, options.Environment); - if (options.Command is not null) - { - arguments.Add(options.Command); - } - - var sequence = new TmuxMutationSequence(); - TmuxCommandResult result = await sequence.MutateAsync( - () => _commandDispatcher.ExecuteAsync(arguments, cancellationToken), - static value => TmuxCommandFailure.ThrowIfFailed(value, "new-window")) - .ConfigureAwait(false); - - WindowId created = sequence.Observe(() => - result.StandardOutputLines.Count > 0 - && WindowId.TryParse(result.StandardOutputLines[0], out WindowId parsed) - ? parsed - : throw new InvalidDataException("tmux reported no new window identifier.")); - - IReadOnlyList windows = await sequence - .ObserveAsync(() => owner.GetWindowsAsync(cancellationToken)) - .ConfigureAwait(false); - return sequence.Observe(() => - windows.FirstOrDefault(window => window.Id == created) - ?? throw new TmuxObjectNotFoundException( - $"tmux did not report the created window '{created}'.", - created.ToString())); - } - - internal List BuildResizeWindowArguments(ResizeWindowRequest request) - { - List arguments = ["resize-window", "-t", Target]; - if (request.Direction is ResizeDirection direction) - { - arguments.Add(CommandFlagCatalog.GetResizeDirectionFlag(direction)); - } - - AddValue(arguments, "-x", request.Width); - AddValue(arguments, "-y", request.Height); - if (request.Mode is WindowResizeMode mode) - { - arguments.Add(mode == WindowResizeMode.Expand ? "-A" : "-a"); - } - - // tmux takes the adjustment as the trailing positional; as a flag value - // it would be read as a second argument and refused. - if (request.Adjustment is int adjustment) - { - arguments.Add(adjustment.ToString(CultureInfo.InvariantCulture)); - } - - return arguments; - } - - /// Builds the arguments a layout request sends. - /// - /// This stays on the window rather than becoming a static helper because - /// validating a layout name asks the running tmux which names it knows, - /// and an unrecognised name takes the whole server down on 3.3a. A chained - /// layout has to be checked the same way a direct one is. - /// - internal List BuildSelectLayoutArguments(SelectLayoutRequest request) - { - List arguments = ["select-layout", "-t", Target]; - if (request.Mode is SelectLayoutMode mode) - { - arguments.Add(mode switch - { - SelectLayoutMode.Spread => "-E", - SelectLayoutMode.Next => "-n", - _ => "-p", - }); - } - - if (request.Layout is not null) - { - ValidateLayout(request.Layout); - arguments.Add(request.Layout); - } - - return arguments; - } - - /// Applies a layout to this window. - /// The layout to apply. - /// Cancels the tmux command. - /// A replacement handle carrying the new layout. - /// - /// The layout is one tmux may not recognise. - /// - [UnsupportedOSPlatform("windows")] - public async Task SelectLayoutAsync( - SelectLayoutRequest? request = null, - CancellationToken cancellationToken = default) - { - SelectLayoutRequest options = request ?? new SelectLayoutRequest(); - List arguments = BuildSelectLayoutArguments(options); - - return await TmuxMutationSequence.RunAsync( - () => RunAsync(arguments, cancellationToken), - () => RefreshAsync(cancellationToken)) - .ConfigureAwait(false); - } - - /// Moves to the next layout. - /// Cancels the tmux command. - /// A replacement handle carrying the new layout. - [UnsupportedOSPlatform("windows")] - public async Task SelectNextLayoutAsync( - CancellationToken cancellationToken = default) - { - return await TmuxMutationSequence.RunAsync( - () => RunAsync(["next-layout", "-t", Target], cancellationToken), - () => RefreshAsync(cancellationToken)) - .ConfigureAwait(false); - } - - /// Moves to the previous layout. - /// Cancels the tmux command. - /// A replacement handle carrying the new layout. - [UnsupportedOSPlatform("windows")] - public async Task SelectPreviousLayoutAsync( - CancellationToken cancellationToken = default) - { - return await TmuxMutationSequence.RunAsync( - () => RunAsync(["previous-layout", "-t", Target], cancellationToken), - () => RefreshAsync(cancellationToken)) - .ConfigureAwait(false); - } - - /// Selects a pane in this window. - /// A pane target, or a direction such as -U. - /// Cancels the tmux command. - /// The pane that is active afterwards, or null when none is. - [UnsupportedOSPlatform("windows")] - public async Task SelectPaneAsync( - string target, - CancellationToken cancellationToken = default) - { - ArgumentException.ThrowIfNullOrWhiteSpace(target); - // A bare pane index would resolve against the caller's current session, - // so anything that is not a direction flag is anchored to this window. - List arguments = target is "-l" or "-U" or "-D" or "-L" or "-R" - ? ["select-pane", "-t", Target, target] - : ["select-pane", "-t", $"{Target}.{target}"]; - return await TmuxMutationSequence.RunAsync( - () => RunAsync(arguments, cancellationToken), - async () => - { - IReadOnlyList panes = await GetPanesAsync(cancellationToken) - .ConfigureAwait(false); - return panes.FirstOrDefault(pane => pane.Snapshot?["pane_active"] == "1"); - }) - .ConfigureAwait(false); - } - - /// Reads one pane in this window. - /// The pane target. - /// Cancels the tmux command. - /// The pane, or null when this window has no such pane. - [UnsupportedOSPlatform("windows")] - public async Task GetPaneAsync( - string target, - CancellationToken cancellationToken = default) - { - ArgumentException.ThrowIfNullOrWhiteSpace(target); - IReadOnlyList panes = await GetPanesAsync(cancellationToken).ConfigureAwait(false); - return panes.FirstOrDefault(pane => - pane.Id.ToString() == target - || pane.Snapshot?["pane_index"] == target); - } - - /// Splits a pane in this window. - /// How to split. - /// Cancels the tmux command. - /// The created pane. - [UnsupportedOSPlatform("windows")] - public async Task SplitPaneAsync( - SplitPaneRequest? request = null, - CancellationToken cancellationToken = default) - { - SplitPaneRequest options = request ?? new SplitPaneRequest(); - Server owner = RequireOwner("panes"); - List arguments = - [ - "split-window", - "-P", - "-F", - "#{pane_id}", - "-t", - options.Target is null ? Target : $"{Target}.{options.Target}", - ]; - foreach (string flag in CommandFlagCatalog.GetPaneDirectionFlags( - options.Direction ?? PaneDirection.Below)) - { - arguments.Add(flag); - } - - // tmux 3.4 misreads -p, so a percentage rides the -l flag instead, - // which every supported version accepts. - AddValue( - arguments, - "-l", - options.Percentage is int share - ? string.Create(CultureInfo.InvariantCulture, $"{share}%") - : options.Size); - if (options.FullWindow) - { - arguments.Add("-f"); - } - - if (options.Zoom) - { - arguments.Add("-Z"); - } - - if (!options.Attach) - { - arguments.Add("-d"); - } - - AddValue(arguments, "-c", StartDirectory.Resolve(options.StartDirectory)); - AddEnvironment(arguments, options.Environment); - AddSplitAppearance(arguments, options); - if (options.Command is not null) - { - arguments.Add(options.Command); - } - - var sequence = new TmuxMutationSequence(); - TmuxCommandResult result = await sequence.MutateAsync( - () => _commandDispatcher.ExecuteAsync(arguments, cancellationToken), - static value => TmuxCommandFailure.ThrowIfFailed(value, "split-window")) - .ConfigureAwait(false); - PaneId created = sequence.Observe(() => - result.StandardOutputLines.Count > 0 - && PaneId.TryParse(result.StandardOutputLines[0], out PaneId parsed) - ? parsed - : throw new InvalidDataException("tmux reported no new pane identifier.")); - - IReadOnlyList panes = await sequence - .ObserveAsync(() => GetPanesAsync(cancellationToken)) - .ConfigureAwait(false); - return sequence.Observe(() => - panes.FirstOrDefault(pane => pane.Id == created) - ?? throw new TmuxObjectNotFoundException( - $"tmux did not report the created pane '{created}'.", - created.ToString())); - } - - /// Creates a floating pane in this window. - /// The pane to create. - /// Cancels the tmux command. - /// The created pane. - /// - /// The server predates tmux 3.7, which introduced the command. - /// - [UnsupportedOSPlatform("windows")] - public async Task CreatePaneAsync( - NewPaneRequest? request = null, - CancellationToken cancellationToken = default) - { - NewPaneRequest options = request ?? new NewPaneRequest(); - Server owner = RequireOwner("panes"); - // The whole command is missing before 3.7, so there is nothing to omit - // and nothing worth dispatching. - if (!Supports(owner, NewPaneCommandCapability)) - { - throw new TmuxVersionTooLowException( - "new-pane requires tmux 3.7.", - TmuxVersion.Parse("3.7"), - owner.Version ?? default); - } - - List arguments = - [ - "new-pane", - "-P", - "-F", - "#{pane_id}", - "-t", - options.Target is null ? Target : $"{Target}.{options.Target}", - ]; - if (!options.Attach) - { - arguments.Add("-d"); - } - - AddValue(arguments, "-x", options.Width); - AddValue(arguments, "-y", options.Height); - AddValue(arguments, "-X", options.X); - AddValue(arguments, "-Y", options.Y); - if (options.Zoom) - { - arguments.Add("-Z"); - } - - AddValue(arguments, "-c", StartDirectory.Resolve(options.StartDirectory)); - AddEnvironment(arguments, options.Environment); - if (options.Empty) - { - arguments.Add("-E"); - } - - AddValue(arguments, "-s", options.Style); - AddValue(arguments, "-S", options.ActiveBorderStyle); - AddValue(arguments, "-R", options.InactiveBorderStyle); - AddValue(arguments, "-m", options.Message); - if (options.KeepOpen) - { - arguments.Add("-k"); - } - - if (options.Command is not null) - { - arguments.Add(options.Command); - } - - var sequence = new TmuxMutationSequence(); - TmuxCommandResult result = await sequence.MutateAsync( - () => _commandDispatcher.ExecuteAsync(arguments, cancellationToken), - static value => TmuxCommandFailure.ThrowIfFailed(value, "new-pane")) - .ConfigureAwait(false); - PaneId created = sequence.Observe(() => - result.StandardOutputLines.Count > 0 - && PaneId.TryParse(result.StandardOutputLines[0], out PaneId parsed) - ? parsed - : throw new InvalidDataException("tmux reported no new pane identifier.")); - - IReadOnlyList panes = await sequence - .ObserveAsync(() => GetPanesAsync(cancellationToken)) - .ConfigureAwait(false); - return sequence.Observe(() => - panes.FirstOrDefault(pane => pane.Id == created) - ?? throw new TmuxObjectNotFoundException( - $"tmux did not report the created pane '{created}'.", - created.ToString())); - } - - /// Runs a tmux-side filter over this window's panes. - /// The raw tmux filter expression. - /// Cancels the tmux command. - /// The panes tmux kept. - [UnsupportedOSPlatform("windows")] - public async Task> SearchPanesAsync( - UnsafeTmuxFilter filter, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(filter); - Server owner = RequireOwner("panes"); - IReadOnlyList> rows = await RelationReader - .ListAsync( - owner, - "list-panes", - ["-t", Target, "-f", filter.Value], - cancellationToken) - .ConfigureAwait(false); - return [.. rows.Select(row => RelationReader.ToPane(owner, row))]; - } - - /// Shows a message on the client viewing this window. - /// The message to show. - /// Cancels the tmux command. - /// The printed lines when the request asked for them, else null. - /// - /// The request asks to redraw the pane, which only a pane can honour. - /// - /// - /// A message with no client to show it on is not a failure, so tmux's - /// complaint is logged rather than raised. - /// - [UnsupportedOSPlatform("windows")] - public async Task?> DisplayMessageAsync( - DisplayMessageRequest request, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(request); - if (request.UpdatePane) - { - throw new ArgumentException( - "Redrawing while a message is shown is pane-scoped.", - nameof(request)); - } - - Server owner = RequireOwner("display"); - if (request.TargetClient is not null - && owner.Version is TmuxVersion version - && version < TmuxVersion.Parse("3.3a")) - { - // tmux 3.2a declares the flag without a value, so naming a client - // there would silently address a different one. - throw new TmuxVersionTooLowException( - "Naming a display-message client requires tmux 3.3a.", - TmuxVersion.Parse("3.3a"), - owner.Version ?? default); - } - - List arguments = ["display-message", "-t", Target]; - if (request.ReturnText) - { - arguments.Add("-p"); - } - - if (request.AllFormats) - { - arguments.Add("-a"); - } - - if (request.Verbose) - { - arguments.Add("-v"); - } - - if (request.NoExpand && RequireLiteralMessages(owner)) - { - arguments.Add("-l"); - } - - if (request.Notify) - { - arguments.Add("-N"); - } - - AddValue(arguments, "-c", request.TargetClient); - AddValue( - arguments, - "-d", - request.Delay is TimeSpan delay - ? ((long)delay.TotalMilliseconds).ToString(CultureInfo.InvariantCulture) - : null); - AddValue(arguments, "-F", request.Format); - if (request.Message.Length > 0) - { - arguments.Add(request.Message); - } - - TmuxCommandResult result = await _commandDispatcher - .ExecuteAsync(arguments, cancellationToken) - .ConfigureAwait(false); - if (result.StandardErrorLines.Count > 0 - && owner.Connection?.Options.Logger is ILogger logger) - { - LogDisplayMessageRefused(logger, string.Join('\n', result.StandardErrorLines)); - } - - return request.ReturnText ? result.StandardOutputLines : null; - } - - private static void AddDirection(List arguments, WindowDirection? direction) - { - if (direction is WindowDirection value) - { - arguments.Add(CommandFlagCatalog.GetWindowDirectionFlag(value)); - } - } - - private static void AddValue(List arguments, string flag, string? value) - { - if (!string.IsNullOrEmpty(value)) - { - arguments.Add(flag); - arguments.Add(value); - } - } - - private static void AddValue(List arguments, string flag, int? value) - { - if (value is int cells) - { - arguments.Add(flag); - arguments.Add(cells.ToString(CultureInfo.InvariantCulture)); - } - } - - private static void AddEnvironment( - List arguments, - IReadOnlyDictionary? environment) - { - if (environment is null) - { - return; - } - - foreach ((string key, string value) in environment) - { - arguments.Add("-e"); - arguments.Add($"{key}={value}"); - } - } - - private static bool Supports(Server owner, string capability) => - owner.Version is TmuxVersion version - && TmuxCapabilities.IsSupported(version, capability); - - [LoggerMessage( - EventId = 2, - Level = LogLevel.Warning, - Message = "literal message flag omitted, tmux {TmuxVersion} does not carry it")] - private static partial void LogLiteralUnsupported(ILogger logger, string? tmuxVersion); - - [LoggerMessage( - EventId = 3, - Level = LogLevel.Warning, - Message = "split appearance flags omitted, tmux {TmuxVersion} does not carry them")] - private static partial void LogSplitAppearanceUnsupported(ILogger logger, string? tmuxVersion); - - [LoggerMessage( - EventId = 4, - Level = LogLevel.Warning, - Message = "empty split flag omitted, tmux {TmuxVersion} does not carry it")] - private static partial void LogSplitEmptyUnsupported(ILogger logger, string? tmuxVersion); - - [LoggerMessage( - EventId = 5, - Level = LogLevel.Warning, - Message = "tmux refused to display the message: {TmuxError}")] - private static partial void LogDisplayMessageRefused(ILogger logger, string tmuxError); - - private static void Warn(Server owner, Action log) - { - if (owner.Connection?.Options.Logger is ILogger logger) - { - log(logger, owner.RawVersion); - } - } - - // The version comes from state captured when the handle materialized, so - // gating costs no extra tmux command and the call still dispatches once. - private static bool RequireLiteralMessages(Server owner) - { - if (Supports(owner, DisplayMessageLiteralCapability)) - { - return true; - } - - Warn(owner, LogLiteralUnsupported); - return false; - } - - private void AddSplitAppearance(List arguments, SplitPaneRequest options) - { - Server owner = RequireOwner("panes"); - if (options.Empty) - { - if (Supports(owner, SplitWindowEmptyCapability)) - { - arguments.Add("-E"); - } - else - { - Warn(owner, LogSplitEmptyUnsupported); - } - } - - bool wantsAppearance = options.Style is not null - || options.ActiveBorderStyle is not null - || options.InactiveBorderStyle is not null - || options.Message is not null - || options.KeepOpen; - if (!wantsAppearance) - { - return; - } - - if (!Supports(owner, SplitWindowAppearanceCapability)) - { - Warn(owner, LogSplitAppearanceUnsupported); - return; - } - - AddValue(arguments, "-s", options.Style); - AddValue(arguments, "-S", options.ActiveBorderStyle); - AddValue(arguments, "-R", options.InactiveBorderStyle); - AddValue(arguments, "-m", options.Message); - if (options.KeepOpen) - { - arguments.Add("-k"); - } - } - - private void ValidateLayout(string layout) - { - if (layout.Length == 0) - { - throw new TmuxWindowException("A layout name cannot be empty.", _id); - } - - // A layout tmux dumped begins with a four-digit hexadecimal checksum, - // and every version parses those. Named layouts are checked against the - // set the running tmux knows. - if (HasCustomLayoutPrefix(layout) - || UniversalLayouts.Contains(layout, StringComparer.Ordinal)) - { - return; - } - - Server owner = RequireOwner("layout"); - bool mirroredKnown = owner.Version is TmuxVersion version - && version >= TmuxVersion.Parse("3.5"); - if (mirroredKnown && MirroredLayouts.Contains(layout, StringComparer.Ordinal)) - { - return; - } - - throw new TmuxWindowException( - $"tmux {owner.RawVersion} does not know the layout '{layout}'.", - _id); - } - - private static bool HasCustomLayoutPrefix(string layout) => - layout.Length > 5 - && layout[4] == ',' - && char.IsAsciiHexDigit(layout[0]) - && char.IsAsciiHexDigit(layout[1]) - && char.IsAsciiHexDigit(layout[2]) - && char.IsAsciiHexDigit(layout[3]); - - private string Target => _id.ToString(); - - // A bare window id lets tmux choose which link it means, so any operation - // that moves a link names the session it belongs to as well. - private string SourceLink(string relation) - { - string session = CapturedSession(relation); - string index = ReadSnapshot("window_index") - ?? throw new IncompleteSnapshotException(relation, SnapshotDepth.Windows); - return $"{session}:{index}"; - } - - private string CapturedSession(string relation) => - ReadSnapshot("session_id") - ?? throw new IncompleteSnapshotException(relation, SnapshotDepth.Windows); - - private int ReadCapturedInt(string wireName, string relation) => - int.TryParse( - ReadSnapshot(wireName), - NumberStyles.None, - CultureInfo.InvariantCulture, - out int value) - ? value - : throw new IncompleteSnapshotException(relation, SnapshotDepth.Windows); - - [UnsupportedOSPlatform("windows")] - private async Task RunAsync(List arguments, CancellationToken cancellationToken) - { - TmuxCommandResult result = await _commandDispatcher - .ExecuteAsync(arguments, cancellationToken) - .ConfigureAwait(false); - TmuxCommandFailure.ThrowIfFailed(result, arguments[0]); - } -} From c033cecfdba16eb83e1d3dd4002b3c81244be8f8 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 14:45:54 -0500 Subject: [PATCH 063/129] Chaining(refactor[files]): Split request domains why: TmuxChaining mixed 83 overloads for seven independent request domains in one 1,391-line catalog. what: - keep the public extension type partial and group overloads by request owner - retain one shared command constructor in the documented root partial - preserve every public signature and request builder path --- src/LibTmux/Chaining/TmuxChaining.Hooks.cs | 175 +++ src/LibTmux/Chaining/TmuxChaining.Keys.cs | 61 + src/LibTmux/Chaining/TmuxChaining.Options.cs | 155 ++ src/LibTmux/Chaining/TmuxChaining.Panes.cs | 480 ++++++ src/LibTmux/Chaining/TmuxChaining.Server.cs | 295 ++++ src/LibTmux/Chaining/TmuxChaining.Sessions.cs | 77 + src/LibTmux/Chaining/TmuxChaining.Windows.cs | 173 +++ src/LibTmux/Chaining/TmuxChaining.cs | 1371 +---------------- 8 files changed, 1417 insertions(+), 1370 deletions(-) create mode 100644 src/LibTmux/Chaining/TmuxChaining.Hooks.cs create mode 100644 src/LibTmux/Chaining/TmuxChaining.Keys.cs create mode 100644 src/LibTmux/Chaining/TmuxChaining.Options.cs create mode 100644 src/LibTmux/Chaining/TmuxChaining.Panes.cs create mode 100644 src/LibTmux/Chaining/TmuxChaining.Server.cs create mode 100644 src/LibTmux/Chaining/TmuxChaining.Sessions.cs create mode 100644 src/LibTmux/Chaining/TmuxChaining.Windows.cs diff --git a/src/LibTmux/Chaining/TmuxChaining.Hooks.cs b/src/LibTmux/Chaining/TmuxChaining.Hooks.cs new file mode 100644 index 0000000..8c95e4c --- /dev/null +++ b/src/LibTmux/Chaining/TmuxChaining.Hooks.cs @@ -0,0 +1,175 @@ +using System.Runtime.Versioning; + +namespace LibTmux; + +// Builds and executes hook requests. +public static partial class TmuxChaining +{ + /// Returns a hook request as one tmux command. + /// Which hook to set, and to what. + /// The hooks handle whose scope the hook is set in. + /// The command, ready to add to a . + /// An argument is null. + [UnsupportedOSPlatform("windows")] + public static TmuxCommand ToCommand(this SetHookRequest request, TmuxHooks hooks) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(hooks); + return Command([.. hooks.BuildSetArguments(request)]); + } + + /// Runs a hook request on its own. + /// Which hook to set, and to what. + /// The hooks handle whose scope the hook is set in. + /// The server the hook is set on. + /// Cancels the tmux command. + /// What tmux printed, which is nothing for an ordinary hook. + /// An argument is null. + /// tmux reported the command failed. + [UnsupportedOSPlatform("windows")] + public static Task ExecuteAsync( + this SetHookRequest request, + TmuxHooks hooks, + Server server, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(server); + return server.Chain().Then(request.ToCommand(hooks)).ExecuteAsync(cancellationToken); + } + + /// Returns a hook listing as one tmux command. + /// Which scope to list. + /// The hooks handle whose scope is listed. + /// The command, ready to add to a . + /// + /// A chain returns one combined output stream, so batch a listing to see + /// what the same invocation just installed. + /// answers the same question with the hooks already parsed. + /// + /// An argument is null. + [UnsupportedOSPlatform("windows")] + public static TmuxCommand ToCommand(this ListHooksRequest request, TmuxHooks hooks) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(hooks); + return Command([.. hooks.BuildListArguments(request)]); + } + + /// Runs a hook listing on its own. + /// Which scope to list. + /// The hooks handle whose scope is listed. + /// The server the hooks are read from. + /// Cancels the tmux command. + /// What tmux printed, unparsed. + /// An argument is null. + /// tmux reported the command failed. + [UnsupportedOSPlatform("windows")] + public static Task ExecuteAsync( + this ListHooksRequest request, + TmuxHooks hooks, + Server server, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(server); + return server.Chain().Then(request.ToCommand(hooks)).ExecuteAsync(cancellationToken); + } + + /// Returns running a hook as one tmux command. + /// Which hook to run. + /// The hooks handle whose scope holds it. + /// The command, ready to add to a . + /// + /// A hook request names a hook without saying what to do with it, so + /// running and removing are separate here rather than one call that has to + /// guess which was meant. + /// + /// An argument is null. + [UnsupportedOSPlatform("windows")] + public static TmuxCommand ToRunCommand(this HookRequest request, TmuxHooks hooks) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(hooks); + return Command([.. hooks.BuildRunArguments(request)]); + } + + /// Returns removing a hook as one tmux command. + /// Which hook to remove. + /// The hooks handle whose scope holds it. + /// The command, ready to add to a . + /// An argument is null. + [UnsupportedOSPlatform("windows")] + public static TmuxCommand ToUnsetCommand(this HookRequest request, TmuxHooks hooks) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(hooks); + return Command([.. hooks.BuildUnsetArguments(request)]); + } + + /// Runs a hook on its own. + /// Which hook to run. + /// The hooks handle whose scope holds it. + /// The server the hook runs on. + /// Cancels the tmux command. + /// What tmux printed, which is nothing for an ordinary run. + /// An argument is null. + /// tmux reported the command failed. + [UnsupportedOSPlatform("windows")] + public static Task ExecuteAsync( + this HookRequest request, + TmuxHooks hooks, + Server server, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(server); + return server.Chain().Then(request.ToRunCommand(hooks)).ExecuteAsync(cancellationToken); + } + + /// Returns every command a multi-entry hook request sends. + /// Which hook to set, and to what entries. + /// The hooks handle whose scope holds it. + /// The commands, in the order tmux must receive them. + /// + /// This request is several tmux commands rather than one, so it answers a + /// list. Running them one at a time is what the one-shot path does; adding + /// them to a chain is what this is for. + /// + /// An argument is null. + [UnsupportedOSPlatform("windows")] + public static IReadOnlyList ToCommands( + this SetHooksRequest request, + TmuxHooks hooks) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(hooks); + return [.. hooks.BuildSetAllArguments(request).Select(arguments => Command([.. arguments]))]; + } + + /// Runs a multi-entry hook request in one invocation. + /// Which hook to set, and to what entries. + /// The hooks handle whose scope holds it. + /// The server the hook is set on. + /// Cancels the tmux command. + /// What that one invocation produced. + /// + /// The one-shot path sends these one process at a time, so this is the + /// case batching helps most. + /// + /// An argument is null. + /// tmux reported the run failed. + [UnsupportedOSPlatform("windows")] + public static Task ExecuteAsync( + this SetHooksRequest request, + TmuxHooks hooks, + Server server, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(server); + TmuxChain chain = server.Chain(); + foreach (TmuxCommand command in request.ToCommands(hooks)) + { + chain = chain.Then(command); + } + + return chain.ExecuteAsync(cancellationToken); + } +} diff --git a/src/LibTmux/Chaining/TmuxChaining.Keys.cs b/src/LibTmux/Chaining/TmuxChaining.Keys.cs new file mode 100644 index 0000000..2b4f1a4 --- /dev/null +++ b/src/LibTmux/Chaining/TmuxChaining.Keys.cs @@ -0,0 +1,61 @@ +using System.Runtime.Versioning; + +namespace LibTmux; + +// Builds and executes key-binding requests. +public static partial class TmuxChaining +{ + /// Returns a key-binding request as one tmux command. + /// The binding to add. + /// The command, ready to add to a . + /// is null. + public static TmuxCommand ToCommand(this BindKeyRequest request) + { + ArgumentNullException.ThrowIfNull(request); + return Command([.. Server.BuildBindKeyArguments(request)]); + } + + /// Returns a key-unbinding request as one tmux command. + /// The binding to remove. + /// The command, ready to add to a . + /// is null. + public static TmuxCommand ToCommand(this UnbindKeyRequest request) + { + ArgumentNullException.ThrowIfNull(request); + return Command([.. Server.BuildUnbindKeyArguments(request)]); + } + + /// Runs a key-binding request on its own. + /// The binding to add. + /// The server to bind on. + /// Cancels the tmux command. + /// What tmux printed, which is nothing for an ordinary bind. + /// An argument is null. + /// tmux reported the command failed. + [UnsupportedOSPlatform("windows")] + public static Task ExecuteAsync( + this BindKeyRequest request, + Server server, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(server); + return server.Chain().Then(request.ToCommand()).ExecuteAsync(cancellationToken); + } + + /// Runs a key-unbinding request on its own. + /// The binding to remove. + /// The server to unbind on. + /// Cancels the tmux command. + /// What tmux printed, which is nothing for an ordinary unbind. + /// An argument is null. + /// tmux reported the command failed. + [UnsupportedOSPlatform("windows")] + public static Task ExecuteAsync( + this UnbindKeyRequest request, + Server server, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(server); + return server.Chain().Then(request.ToCommand()).ExecuteAsync(cancellationToken); + } +} diff --git a/src/LibTmux/Chaining/TmuxChaining.Options.cs b/src/LibTmux/Chaining/TmuxChaining.Options.cs new file mode 100644 index 0000000..1f1af97 --- /dev/null +++ b/src/LibTmux/Chaining/TmuxChaining.Options.cs @@ -0,0 +1,155 @@ +using System.Runtime.Versioning; + +namespace LibTmux; + +// Builds and executes option requests. +public static partial class TmuxChaining +{ + /// Returns an option request as one tmux command. + /// Which option to set, and to what. + /// The options handle whose scope the option is set in. + /// The command, ready to add to a . + /// + /// This takes the options handle rather than a server, because which + /// scope flags and target tmux receives follow from the handle the caller + /// reached for: a window's options and a server's are the same request + /// spelled differently. + /// + /// An argument is null. + [UnsupportedOSPlatform("windows")] + public static TmuxCommand ToCommand(this SetOptionRequest request, TmuxOptions options) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(options); + return Command([.. options.BuildSetArguments(request)]); + } + + /// Runs an option request on its own. + /// Which option to set, and to what. + /// The options handle whose scope the option is set in. + /// The server the option is set on. + /// Cancels the tmux command. + /// What tmux printed, which is nothing for an ordinary set. + /// An argument is null. + /// tmux reported the command failed. + [UnsupportedOSPlatform("windows")] + public static Task ExecuteAsync( + this SetOptionRequest request, + TmuxOptions options, + Server server, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(server); + return server.Chain().Then(request.ToCommand(options)).ExecuteAsync(cancellationToken); + } + + /// Returns an unset request as one tmux command. + /// Which option to unset, and how. + /// The options handle whose scope the option is unset in. + /// The command, ready to add to a . + /// An argument is null. + [UnsupportedOSPlatform("windows")] + public static TmuxCommand ToCommand(this UnsetOptionRequest request, TmuxOptions options) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(options); + return Command([.. options.BuildUnsetArguments(request)]); + } + + /// Runs an unset request on its own. + /// Which option to unset, and how. + /// The options handle whose scope the option is unset in. + /// The server the option is unset on. + /// Cancels the tmux command. + /// What tmux printed, which is nothing for an ordinary unset. + /// An argument is null. + /// tmux reported the command failed. + [UnsupportedOSPlatform("windows")] + public static Task ExecuteAsync( + this UnsetOptionRequest request, + TmuxOptions options, + Server server, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(server); + return server.Chain().Then(request.ToCommand(options)).ExecuteAsync(cancellationToken); + } + + /// Returns a named option read as one tmux command. + /// Which option to read. + /// The options handle whose scope is read. + /// The command, ready to add to a . + /// + /// A chain returns one combined output stream, so several reads batched + /// together arrive undelimited. Reach for this to read something beside + /// the changes a chain makes; reach for the handle's own accessor when + /// what you want is a parsed value. + /// + /// An argument is null. + [UnsupportedOSPlatform("windows")] + public static TmuxCommand ToCommand(this GetOptionRequest request, TmuxOptions options) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(options); + return Command([.. options.BuildGetArguments(request)]); + } + + /// Runs a named option read on its own. + /// Which option to read. + /// The options handle whose scope is read. + /// The server the option is read from. + /// Cancels the tmux command. + /// What tmux printed, unparsed. + /// + /// answers the same question with the + /// value already parsed, and is what most callers want. + /// + /// An argument is null. + /// tmux reported the command failed. + [UnsupportedOSPlatform("windows")] + public static Task ExecuteAsync( + this GetOptionRequest request, + TmuxOptions options, + Server server, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(server); + return server.Chain().Then(request.ToCommand(options)).ExecuteAsync(cancellationToken); + } + + /// Returns a whole-scope option read as one tmux command. + /// How the scope is read. + /// The options handle whose scope is read. + /// The command, ready to add to a . + /// An argument is null. + [UnsupportedOSPlatform("windows")] + public static TmuxCommand ToCommand(this GetOptionsRequest request, TmuxOptions options) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(options); + return Command([.. options.BuildGetAllArguments(request)]); + } + + /// Runs a whole-scope option read on its own. + /// How the scope is read. + /// The options handle whose scope is read. + /// The server the options are read from. + /// Cancels the tmux command. + /// What tmux printed, unparsed. + /// + /// answers the same question with + /// the values already parsed, and is what most callers want. + /// + /// An argument is null. + /// tmux reported the command failed. + [UnsupportedOSPlatform("windows")] + public static Task ExecuteAsync( + this GetOptionsRequest request, + TmuxOptions options, + Server server, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(server); + return server.Chain().Then(request.ToCommand(options)).ExecuteAsync(cancellationToken); + } +} diff --git a/src/LibTmux/Chaining/TmuxChaining.Panes.cs b/src/LibTmux/Chaining/TmuxChaining.Panes.cs new file mode 100644 index 0000000..fd2d81c --- /dev/null +++ b/src/LibTmux/Chaining/TmuxChaining.Panes.cs @@ -0,0 +1,480 @@ +using System.Runtime.Versioning; + +namespace LibTmux; + +// Builds and executes pane requests. +public static partial class TmuxChaining +{ + /// Returns a key request as one tmux command for a pane. + /// The keys to send. + /// The pane that receives them. + /// The command, ready to add to a . + /// An argument is null. + public static TmuxCommand ToCommand(this SendKeysRequest request, Pane pane) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(pane); + + // The pane ID travels into the chain as plain text, so RequiredGeneration + // pins it: after a restart, that ID could name a different pane. + return Command([.. pane.BuildSendKeysArguments(request)]) with + { + RequiredGeneration = pane.Generation, + }; + } + + /// Returns a pane-selection request as one tmux command. + /// Which pane to select, and how. + /// The pane the selection is relative to. + /// The command, ready to add to a . + /// An argument is null. + public static TmuxCommand ToCommand(this SelectPaneRequest request, Pane pane) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(pane); + return Command([.. pane.BuildSelectPaneArguments(request)]); + } + + /// Runs a pane-selection request on its own. + /// Which pane to select, and how. + /// The pane the selection is relative to. + /// Cancels the tmux command. + /// What tmux printed, which is nothing for an ordinary selection. + /// An argument is null. + /// tmux reported the command failed. + [UnsupportedOSPlatform("windows")] + public static Task ExecuteAsync( + this SelectPaneRequest request, + Pane pane, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(pane); + return pane.Server.Chain().Then(request.ToCommand(pane)).ExecuteAsync(cancellationToken); + } + + /// Returns a pane-resize request as one tmux command. + /// How to resize. + /// The pane being resized. + /// The command, ready to add to a . + /// An argument is null. + public static TmuxCommand ToCommand(this ResizePaneRequest request, Pane pane) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(pane); + return Command([.. pane.BuildResizePaneArguments(request)]); + } + + /// Runs a pane-resize request on its own. + /// How to resize. + /// The pane being resized. + /// Cancels the tmux command. + /// What tmux printed, which is nothing for an ordinary resize. + /// An argument is null. + /// tmux reported the command failed. + [UnsupportedOSPlatform("windows")] + public static Task ExecuteAsync( + this ResizePaneRequest request, + Pane pane, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(pane); + return pane.Server.Chain().Then(request.ToCommand(pane)).ExecuteAsync(cancellationToken); + } + + /// Returns a window-search request as one tmux command. + /// What to look for. + /// The pane the search starts from. + /// The command, ready to add to a . + /// An argument is null. + public static TmuxCommand ToCommand(this FindWindowRequest request, Pane pane) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(pane); + return Command([.. pane.BuildFindWindowArguments(request)]); + } + + /// Runs a window-search request on its own. + /// What to look for. + /// The pane the search starts from. + /// Cancels the tmux command. + /// What tmux printed, which is nothing for an ordinary search. + /// An argument is null. + /// tmux reported the command failed. + [UnsupportedOSPlatform("windows")] + public static Task ExecuteAsync( + this FindWindowRequest request, + Pane pane, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(pane); + return pane.Server.Chain().Then(request.ToCommand(pane)).ExecuteAsync(cancellationToken); + } + + /// Returns a pane-swap request as one tmux command. + /// Which pane to swap with, and how. + /// The pane being swapped. + /// The command, ready to add to a . + /// An argument is null. + public static TmuxCommand ToCommand(this SwapPaneRequest request, Pane pane) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(pane); + return Command([.. pane.BuildSwapPaneArguments(request)]); + } + + /// Runs a pane-swap request on its own. + /// Which pane to swap with, and how. + /// The pane being swapped. + /// Cancels the tmux command. + /// What tmux printed, which is nothing for an ordinary swap. + /// An argument is null. + /// tmux reported the command failed. + [UnsupportedOSPlatform("windows")] + public static Task ExecuteAsync( + this SwapPaneRequest request, + Pane pane, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(pane); + return pane.Server.Chain().Then(request.ToCommand(pane)).ExecuteAsync(cancellationToken); + } + + /// Returns a pane-piping request as one tmux command. + /// What to pipe, and which way. + /// The pane being piped. + /// The command, ready to add to a . + /// An argument is null. + public static TmuxCommand ToCommand(this PipePaneRequest request, Pane pane) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(pane); + return Command([.. pane.BuildPipePaneArguments(request)]); + } + + /// Runs a pane-piping request on its own. + /// What to pipe, and which way. + /// The pane being piped. + /// Cancels the tmux command. + /// What tmux printed, which is nothing for an ordinary pipe. + /// An argument is null. + /// tmux reported the command failed. + [UnsupportedOSPlatform("windows")] + public static Task ExecuteAsync( + this PipePaneRequest request, + Pane pane, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(pane); + return pane.Server.Chain().Then(request.ToCommand(pane)).ExecuteAsync(cancellationToken); + } + + /// Returns a capture request as one tmux command. + /// What to capture. + /// The pane being captured. + /// The command, ready to add to a . + /// + /// Several capture flags arrived after tmux 3.2a, and the pane is what + /// knows which tmux is answering, so the command it builds carries only + /// the flags that server accepts. + /// + /// An argument is null. + public static TmuxCommand ToCommand(this CapturePaneRequest request, Pane pane) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(pane); + return Command([.. pane.BuildCaptureArguments(["-p"], request)]); + } + + /// Runs a capture request on its own. + /// What to capture. + /// The pane being captured. + /// Cancels the tmux command. + /// What tmux printed, which is the captured text. + /// An argument is null. + /// tmux reported the command failed. + [UnsupportedOSPlatform("windows")] + public static Task ExecuteAsync( + this CapturePaneRequest request, + Pane pane, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(pane); + return pane.Server.Chain().Then(request.ToCommand(pane)).ExecuteAsync(cancellationToken); + } + + /// Returns a paste request as one tmux command. + /// Which buffer to paste, and how. + /// The pane being pasted into. + /// The command, ready to add to a . + /// + /// Pasting raw bytes arrived in tmux 3.7, so the pane decides whether the + /// built command carries that flag. + /// + /// An argument is null. + public static TmuxCommand ToCommand(this PasteBufferRequest request, Pane pane) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(pane); + return Command([.. pane.BuildPasteBufferArguments(request)]); + } + + /// Runs a paste request on its own. + /// Which buffer to paste, and how. + /// The pane being pasted into. + /// Cancels the tmux command. + /// What tmux printed, which is nothing for an ordinary paste. + /// An argument is null. + /// tmux reported the command failed. + [UnsupportedOSPlatform("windows")] + public static Task ExecuteAsync( + this PasteBufferRequest request, + Pane pane, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(pane); + return pane.Server.Chain().Then(request.ToCommand(pane)).ExecuteAsync(cancellationToken); + } + + /// Returns a popup request as one tmux command. + /// What the popup shows, and where. + /// The pane the popup belongs to. + /// The command, ready to add to a . + /// + /// Popup options arrived in tmux 3.3 and the key policy in 3.6, so the + /// pane decides which of them the built command carries. + /// + /// An argument is null. + public static TmuxCommand ToCommand(this DisplayPopupRequest request, Pane pane) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(pane); + return Command([.. pane.BuildDisplayPopupArguments(request)]); + } + + /// Runs a popup request on its own. + /// What the popup shows, and where. + /// The pane the popup belongs to. + /// Cancels the tmux command. + /// What tmux printed, which is nothing for an ordinary popup. + /// An argument is null. + /// tmux reported the command failed. + [UnsupportedOSPlatform("windows")] + public static Task ExecuteAsync( + this DisplayPopupRequest request, + Pane pane, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(pane); + return pane.Server.Chain().Then(request.ToCommand(pane)).ExecuteAsync(cancellationToken); + } + + /// Returns a copy-mode request as one tmux command. + /// How to enter copy mode, or whether to leave it. + /// The pane entering copy mode. + /// The command, ready to add to a . + /// + /// Paging down on entry arrived in tmux 3.5, so the pane decides whether + /// the built command carries that flag. + /// + /// An argument is null. + public static TmuxCommand ToCommand(this CopyModeRequest request, Pane pane) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(pane); + return Command([.. pane.BuildCopyModeArguments(request)]); + } + + /// Runs a copy-mode request on its own. + /// How to enter copy mode, or whether to leave it. + /// The pane entering copy mode. + /// Cancels the tmux command. + /// What tmux printed, which is nothing for an ordinary entry. + /// An argument is null. + /// tmux reported the command failed. + [UnsupportedOSPlatform("windows")] + public static Task ExecuteAsync( + this CopyModeRequest request, + Pane pane, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(pane); + return pane.Server.Chain().Then(request.ToCommand(pane)).ExecuteAsync(cancellationToken); + } + + /// Returns a respawn request as one tmux command for a pane. + /// What to respawn, and how. + /// The pane being respawned. + /// The command, ready to add to a . + /// An argument is null. + public static TmuxCommand ToCommand(this RespawnRequest request, Pane pane) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(pane); + return Command([.. pane.BuildRespawnPaneArguments(request)]); + } + + /// Runs a respawn request on its own. + /// What to respawn, and how. + /// The pane being respawned. + /// Cancels the tmux command. + /// What tmux printed, which is nothing for an ordinary respawn. + /// An argument is null. + /// tmux reported the command failed. + [UnsupportedOSPlatform("windows")] + public static Task ExecuteAsync( + this RespawnRequest request, + Pane pane, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(pane); + return pane.Server.Chain().Then(request.ToCommand(pane)).ExecuteAsync(cancellationToken); + } + + /// Returns a chooser request as one tmux command. + /// What the chooser shows, and how it is ordered. + /// The pane the chooser opens in. + /// The command, ready to add to a . + /// + /// tmux 3.7 dropped the activity-time sort order and rejects it by name, + /// so the pane decides whether the built command carries it. + /// + /// An argument is null. + public static TmuxCommand ToCommand(this ChooseTreeRequest request, Pane pane) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(pane); + return Command([.. pane.BuildChooseTreeArguments(request)]); + } + + /// Runs a chooser request on its own. + /// What the chooser shows, and how it is ordered. + /// The pane the chooser opens in. + /// Cancels the tmux command. + /// What tmux printed, which is nothing for an ordinary chooser. + /// An argument is null. + /// tmux reported the command failed. + [UnsupportedOSPlatform("windows")] + public static Task ExecuteAsync( + this ChooseTreeRequest request, + Pane pane, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(pane); + return pane.Server.Chain().Then(request.ToCommand(pane)).ExecuteAsync(cancellationToken); + } + + /// Returns a pane-move request as one tmux command. + /// Where the pane goes. + /// The pane being moved. + /// The command, ready to add to a . + /// An argument is null. + public static TmuxCommand ToCommand(this MovePaneRequest request, Pane pane) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(pane); + return Command([.. pane.BuildRehomeArguments("move-pane", request)]); + } + + /// Runs a pane-move request on its own. + /// Where the pane goes. + /// The pane being moved. + /// Cancels the tmux command. + /// What tmux printed, which is nothing for an ordinary move. + /// An argument is null. + /// tmux reported the command failed. + [UnsupportedOSPlatform("windows")] + public static Task ExecuteAsync( + this MovePaneRequest request, + Pane pane, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(pane); + return pane.Server.Chain().Then(request.ToCommand(pane)).ExecuteAsync(cancellationToken); + } + + /// Returns a split request as one tmux command. + /// How to split. + /// The pane being split. + /// The command, ready to add to a . + /// + /// Splitting into an empty pane arrived in tmux 3.7 and the appearance + /// flags in 3.6, so the pane decides which of them the built command + /// carries. It prints the new pane's identifier the same way the one-shot + /// path does. + /// + /// An argument is null. + public static TmuxCommand ToCommand(this SplitPaneRequest request, Pane pane) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(pane); + return Command([.. pane.BuildSplitArguments(request)]); + } + + /// Runs a split request on its own. + /// How to split. + /// The pane being split. + /// Cancels the tmux command. + /// What tmux printed, which names the created pane. + /// An argument is null. + /// tmux reported the command failed. + [UnsupportedOSPlatform("windows")] + public static Task ExecuteAsync( + this SplitPaneRequest request, + Pane pane, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(pane); + return pane.Server.Chain().Then(request.ToCommand(pane)).ExecuteAsync(cancellationToken); + } + + /// Returns a floating-pane request as one tmux command. + /// How the pane floats. + /// The pane the new one is created from. + /// The command, ready to add to a . + /// + /// The command arrived whole in tmux 3.7, so batching does not soften the + /// refusal below that: an older server has nothing to send it to. + /// + /// An argument is null. + /// tmux is older than 3.7. + public static TmuxCommand ToCommand(this NewPaneRequest request, Pane pane) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(pane); + return Command([.. pane.BuildNewPaneArguments(request)]); + } + + /// Runs a floating-pane request on its own. + /// How the pane floats. + /// The pane the new one is created from. + /// Cancels the tmux command. + /// What tmux printed, which names the created pane. + /// An argument is null. + /// tmux reported the command failed. + [UnsupportedOSPlatform("windows")] + public static Task ExecuteAsync( + this NewPaneRequest request, + Pane pane, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(pane); + return pane.Server.Chain().Then(request.ToCommand(pane)).ExecuteAsync(cancellationToken); + } + + /// Runs a key request on its own. + /// The keys to send. + /// The pane that receives them. + /// Cancels the tmux command. + /// What tmux printed, which is nothing for an ordinary send. + /// An argument is null. + /// tmux reported the command failed. + [UnsupportedOSPlatform("windows")] + public static Task ExecuteAsync( + this SendKeysRequest request, + Pane pane, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(pane); + return pane.Server.Chain().Then(request.ToCommand(pane)).ExecuteAsync(cancellationToken); + } +} diff --git a/src/LibTmux/Chaining/TmuxChaining.Server.cs b/src/LibTmux/Chaining/TmuxChaining.Server.cs new file mode 100644 index 0000000..aedbdf1 --- /dev/null +++ b/src/LibTmux/Chaining/TmuxChaining.Server.cs @@ -0,0 +1,295 @@ +using System.Runtime.Versioning; + +namespace LibTmux; + +// Builds and executes server-scoped requests. +public static partial class TmuxChaining +{ + /// Returns a conditional request as one tmux command. + /// What to run. + /// The command, ready to add to a . + /// is null. + public static TmuxCommand ToCommand(this IfShellRequest request) + { + ArgumentNullException.ThrowIfNull(request); + return Command([.. Server.BuildIfShellArguments(request)]); + } + + /// Runs a conditional request on its own. + /// What to run. + /// The server to run it on. + /// Cancels the tmux command. + /// What tmux printed. + /// An argument is null. + /// tmux reported the command failed. + [UnsupportedOSPlatform("windows")] + public static Task ExecuteAsync( + this IfShellRequest request, + Server server, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(server); + return server.Chain().Then(request.ToCommand()).ExecuteAsync(cancellationToken); + } + + /// Returns a channel request as one tmux command. + /// What to run. + /// The command, ready to add to a . + /// is null. + public static TmuxCommand ToCommand(this WaitForRequest request) + { + ArgumentNullException.ThrowIfNull(request); + return Command([.. Server.BuildWaitForArguments(request)]); + } + + /// Runs a channel request on its own. + /// What to run. + /// The server to run it on. + /// Cancels the tmux command. + /// What tmux printed. + /// An argument is null. + /// tmux reported the command failed. + [UnsupportedOSPlatform("windows")] + public static Task ExecuteAsync( + this WaitForRequest request, + Server server, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(server); + return server.Chain().Then(request.ToCommand()).ExecuteAsync(cancellationToken); + } + + /// Returns a message request as one tmux command. + /// What to show, and where. + /// The server the message is shown on. + /// The command, ready to add to a . + /// + /// This takes the server because two of the flags depend on which tmux is + /// answering: literal expansion arrived in 3.4, and 3.2a refuses the + /// target-client flag even for a client that is really attached. + /// + /// An argument is null. + public static TmuxCommand ToCommand(this DisplayMessageRequest request, Server server) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(server); + return Command([.. server.BuildDisplayMessageArguments(request)]); + } + + /// Runs a message request on its own. + /// What to show, and where. + /// The server the message is shown on. + /// Cancels the tmux command. + /// What tmux printed, which is the message when it was asked for. + /// An argument is null. + /// tmux reported the command failed. + [UnsupportedOSPlatform("windows")] + public static Task ExecuteAsync( + this DisplayMessageRequest request, + Server server, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(server); + return server.Chain().Then(request.ToCommand(server)).ExecuteAsync(cancellationToken); + } + + /// Returns a shell request as one tmux command. + /// What to run, and how. + /// The server that runs it. + /// The command, ready to add to a . + /// + /// Three of this command's flags arrived at different tmux versions, so + /// the server is what decides which of them the built command carries. + /// + /// An argument is null. + public static TmuxCommand ToCommand(this RunShellRequest request, Server server) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(server); + return Command([.. server.BuildRunShellArguments(request)]); + } + + /// Runs a shell request on its own. + /// What to run, and how. + /// The server that runs it. + /// Cancels the tmux command. + /// What tmux printed, which is the command's output. + /// An argument is null. + /// tmux reported the command failed. + [UnsupportedOSPlatform("windows")] + public static Task ExecuteAsync( + this RunShellRequest request, + Server server, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(server); + return server.Chain().Then(request.ToCommand(server)).ExecuteAsync(cancellationToken); + } + + /// Returns a menu request as one tmux command. + /// What the menu offers, and how it looks. + /// The server the menu is shown on. + /// The command, ready to add to a . + /// + /// The style flags arrived in tmux 3.4 and the mouse flag in 3.5, so the + /// server decides which of them the built command carries. + /// + /// An argument is null. + public static TmuxCommand ToCommand(this DisplayMenuRequest request, Server server) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(server); + return Command([.. server.BuildDisplayMenuArguments(request)]); + } + + /// Runs a menu request on its own. + /// What the menu offers, and how it looks. + /// The server the menu is shown on. + /// Cancels the tmux command. + /// What tmux printed, which is nothing for an ordinary menu. + /// An argument is null. + /// tmux reported the command failed. + [UnsupportedOSPlatform("windows")] + public static Task ExecuteAsync( + this DisplayMenuRequest request, + Server server, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(server); + return server.Chain().Then(request.ToCommand(server)).ExecuteAsync(cancellationToken); + } + + /// Returns a confirmation request as one tmux command. + /// What to confirm, and what to run when it is. + /// The server the confirmation is shown on. + /// The command, ready to add to a . + /// + /// Naming the accepting key, and defaulting to yes, arrived in tmux 3.4, + /// so the server decides whether the built command carries them. + /// + /// An argument is null. + [UnsupportedOSPlatform("windows")] + public static TmuxCommand ToCommand(this ConfirmBeforeRequest request, Server server) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(server); + return Command([.. server.BuildConfirmBeforeArguments(request)]); + } + + /// Runs a confirmation request on its own. + /// What to confirm, and what to run when it is. + /// The server the confirmation is shown on. + /// Cancels the tmux command. + /// What tmux printed, which is nothing for an ordinary confirmation. + /// An argument is null. + /// tmux reported the command failed. + [UnsupportedOSPlatform("windows")] + public static Task ExecuteAsync( + this ConfirmBeforeRequest request, + Server server, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(server); + return server.Chain().Then(request.ToCommand(server)).ExecuteAsync(cancellationToken); + } + + /// Returns a prompt request as one tmux command. + /// What to ask, and how. + /// The server the prompt is shown on. + /// The command, ready to add to a . + /// + /// Batching does not soften the refusal below tmux 3.3: that version reads + /// the type flag as something else, so a prompt asking for one is refused + /// here exactly as it is when run alone. + /// + /// An argument is null. + /// + /// The request asks for a format or a prompt type and tmux is older than 3.3. + /// + [UnsupportedOSPlatform("windows")] + public static TmuxCommand ToCommand(this CommandPromptRequest request, Server server) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(server); + return Command([.. server.BuildCommandPromptArguments(request)]); + } + + /// Runs a prompt request on its own. + /// What to ask, and how. + /// The server the prompt is shown on. + /// Cancels the tmux command. + /// What tmux printed, which is nothing for an ordinary prompt. + /// An argument is null. + /// tmux reported the command failed. + [UnsupportedOSPlatform("windows")] + public static Task ExecuteAsync( + this CommandPromptRequest request, + Server server, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(server); + return server.Chain().Then(request.ToCommand(server)).ExecuteAsync(cancellationToken); + } + + /// Returns an access request as one tmux command. + /// Whose access to change, and how. + /// The server whose access is changed. + /// The command, ready to add to a . + /// + /// The command itself arrived in tmux 3.3, so batching does not soften the + /// refusal below that: an older server has nothing to send it to. + /// + /// An argument is null. + /// tmux is older than 3.3. + [UnsupportedOSPlatform("windows")] + public static TmuxCommand ToCommand(this ServerAccessRequest request, Server server) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(server); + return Command([.. server.BuildServerAccessArguments(request)]); + } + + /// Runs an access request on its own. + /// Whose access to change, and how. + /// The server whose access is changed. + /// Cancels the tmux command. + /// What tmux printed, which lists the users when it was asked to. + /// An argument is null. + /// tmux reported the command failed. + [UnsupportedOSPlatform("windows")] + public static Task ExecuteAsync( + this ServerAccessRequest request, + Server server, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(server); + return server.Chain().Then(request.ToCommand(server)).ExecuteAsync(cancellationToken); + } + + /// Returns a buffer-listing request as one tmux command. + /// How the buffers are rendered and filtered. + /// The command, ready to add to a . + /// is null. + public static TmuxCommand ToCommand(this ListBuffersRequest request) + { + ArgumentNullException.ThrowIfNull(request); + return Command([.. Server.BuildListBuffersArguments(request)]); + } + + /// Runs a buffer-listing request on its own. + /// How the buffers are rendered and filtered. + /// The server whose buffers are listed. + /// Cancels the tmux command. + /// What tmux printed, which is one line per buffer. + /// An argument is null. + /// tmux reported the command failed. + [UnsupportedOSPlatform("windows")] + public static Task ExecuteAsync( + this ListBuffersRequest request, + Server server, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(server); + return server.Chain().Then(request.ToCommand()).ExecuteAsync(cancellationToken); + } +} diff --git a/src/LibTmux/Chaining/TmuxChaining.Sessions.cs b/src/LibTmux/Chaining/TmuxChaining.Sessions.cs new file mode 100644 index 0000000..d0a4f22 --- /dev/null +++ b/src/LibTmux/Chaining/TmuxChaining.Sessions.cs @@ -0,0 +1,77 @@ +using System.Runtime.Versioning; + +namespace LibTmux; + +// Builds and executes session requests. +public static partial class TmuxChaining +{ + /// Returns a session request as one tmux command. + /// The session to create. + /// The command, ready to add to a . + /// is null. + public static TmuxCommand ToCommand(this NewSessionRequest request) + { + ArgumentNullException.ThrowIfNull(request); + return Command([.. Server.BuildNewSessionArguments(request)]); + } + + /// Returns an attach request as one tmux command. + /// How to attach. + /// The session being attached to. + /// The command, ready to add to a . + /// An argument is null. + public static TmuxCommand ToCommand(this AttachSessionRequest request, Session session) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(session); + return Command([.. Session.BuildAttachArguments(request, session.Id.ToString())]); + } + + /// Runs an attach request on its own. + /// How to attach. + /// The session being attached to. + /// Cancels the tmux command. + /// What tmux printed. + /// + /// Attaching needs a terminal, so this fails from a process that has none. + /// It is here because a chain that switches a client between sessions is + /// built the same way. + /// + /// An argument is null. + /// tmux reported the command failed. + [UnsupportedOSPlatform("windows")] + public static Task ExecuteAsync( + this AttachSessionRequest request, + Session session, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(session); + return session.Server + .Chain() + .Then(request.ToCommand(session)) + .ExecuteAsync(cancellationToken); + } + + /// Runs a session request on its own. + /// The session to create. + /// The server to create it on. + /// Cancels the tmux command. + /// What tmux printed, which names the created session. + /// + /// This runs the same command + /// builds, so a request executed on its own and the same request added to + /// a chain do the same thing. Reach for the chain when there is more than + /// one command; a single request costs the same either way. + /// + /// An argument is null. + /// tmux reported the command failed. + [UnsupportedOSPlatform("windows")] + public static Task ExecuteAsync( + this NewSessionRequest request, + Server server, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(server); + return server.Chain().Then(request.ToCommand()).ExecuteAsync(cancellationToken); + } +} diff --git a/src/LibTmux/Chaining/TmuxChaining.Windows.cs b/src/LibTmux/Chaining/TmuxChaining.Windows.cs new file mode 100644 index 0000000..8fa08aa --- /dev/null +++ b/src/LibTmux/Chaining/TmuxChaining.Windows.cs @@ -0,0 +1,173 @@ +using System.Runtime.Versioning; + +namespace LibTmux; + +// Builds and executes window requests. +public static partial class TmuxChaining +{ + /// Returns a window request as one tmux command. + /// The window to create. + /// The session the window is created in. + /// The command, ready to add to a . + /// is null. + /// is empty. + public static TmuxCommand ToCommand(this NewWindowRequest request, string target) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentException.ThrowIfNullOrEmpty(target); + return Command([.. Session.BuildNewWindowArguments(request, target)]); + } + + /// Returns a layout request as one tmux command for a window. + /// The layout to apply. + /// The window the layout applies to. + /// The command, ready to add to a . + /// + /// This takes the window because a layout name is checked against the ones + /// the running tmux knows, and an unrecognised name takes the whole server + /// down on tmux 3.3a. Batching a layout must not skip that check. + /// + /// An argument is null. + /// The layout is one tmux may not recognise. + public static TmuxCommand ToCommand(this SelectLayoutRequest request, Window window) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(window); + return Command([.. window.BuildSelectLayoutArguments(request)]); + } + + /// Runs a layout request on its own. + /// The layout to apply. + /// The window the layout applies to. + /// Cancels the tmux command. + /// What tmux printed, which is nothing for an ordinary layout. + /// An argument is null. + /// tmux reported the command failed. + [UnsupportedOSPlatform("windows")] + public static Task ExecuteAsync( + this SelectLayoutRequest request, + Window window, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(window); + return window.Server + .Chain() + .Then(request.ToCommand(window)) + .ExecuteAsync(cancellationToken); + } + + /// Returns a window-resize request as one tmux command. + /// The size to apply. + /// The window being resized. + /// The command, ready to add to a . + /// An argument is null. + public static TmuxCommand ToCommand(this ResizeWindowRequest request, Window window) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(window); + return Command([.. window.BuildResizeWindowArguments(request)]); + } + + /// Runs a window-resize request on its own. + /// The size to apply. + /// The window being resized. + /// Cancels the tmux command. + /// What tmux printed, which is nothing for an ordinary resize. + /// An argument is null. + /// tmux reported the command failed. + [UnsupportedOSPlatform("windows")] + public static Task ExecuteAsync( + this ResizeWindowRequest request, + Window window, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(window); + return window.Server.Chain().Then(request.ToCommand(window)).ExecuteAsync(cancellationToken); + } + + /// Returns a link request as one tmux command. + /// Where the link goes. + /// The window being linked. + /// The command, ready to add to a . + /// + /// This takes the window because the link's source is the session that + /// window was read through, which a window resolved by identifier alone + /// does not know. + /// + /// An argument is null. + /// + /// The window was resolved by identifier, so its source link is unknown. + /// + public static TmuxCommand ToCommand(this LinkWindowRequest request, Window window) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(window); + return Command([.. window.BuildLinkWindowArguments(request)]); + } + + /// Runs a link request on its own. + /// Where the link goes. + /// The window being linked. + /// Cancels the tmux command. + /// What tmux printed, which is nothing for an ordinary link. + /// An argument is null. + /// tmux reported the command failed. + [UnsupportedOSPlatform("windows")] + public static Task ExecuteAsync( + this LinkWindowRequest request, + Window window, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(window); + return window.Server.Chain().Then(request.ToCommand(window)).ExecuteAsync(cancellationToken); + } + + /// Returns a window-move request as one tmux command. + /// Where the window goes. + /// The window being moved. + /// The command, ready to add to a . + /// An argument is null. + public static TmuxCommand ToCommand(this MoveWindowRequest request, Window window) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(window); + return Command([.. window.BuildMoveWindowArguments(request)]); + } + + /// Runs a window-move request on its own. + /// Where the window goes. + /// The window being moved. + /// Cancels the tmux command. + /// What tmux printed, which is nothing for an ordinary move. + /// An argument is null. + /// tmux reported the command failed. + [UnsupportedOSPlatform("windows")] + public static Task ExecuteAsync( + this MoveWindowRequest request, + Window window, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(window); + return window.Server.Chain().Then(request.ToCommand(window)).ExecuteAsync(cancellationToken); + } + + /// Runs a window request on its own. + /// The window to create. + /// The session that will hold it. + /// Cancels the tmux command. + /// What tmux printed, which names the created window. + /// An argument is null. + /// tmux reported the command failed. + [UnsupportedOSPlatform("windows")] + public static Task ExecuteAsync( + this NewWindowRequest request, + Session session, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(session); + return session.Server + .Chain() + .Then(request.ToCommand(session.Id.ToString())) + .ExecuteAsync(cancellationToken); + } +} diff --git a/src/LibTmux/Chaining/TmuxChaining.cs b/src/LibTmux/Chaining/TmuxChaining.cs index 271b139..a3fcb19 100644 --- a/src/LibTmux/Chaining/TmuxChaining.cs +++ b/src/LibTmux/Chaining/TmuxChaining.cs @@ -1,5 +1,3 @@ -using System.Runtime.Versioning; - namespace LibTmux; /// Turns a request record into a command a chain can carry. @@ -17,1375 +15,8 @@ namespace LibTmux; /// it. /// /// -public static class TmuxChaining +public static partial class TmuxChaining { - /// Returns a session request as one tmux command. - /// The session to create. - /// The command, ready to add to a . - /// is null. - public static TmuxCommand ToCommand(this NewSessionRequest request) - { - ArgumentNullException.ThrowIfNull(request); - return Command([.. Server.BuildNewSessionArguments(request)]); - } - - /// Returns a window request as one tmux command. - /// The window to create. - /// The session the window is created in. - /// The command, ready to add to a . - /// is null. - /// is empty. - public static TmuxCommand ToCommand(this NewWindowRequest request, string target) - { - ArgumentNullException.ThrowIfNull(request); - ArgumentException.ThrowIfNullOrEmpty(target); - return Command([.. Session.BuildNewWindowArguments(request, target)]); - } - - /// Returns a key request as one tmux command for a pane. - /// The keys to send. - /// The pane that receives them. - /// The command, ready to add to a . - /// An argument is null. - public static TmuxCommand ToCommand(this SendKeysRequest request, Pane pane) - { - ArgumentNullException.ThrowIfNull(request); - ArgumentNullException.ThrowIfNull(pane); - - // The pane ID travels into the chain as plain text, so RequiredGeneration - // pins it: after a restart, that ID could name a different pane. - return Command([.. pane.BuildSendKeysArguments(request)]) with - { - RequiredGeneration = pane.Generation, - }; - } - - /// Returns a key-binding request as one tmux command. - /// The binding to add. - /// The command, ready to add to a . - /// is null. - public static TmuxCommand ToCommand(this BindKeyRequest request) - { - ArgumentNullException.ThrowIfNull(request); - return Command([.. Server.BuildBindKeyArguments(request)]); - } - - /// Returns a key-unbinding request as one tmux command. - /// The binding to remove. - /// The command, ready to add to a . - /// is null. - public static TmuxCommand ToCommand(this UnbindKeyRequest request) - { - ArgumentNullException.ThrowIfNull(request); - return Command([.. Server.BuildUnbindKeyArguments(request)]); - } - - /// Runs a key-binding request on its own. - /// The binding to add. - /// The server to bind on. - /// Cancels the tmux command. - /// What tmux printed, which is nothing for an ordinary bind. - /// An argument is null. - /// tmux reported the command failed. - [UnsupportedOSPlatform("windows")] - public static Task ExecuteAsync( - this BindKeyRequest request, - Server server, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(server); - return server.Chain().Then(request.ToCommand()).ExecuteAsync(cancellationToken); - } - - /// Runs a key-unbinding request on its own. - /// The binding to remove. - /// The server to unbind on. - /// Cancels the tmux command. - /// What tmux printed, which is nothing for an ordinary unbind. - /// An argument is null. - /// tmux reported the command failed. - [UnsupportedOSPlatform("windows")] - public static Task ExecuteAsync( - this UnbindKeyRequest request, - Server server, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(server); - return server.Chain().Then(request.ToCommand()).ExecuteAsync(cancellationToken); - } - - /// Returns a layout request as one tmux command for a window. - /// The layout to apply. - /// The window the layout applies to. - /// The command, ready to add to a . - /// - /// This takes the window because a layout name is checked against the ones - /// the running tmux knows, and an unrecognised name takes the whole server - /// down on tmux 3.3a. Batching a layout must not skip that check. - /// - /// An argument is null. - /// The layout is one tmux may not recognise. - public static TmuxCommand ToCommand(this SelectLayoutRequest request, Window window) - { - ArgumentNullException.ThrowIfNull(request); - ArgumentNullException.ThrowIfNull(window); - return Command([.. window.BuildSelectLayoutArguments(request)]); - } - - /// Runs a layout request on its own. - /// The layout to apply. - /// The window the layout applies to. - /// Cancels the tmux command. - /// What tmux printed, which is nothing for an ordinary layout. - /// An argument is null. - /// tmux reported the command failed. - [UnsupportedOSPlatform("windows")] - public static Task ExecuteAsync( - this SelectLayoutRequest request, - Window window, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(window); - return window.Server - .Chain() - .Then(request.ToCommand(window)) - .ExecuteAsync(cancellationToken); - } - - /// Returns a conditional request as one tmux command. - /// What to run. - /// The command, ready to add to a . - /// is null. - public static TmuxCommand ToCommand(this IfShellRequest request) - { - ArgumentNullException.ThrowIfNull(request); - return Command([.. Server.BuildIfShellArguments(request)]); - } - - /// Runs a conditional request on its own. - /// What to run. - /// The server to run it on. - /// Cancels the tmux command. - /// What tmux printed. - /// An argument is null. - /// tmux reported the command failed. - [UnsupportedOSPlatform("windows")] - public static Task ExecuteAsync( - this IfShellRequest request, - Server server, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(server); - return server.Chain().Then(request.ToCommand()).ExecuteAsync(cancellationToken); - } - - /// Returns a channel request as one tmux command. - /// What to run. - /// The command, ready to add to a . - /// is null. - public static TmuxCommand ToCommand(this WaitForRequest request) - { - ArgumentNullException.ThrowIfNull(request); - return Command([.. Server.BuildWaitForArguments(request)]); - } - - /// Runs a channel request on its own. - /// What to run. - /// The server to run it on. - /// Cancels the tmux command. - /// What tmux printed. - /// An argument is null. - /// tmux reported the command failed. - [UnsupportedOSPlatform("windows")] - public static Task ExecuteAsync( - this WaitForRequest request, - Server server, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(server); - return server.Chain().Then(request.ToCommand()).ExecuteAsync(cancellationToken); - } - - /// Returns a pane-selection request as one tmux command. - /// Which pane to select, and how. - /// The pane the selection is relative to. - /// The command, ready to add to a . - /// An argument is null. - public static TmuxCommand ToCommand(this SelectPaneRequest request, Pane pane) - { - ArgumentNullException.ThrowIfNull(request); - ArgumentNullException.ThrowIfNull(pane); - return Command([.. pane.BuildSelectPaneArguments(request)]); - } - - /// Runs a pane-selection request on its own. - /// Which pane to select, and how. - /// The pane the selection is relative to. - /// Cancels the tmux command. - /// What tmux printed, which is nothing for an ordinary selection. - /// An argument is null. - /// tmux reported the command failed. - [UnsupportedOSPlatform("windows")] - public static Task ExecuteAsync( - this SelectPaneRequest request, - Pane pane, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(pane); - return pane.Server.Chain().Then(request.ToCommand(pane)).ExecuteAsync(cancellationToken); - } - - /// Returns a pane-resize request as one tmux command. - /// How to resize. - /// The pane being resized. - /// The command, ready to add to a . - /// An argument is null. - public static TmuxCommand ToCommand(this ResizePaneRequest request, Pane pane) - { - ArgumentNullException.ThrowIfNull(request); - ArgumentNullException.ThrowIfNull(pane); - return Command([.. pane.BuildResizePaneArguments(request)]); - } - - /// Runs a pane-resize request on its own. - /// How to resize. - /// The pane being resized. - /// Cancels the tmux command. - /// What tmux printed, which is nothing for an ordinary resize. - /// An argument is null. - /// tmux reported the command failed. - [UnsupportedOSPlatform("windows")] - public static Task ExecuteAsync( - this ResizePaneRequest request, - Pane pane, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(pane); - return pane.Server.Chain().Then(request.ToCommand(pane)).ExecuteAsync(cancellationToken); - } - - /// Returns a window-search request as one tmux command. - /// What to look for. - /// The pane the search starts from. - /// The command, ready to add to a . - /// An argument is null. - public static TmuxCommand ToCommand(this FindWindowRequest request, Pane pane) - { - ArgumentNullException.ThrowIfNull(request); - ArgumentNullException.ThrowIfNull(pane); - return Command([.. pane.BuildFindWindowArguments(request)]); - } - - /// Runs a window-search request on its own. - /// What to look for. - /// The pane the search starts from. - /// Cancels the tmux command. - /// What tmux printed, which is nothing for an ordinary search. - /// An argument is null. - /// tmux reported the command failed. - [UnsupportedOSPlatform("windows")] - public static Task ExecuteAsync( - this FindWindowRequest request, - Pane pane, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(pane); - return pane.Server.Chain().Then(request.ToCommand(pane)).ExecuteAsync(cancellationToken); - } - - /// Returns a pane-swap request as one tmux command. - /// Which pane to swap with, and how. - /// The pane being swapped. - /// The command, ready to add to a . - /// An argument is null. - public static TmuxCommand ToCommand(this SwapPaneRequest request, Pane pane) - { - ArgumentNullException.ThrowIfNull(request); - ArgumentNullException.ThrowIfNull(pane); - return Command([.. pane.BuildSwapPaneArguments(request)]); - } - - /// Runs a pane-swap request on its own. - /// Which pane to swap with, and how. - /// The pane being swapped. - /// Cancels the tmux command. - /// What tmux printed, which is nothing for an ordinary swap. - /// An argument is null. - /// tmux reported the command failed. - [UnsupportedOSPlatform("windows")] - public static Task ExecuteAsync( - this SwapPaneRequest request, - Pane pane, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(pane); - return pane.Server.Chain().Then(request.ToCommand(pane)).ExecuteAsync(cancellationToken); - } - - /// Returns a pane-piping request as one tmux command. - /// What to pipe, and which way. - /// The pane being piped. - /// The command, ready to add to a . - /// An argument is null. - public static TmuxCommand ToCommand(this PipePaneRequest request, Pane pane) - { - ArgumentNullException.ThrowIfNull(request); - ArgumentNullException.ThrowIfNull(pane); - return Command([.. pane.BuildPipePaneArguments(request)]); - } - - /// Runs a pane-piping request on its own. - /// What to pipe, and which way. - /// The pane being piped. - /// Cancels the tmux command. - /// What tmux printed, which is nothing for an ordinary pipe. - /// An argument is null. - /// tmux reported the command failed. - [UnsupportedOSPlatform("windows")] - public static Task ExecuteAsync( - this PipePaneRequest request, - Pane pane, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(pane); - return pane.Server.Chain().Then(request.ToCommand(pane)).ExecuteAsync(cancellationToken); - } - - /// Returns a capture request as one tmux command. - /// What to capture. - /// The pane being captured. - /// The command, ready to add to a . - /// - /// Several capture flags arrived after tmux 3.2a, and the pane is what - /// knows which tmux is answering, so the command it builds carries only - /// the flags that server accepts. - /// - /// An argument is null. - public static TmuxCommand ToCommand(this CapturePaneRequest request, Pane pane) - { - ArgumentNullException.ThrowIfNull(request); - ArgumentNullException.ThrowIfNull(pane); - return Command([.. pane.BuildCaptureArguments(["-p"], request)]); - } - - /// Runs a capture request on its own. - /// What to capture. - /// The pane being captured. - /// Cancels the tmux command. - /// What tmux printed, which is the captured text. - /// An argument is null. - /// tmux reported the command failed. - [UnsupportedOSPlatform("windows")] - public static Task ExecuteAsync( - this CapturePaneRequest request, - Pane pane, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(pane); - return pane.Server.Chain().Then(request.ToCommand(pane)).ExecuteAsync(cancellationToken); - } - - /// Returns a message request as one tmux command. - /// What to show, and where. - /// The server the message is shown on. - /// The command, ready to add to a . - /// - /// This takes the server because two of the flags depend on which tmux is - /// answering: literal expansion arrived in 3.4, and 3.2a refuses the - /// target-client flag even for a client that is really attached. - /// - /// An argument is null. - public static TmuxCommand ToCommand(this DisplayMessageRequest request, Server server) - { - ArgumentNullException.ThrowIfNull(request); - ArgumentNullException.ThrowIfNull(server); - return Command([.. server.BuildDisplayMessageArguments(request)]); - } - - /// Runs a message request on its own. - /// What to show, and where. - /// The server the message is shown on. - /// Cancels the tmux command. - /// What tmux printed, which is the message when it was asked for. - /// An argument is null. - /// tmux reported the command failed. - [UnsupportedOSPlatform("windows")] - public static Task ExecuteAsync( - this DisplayMessageRequest request, - Server server, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(server); - return server.Chain().Then(request.ToCommand(server)).ExecuteAsync(cancellationToken); - } - - /// Returns a shell request as one tmux command. - /// What to run, and how. - /// The server that runs it. - /// The command, ready to add to a . - /// - /// Three of this command's flags arrived at different tmux versions, so - /// the server is what decides which of them the built command carries. - /// - /// An argument is null. - public static TmuxCommand ToCommand(this RunShellRequest request, Server server) - { - ArgumentNullException.ThrowIfNull(request); - ArgumentNullException.ThrowIfNull(server); - return Command([.. server.BuildRunShellArguments(request)]); - } - - /// Runs a shell request on its own. - /// What to run, and how. - /// The server that runs it. - /// Cancels the tmux command. - /// What tmux printed, which is the command's output. - /// An argument is null. - /// tmux reported the command failed. - [UnsupportedOSPlatform("windows")] - public static Task ExecuteAsync( - this RunShellRequest request, - Server server, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(server); - return server.Chain().Then(request.ToCommand(server)).ExecuteAsync(cancellationToken); - } - - /// Returns a paste request as one tmux command. - /// Which buffer to paste, and how. - /// The pane being pasted into. - /// The command, ready to add to a . - /// - /// Pasting raw bytes arrived in tmux 3.7, so the pane decides whether the - /// built command carries that flag. - /// - /// An argument is null. - public static TmuxCommand ToCommand(this PasteBufferRequest request, Pane pane) - { - ArgumentNullException.ThrowIfNull(request); - ArgumentNullException.ThrowIfNull(pane); - return Command([.. pane.BuildPasteBufferArguments(request)]); - } - - /// Runs a paste request on its own. - /// Which buffer to paste, and how. - /// The pane being pasted into. - /// Cancels the tmux command. - /// What tmux printed, which is nothing for an ordinary paste. - /// An argument is null. - /// tmux reported the command failed. - [UnsupportedOSPlatform("windows")] - public static Task ExecuteAsync( - this PasteBufferRequest request, - Pane pane, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(pane); - return pane.Server.Chain().Then(request.ToCommand(pane)).ExecuteAsync(cancellationToken); - } - - /// Returns a menu request as one tmux command. - /// What the menu offers, and how it looks. - /// The server the menu is shown on. - /// The command, ready to add to a . - /// - /// The style flags arrived in tmux 3.4 and the mouse flag in 3.5, so the - /// server decides which of them the built command carries. - /// - /// An argument is null. - public static TmuxCommand ToCommand(this DisplayMenuRequest request, Server server) - { - ArgumentNullException.ThrowIfNull(request); - ArgumentNullException.ThrowIfNull(server); - return Command([.. server.BuildDisplayMenuArguments(request)]); - } - - /// Runs a menu request on its own. - /// What the menu offers, and how it looks. - /// The server the menu is shown on. - /// Cancels the tmux command. - /// What tmux printed, which is nothing for an ordinary menu. - /// An argument is null. - /// tmux reported the command failed. - [UnsupportedOSPlatform("windows")] - public static Task ExecuteAsync( - this DisplayMenuRequest request, - Server server, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(server); - return server.Chain().Then(request.ToCommand(server)).ExecuteAsync(cancellationToken); - } - - /// Returns a popup request as one tmux command. - /// What the popup shows, and where. - /// The pane the popup belongs to. - /// The command, ready to add to a . - /// - /// Popup options arrived in tmux 3.3 and the key policy in 3.6, so the - /// pane decides which of them the built command carries. - /// - /// An argument is null. - public static TmuxCommand ToCommand(this DisplayPopupRequest request, Pane pane) - { - ArgumentNullException.ThrowIfNull(request); - ArgumentNullException.ThrowIfNull(pane); - return Command([.. pane.BuildDisplayPopupArguments(request)]); - } - - /// Runs a popup request on its own. - /// What the popup shows, and where. - /// The pane the popup belongs to. - /// Cancels the tmux command. - /// What tmux printed, which is nothing for an ordinary popup. - /// An argument is null. - /// tmux reported the command failed. - [UnsupportedOSPlatform("windows")] - public static Task ExecuteAsync( - this DisplayPopupRequest request, - Pane pane, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(pane); - return pane.Server.Chain().Then(request.ToCommand(pane)).ExecuteAsync(cancellationToken); - } - - /// Returns an option request as one tmux command. - /// Which option to set, and to what. - /// The options handle whose scope the option is set in. - /// The command, ready to add to a . - /// - /// This takes the options handle rather than a server, because which - /// scope flags and target tmux receives follow from the handle the caller - /// reached for: a window's options and a server's are the same request - /// spelled differently. - /// - /// An argument is null. - [UnsupportedOSPlatform("windows")] - public static TmuxCommand ToCommand(this SetOptionRequest request, TmuxOptions options) - { - ArgumentNullException.ThrowIfNull(request); - ArgumentNullException.ThrowIfNull(options); - return Command([.. options.BuildSetArguments(request)]); - } - - /// Runs an option request on its own. - /// Which option to set, and to what. - /// The options handle whose scope the option is set in. - /// The server the option is set on. - /// Cancels the tmux command. - /// What tmux printed, which is nothing for an ordinary set. - /// An argument is null. - /// tmux reported the command failed. - [UnsupportedOSPlatform("windows")] - public static Task ExecuteAsync( - this SetOptionRequest request, - TmuxOptions options, - Server server, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(server); - return server.Chain().Then(request.ToCommand(options)).ExecuteAsync(cancellationToken); - } - - /// Returns an unset request as one tmux command. - /// Which option to unset, and how. - /// The options handle whose scope the option is unset in. - /// The command, ready to add to a . - /// An argument is null. - [UnsupportedOSPlatform("windows")] - public static TmuxCommand ToCommand(this UnsetOptionRequest request, TmuxOptions options) - { - ArgumentNullException.ThrowIfNull(request); - ArgumentNullException.ThrowIfNull(options); - return Command([.. options.BuildUnsetArguments(request)]); - } - - /// Runs an unset request on its own. - /// Which option to unset, and how. - /// The options handle whose scope the option is unset in. - /// The server the option is unset on. - /// Cancels the tmux command. - /// What tmux printed, which is nothing for an ordinary unset. - /// An argument is null. - /// tmux reported the command failed. - [UnsupportedOSPlatform("windows")] - public static Task ExecuteAsync( - this UnsetOptionRequest request, - TmuxOptions options, - Server server, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(server); - return server.Chain().Then(request.ToCommand(options)).ExecuteAsync(cancellationToken); - } - - /// Returns a hook request as one tmux command. - /// Which hook to set, and to what. - /// The hooks handle whose scope the hook is set in. - /// The command, ready to add to a . - /// An argument is null. - [UnsupportedOSPlatform("windows")] - public static TmuxCommand ToCommand(this SetHookRequest request, TmuxHooks hooks) - { - ArgumentNullException.ThrowIfNull(request); - ArgumentNullException.ThrowIfNull(hooks); - return Command([.. hooks.BuildSetArguments(request)]); - } - - /// Runs a hook request on its own. - /// Which hook to set, and to what. - /// The hooks handle whose scope the hook is set in. - /// The server the hook is set on. - /// Cancels the tmux command. - /// What tmux printed, which is nothing for an ordinary hook. - /// An argument is null. - /// tmux reported the command failed. - [UnsupportedOSPlatform("windows")] - public static Task ExecuteAsync( - this SetHookRequest request, - TmuxHooks hooks, - Server server, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(server); - return server.Chain().Then(request.ToCommand(hooks)).ExecuteAsync(cancellationToken); - } - - /// Returns a confirmation request as one tmux command. - /// What to confirm, and what to run when it is. - /// The server the confirmation is shown on. - /// The command, ready to add to a . - /// - /// Naming the accepting key, and defaulting to yes, arrived in tmux 3.4, - /// so the server decides whether the built command carries them. - /// - /// An argument is null. - [UnsupportedOSPlatform("windows")] - public static TmuxCommand ToCommand(this ConfirmBeforeRequest request, Server server) - { - ArgumentNullException.ThrowIfNull(request); - ArgumentNullException.ThrowIfNull(server); - return Command([.. server.BuildConfirmBeforeArguments(request)]); - } - - /// Runs a confirmation request on its own. - /// What to confirm, and what to run when it is. - /// The server the confirmation is shown on. - /// Cancels the tmux command. - /// What tmux printed, which is nothing for an ordinary confirmation. - /// An argument is null. - /// tmux reported the command failed. - [UnsupportedOSPlatform("windows")] - public static Task ExecuteAsync( - this ConfirmBeforeRequest request, - Server server, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(server); - return server.Chain().Then(request.ToCommand(server)).ExecuteAsync(cancellationToken); - } - - /// Returns a prompt request as one tmux command. - /// What to ask, and how. - /// The server the prompt is shown on. - /// The command, ready to add to a . - /// - /// Batching does not soften the refusal below tmux 3.3: that version reads - /// the type flag as something else, so a prompt asking for one is refused - /// here exactly as it is when run alone. - /// - /// An argument is null. - /// - /// The request asks for a format or a prompt type and tmux is older than 3.3. - /// - [UnsupportedOSPlatform("windows")] - public static TmuxCommand ToCommand(this CommandPromptRequest request, Server server) - { - ArgumentNullException.ThrowIfNull(request); - ArgumentNullException.ThrowIfNull(server); - return Command([.. server.BuildCommandPromptArguments(request)]); - } - - /// Runs a prompt request on its own. - /// What to ask, and how. - /// The server the prompt is shown on. - /// Cancels the tmux command. - /// What tmux printed, which is nothing for an ordinary prompt. - /// An argument is null. - /// tmux reported the command failed. - [UnsupportedOSPlatform("windows")] - public static Task ExecuteAsync( - this CommandPromptRequest request, - Server server, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(server); - return server.Chain().Then(request.ToCommand(server)).ExecuteAsync(cancellationToken); - } - - /// Returns a copy-mode request as one tmux command. - /// How to enter copy mode, or whether to leave it. - /// The pane entering copy mode. - /// The command, ready to add to a . - /// - /// Paging down on entry arrived in tmux 3.5, so the pane decides whether - /// the built command carries that flag. - /// - /// An argument is null. - public static TmuxCommand ToCommand(this CopyModeRequest request, Pane pane) - { - ArgumentNullException.ThrowIfNull(request); - ArgumentNullException.ThrowIfNull(pane); - return Command([.. pane.BuildCopyModeArguments(request)]); - } - - /// Runs a copy-mode request on its own. - /// How to enter copy mode, or whether to leave it. - /// The pane entering copy mode. - /// Cancels the tmux command. - /// What tmux printed, which is nothing for an ordinary entry. - /// An argument is null. - /// tmux reported the command failed. - [UnsupportedOSPlatform("windows")] - public static Task ExecuteAsync( - this CopyModeRequest request, - Pane pane, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(pane); - return pane.Server.Chain().Then(request.ToCommand(pane)).ExecuteAsync(cancellationToken); - } - - /// Returns a window-resize request as one tmux command. - /// The size to apply. - /// The window being resized. - /// The command, ready to add to a . - /// An argument is null. - public static TmuxCommand ToCommand(this ResizeWindowRequest request, Window window) - { - ArgumentNullException.ThrowIfNull(request); - ArgumentNullException.ThrowIfNull(window); - return Command([.. window.BuildResizeWindowArguments(request)]); - } - - /// Runs a window-resize request on its own. - /// The size to apply. - /// The window being resized. - /// Cancels the tmux command. - /// What tmux printed, which is nothing for an ordinary resize. - /// An argument is null. - /// tmux reported the command failed. - [UnsupportedOSPlatform("windows")] - public static Task ExecuteAsync( - this ResizeWindowRequest request, - Window window, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(window); - return window.Server.Chain().Then(request.ToCommand(window)).ExecuteAsync(cancellationToken); - } - - /// Returns a respawn request as one tmux command for a pane. - /// What to respawn, and how. - /// The pane being respawned. - /// The command, ready to add to a . - /// An argument is null. - public static TmuxCommand ToCommand(this RespawnRequest request, Pane pane) - { - ArgumentNullException.ThrowIfNull(request); - ArgumentNullException.ThrowIfNull(pane); - return Command([.. pane.BuildRespawnPaneArguments(request)]); - } - - /// Runs a respawn request on its own. - /// What to respawn, and how. - /// The pane being respawned. - /// Cancels the tmux command. - /// What tmux printed, which is nothing for an ordinary respawn. - /// An argument is null. - /// tmux reported the command failed. - [UnsupportedOSPlatform("windows")] - public static Task ExecuteAsync( - this RespawnRequest request, - Pane pane, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(pane); - return pane.Server.Chain().Then(request.ToCommand(pane)).ExecuteAsync(cancellationToken); - } - - /// Returns a chooser request as one tmux command. - /// What the chooser shows, and how it is ordered. - /// The pane the chooser opens in. - /// The command, ready to add to a . - /// - /// tmux 3.7 dropped the activity-time sort order and rejects it by name, - /// so the pane decides whether the built command carries it. - /// - /// An argument is null. - public static TmuxCommand ToCommand(this ChooseTreeRequest request, Pane pane) - { - ArgumentNullException.ThrowIfNull(request); - ArgumentNullException.ThrowIfNull(pane); - return Command([.. pane.BuildChooseTreeArguments(request)]); - } - - /// Runs a chooser request on its own. - /// What the chooser shows, and how it is ordered. - /// The pane the chooser opens in. - /// Cancels the tmux command. - /// What tmux printed, which is nothing for an ordinary chooser. - /// An argument is null. - /// tmux reported the command failed. - [UnsupportedOSPlatform("windows")] - public static Task ExecuteAsync( - this ChooseTreeRequest request, - Pane pane, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(pane); - return pane.Server.Chain().Then(request.ToCommand(pane)).ExecuteAsync(cancellationToken); - } - - /// Returns an access request as one tmux command. - /// Whose access to change, and how. - /// The server whose access is changed. - /// The command, ready to add to a . - /// - /// The command itself arrived in tmux 3.3, so batching does not soften the - /// refusal below that: an older server has nothing to send it to. - /// - /// An argument is null. - /// tmux is older than 3.3. - [UnsupportedOSPlatform("windows")] - public static TmuxCommand ToCommand(this ServerAccessRequest request, Server server) - { - ArgumentNullException.ThrowIfNull(request); - ArgumentNullException.ThrowIfNull(server); - return Command([.. server.BuildServerAccessArguments(request)]); - } - - /// Runs an access request on its own. - /// Whose access to change, and how. - /// The server whose access is changed. - /// Cancels the tmux command. - /// What tmux printed, which lists the users when it was asked to. - /// An argument is null. - /// tmux reported the command failed. - [UnsupportedOSPlatform("windows")] - public static Task ExecuteAsync( - this ServerAccessRequest request, - Server server, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(server); - return server.Chain().Then(request.ToCommand(server)).ExecuteAsync(cancellationToken); - } - - /// Returns a buffer-listing request as one tmux command. - /// How the buffers are rendered and filtered. - /// The command, ready to add to a . - /// is null. - public static TmuxCommand ToCommand(this ListBuffersRequest request) - { - ArgumentNullException.ThrowIfNull(request); - return Command([.. Server.BuildListBuffersArguments(request)]); - } - - /// Runs a buffer-listing request on its own. - /// How the buffers are rendered and filtered. - /// The server whose buffers are listed. - /// Cancels the tmux command. - /// What tmux printed, which is one line per buffer. - /// An argument is null. - /// tmux reported the command failed. - [UnsupportedOSPlatform("windows")] - public static Task ExecuteAsync( - this ListBuffersRequest request, - Server server, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(server); - return server.Chain().Then(request.ToCommand()).ExecuteAsync(cancellationToken); - } - - /// Returns a link request as one tmux command. - /// Where the link goes. - /// The window being linked. - /// The command, ready to add to a . - /// - /// This takes the window because the link's source is the session that - /// window was read through, which a window resolved by identifier alone - /// does not know. - /// - /// An argument is null. - /// - /// The window was resolved by identifier, so its source link is unknown. - /// - public static TmuxCommand ToCommand(this LinkWindowRequest request, Window window) - { - ArgumentNullException.ThrowIfNull(request); - ArgumentNullException.ThrowIfNull(window); - return Command([.. window.BuildLinkWindowArguments(request)]); - } - - /// Runs a link request on its own. - /// Where the link goes. - /// The window being linked. - /// Cancels the tmux command. - /// What tmux printed, which is nothing for an ordinary link. - /// An argument is null. - /// tmux reported the command failed. - [UnsupportedOSPlatform("windows")] - public static Task ExecuteAsync( - this LinkWindowRequest request, - Window window, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(window); - return window.Server.Chain().Then(request.ToCommand(window)).ExecuteAsync(cancellationToken); - } - - /// Returns a window-move request as one tmux command. - /// Where the window goes. - /// The window being moved. - /// The command, ready to add to a . - /// An argument is null. - public static TmuxCommand ToCommand(this MoveWindowRequest request, Window window) - { - ArgumentNullException.ThrowIfNull(request); - ArgumentNullException.ThrowIfNull(window); - return Command([.. window.BuildMoveWindowArguments(request)]); - } - - /// Runs a window-move request on its own. - /// Where the window goes. - /// The window being moved. - /// Cancels the tmux command. - /// What tmux printed, which is nothing for an ordinary move. - /// An argument is null. - /// tmux reported the command failed. - [UnsupportedOSPlatform("windows")] - public static Task ExecuteAsync( - this MoveWindowRequest request, - Window window, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(window); - return window.Server.Chain().Then(request.ToCommand(window)).ExecuteAsync(cancellationToken); - } - - /// Returns a pane-move request as one tmux command. - /// Where the pane goes. - /// The pane being moved. - /// The command, ready to add to a . - /// An argument is null. - public static TmuxCommand ToCommand(this MovePaneRequest request, Pane pane) - { - ArgumentNullException.ThrowIfNull(request); - ArgumentNullException.ThrowIfNull(pane); - return Command([.. pane.BuildRehomeArguments("move-pane", request)]); - } - - /// Runs a pane-move request on its own. - /// Where the pane goes. - /// The pane being moved. - /// Cancels the tmux command. - /// What tmux printed, which is nothing for an ordinary move. - /// An argument is null. - /// tmux reported the command failed. - [UnsupportedOSPlatform("windows")] - public static Task ExecuteAsync( - this MovePaneRequest request, - Pane pane, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(pane); - return pane.Server.Chain().Then(request.ToCommand(pane)).ExecuteAsync(cancellationToken); - } - - /// Returns a split request as one tmux command. - /// How to split. - /// The pane being split. - /// The command, ready to add to a . - /// - /// Splitting into an empty pane arrived in tmux 3.7 and the appearance - /// flags in 3.6, so the pane decides which of them the built command - /// carries. It prints the new pane's identifier the same way the one-shot - /// path does. - /// - /// An argument is null. - public static TmuxCommand ToCommand(this SplitPaneRequest request, Pane pane) - { - ArgumentNullException.ThrowIfNull(request); - ArgumentNullException.ThrowIfNull(pane); - return Command([.. pane.BuildSplitArguments(request)]); - } - - /// Runs a split request on its own. - /// How to split. - /// The pane being split. - /// Cancels the tmux command. - /// What tmux printed, which names the created pane. - /// An argument is null. - /// tmux reported the command failed. - [UnsupportedOSPlatform("windows")] - public static Task ExecuteAsync( - this SplitPaneRequest request, - Pane pane, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(pane); - return pane.Server.Chain().Then(request.ToCommand(pane)).ExecuteAsync(cancellationToken); - } - - /// Returns a floating-pane request as one tmux command. - /// How the pane floats. - /// The pane the new one is created from. - /// The command, ready to add to a . - /// - /// The command arrived whole in tmux 3.7, so batching does not soften the - /// refusal below that: an older server has nothing to send it to. - /// - /// An argument is null. - /// tmux is older than 3.7. - public static TmuxCommand ToCommand(this NewPaneRequest request, Pane pane) - { - ArgumentNullException.ThrowIfNull(request); - ArgumentNullException.ThrowIfNull(pane); - return Command([.. pane.BuildNewPaneArguments(request)]); - } - - /// Runs a floating-pane request on its own. - /// How the pane floats. - /// The pane the new one is created from. - /// Cancels the tmux command. - /// What tmux printed, which names the created pane. - /// An argument is null. - /// tmux reported the command failed. - [UnsupportedOSPlatform("windows")] - public static Task ExecuteAsync( - this NewPaneRequest request, - Pane pane, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(pane); - return pane.Server.Chain().Then(request.ToCommand(pane)).ExecuteAsync(cancellationToken); - } - - /// Returns an attach request as one tmux command. - /// How to attach. - /// The session being attached to. - /// The command, ready to add to a . - /// An argument is null. - public static TmuxCommand ToCommand(this AttachSessionRequest request, Session session) - { - ArgumentNullException.ThrowIfNull(request); - ArgumentNullException.ThrowIfNull(session); - return Command([.. Session.BuildAttachArguments(request, session.Id.ToString())]); - } - - /// Runs an attach request on its own. - /// How to attach. - /// The session being attached to. - /// Cancels the tmux command. - /// What tmux printed. - /// - /// Attaching needs a terminal, so this fails from a process that has none. - /// It is here because a chain that switches a client between sessions is - /// built the same way. - /// - /// An argument is null. - /// tmux reported the command failed. - [UnsupportedOSPlatform("windows")] - public static Task ExecuteAsync( - this AttachSessionRequest request, - Session session, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(session); - return session.Server - .Chain() - .Then(request.ToCommand(session)) - .ExecuteAsync(cancellationToken); - } - - /// Returns a named option read as one tmux command. - /// Which option to read. - /// The options handle whose scope is read. - /// The command, ready to add to a . - /// - /// A chain returns one combined output stream, so several reads batched - /// together arrive undelimited. Reach for this to read something beside - /// the changes a chain makes; reach for the handle's own accessor when - /// what you want is a parsed value. - /// - /// An argument is null. - [UnsupportedOSPlatform("windows")] - public static TmuxCommand ToCommand(this GetOptionRequest request, TmuxOptions options) - { - ArgumentNullException.ThrowIfNull(request); - ArgumentNullException.ThrowIfNull(options); - return Command([.. options.BuildGetArguments(request)]); - } - - /// Runs a named option read on its own. - /// Which option to read. - /// The options handle whose scope is read. - /// The server the option is read from. - /// Cancels the tmux command. - /// What tmux printed, unparsed. - /// - /// answers the same question with the - /// value already parsed, and is what most callers want. - /// - /// An argument is null. - /// tmux reported the command failed. - [UnsupportedOSPlatform("windows")] - public static Task ExecuteAsync( - this GetOptionRequest request, - TmuxOptions options, - Server server, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(server); - return server.Chain().Then(request.ToCommand(options)).ExecuteAsync(cancellationToken); - } - - /// Returns a whole-scope option read as one tmux command. - /// How the scope is read. - /// The options handle whose scope is read. - /// The command, ready to add to a . - /// An argument is null. - [UnsupportedOSPlatform("windows")] - public static TmuxCommand ToCommand(this GetOptionsRequest request, TmuxOptions options) - { - ArgumentNullException.ThrowIfNull(request); - ArgumentNullException.ThrowIfNull(options); - return Command([.. options.BuildGetAllArguments(request)]); - } - - /// Runs a whole-scope option read on its own. - /// How the scope is read. - /// The options handle whose scope is read. - /// The server the options are read from. - /// Cancels the tmux command. - /// What tmux printed, unparsed. - /// - /// answers the same question with - /// the values already parsed, and is what most callers want. - /// - /// An argument is null. - /// tmux reported the command failed. - [UnsupportedOSPlatform("windows")] - public static Task ExecuteAsync( - this GetOptionsRequest request, - TmuxOptions options, - Server server, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(server); - return server.Chain().Then(request.ToCommand(options)).ExecuteAsync(cancellationToken); - } - - /// Returns a hook listing as one tmux command. - /// Which scope to list. - /// The hooks handle whose scope is listed. - /// The command, ready to add to a . - /// - /// A chain returns one combined output stream, so batch a listing to see - /// what the same invocation just installed. - /// answers the same question with the hooks already parsed. - /// - /// An argument is null. - [UnsupportedOSPlatform("windows")] - public static TmuxCommand ToCommand(this ListHooksRequest request, TmuxHooks hooks) - { - ArgumentNullException.ThrowIfNull(request); - ArgumentNullException.ThrowIfNull(hooks); - return Command([.. hooks.BuildListArguments(request)]); - } - - /// Runs a hook listing on its own. - /// Which scope to list. - /// The hooks handle whose scope is listed. - /// The server the hooks are read from. - /// Cancels the tmux command. - /// What tmux printed, unparsed. - /// An argument is null. - /// tmux reported the command failed. - [UnsupportedOSPlatform("windows")] - public static Task ExecuteAsync( - this ListHooksRequest request, - TmuxHooks hooks, - Server server, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(server); - return server.Chain().Then(request.ToCommand(hooks)).ExecuteAsync(cancellationToken); - } - - /// Returns running a hook as one tmux command. - /// Which hook to run. - /// The hooks handle whose scope holds it. - /// The command, ready to add to a . - /// - /// A hook request names a hook without saying what to do with it, so - /// running and removing are separate here rather than one call that has to - /// guess which was meant. - /// - /// An argument is null. - [UnsupportedOSPlatform("windows")] - public static TmuxCommand ToRunCommand(this HookRequest request, TmuxHooks hooks) - { - ArgumentNullException.ThrowIfNull(request); - ArgumentNullException.ThrowIfNull(hooks); - return Command([.. hooks.BuildRunArguments(request)]); - } - - /// Returns removing a hook as one tmux command. - /// Which hook to remove. - /// The hooks handle whose scope holds it. - /// The command, ready to add to a . - /// An argument is null. - [UnsupportedOSPlatform("windows")] - public static TmuxCommand ToUnsetCommand(this HookRequest request, TmuxHooks hooks) - { - ArgumentNullException.ThrowIfNull(request); - ArgumentNullException.ThrowIfNull(hooks); - return Command([.. hooks.BuildUnsetArguments(request)]); - } - - /// Runs a hook on its own. - /// Which hook to run. - /// The hooks handle whose scope holds it. - /// The server the hook runs on. - /// Cancels the tmux command. - /// What tmux printed, which is nothing for an ordinary run. - /// An argument is null. - /// tmux reported the command failed. - [UnsupportedOSPlatform("windows")] - public static Task ExecuteAsync( - this HookRequest request, - TmuxHooks hooks, - Server server, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(server); - return server.Chain().Then(request.ToRunCommand(hooks)).ExecuteAsync(cancellationToken); - } - - /// Returns every command a multi-entry hook request sends. - /// Which hook to set, and to what entries. - /// The hooks handle whose scope holds it. - /// The commands, in the order tmux must receive them. - /// - /// This request is several tmux commands rather than one, so it answers a - /// list. Running them one at a time is what the one-shot path does; adding - /// them to a chain is what this is for. - /// - /// An argument is null. - [UnsupportedOSPlatform("windows")] - public static IReadOnlyList ToCommands( - this SetHooksRequest request, - TmuxHooks hooks) - { - ArgumentNullException.ThrowIfNull(request); - ArgumentNullException.ThrowIfNull(hooks); - return [.. hooks.BuildSetAllArguments(request).Select(arguments => Command([.. arguments]))]; - } - - /// Runs a multi-entry hook request in one invocation. - /// Which hook to set, and to what entries. - /// The hooks handle whose scope holds it. - /// The server the hook is set on. - /// Cancels the tmux command. - /// What that one invocation produced. - /// - /// The one-shot path sends these one process at a time, so this is the - /// case batching helps most. - /// - /// An argument is null. - /// tmux reported the run failed. - [UnsupportedOSPlatform("windows")] - public static Task ExecuteAsync( - this SetHooksRequest request, - TmuxHooks hooks, - Server server, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(server); - TmuxChain chain = server.Chain(); - foreach (TmuxCommand command in request.ToCommands(hooks)) - { - chain = chain.Then(command); - } - - return chain.ExecuteAsync(cancellationToken); - } - - /// Runs a session request on its own. - /// The session to create. - /// The server to create it on. - /// Cancels the tmux command. - /// What tmux printed, which names the created session. - /// - /// This runs the same command - /// builds, so a request executed on its own and the same request added to - /// a chain do the same thing. Reach for the chain when there is more than - /// one command; a single request costs the same either way. - /// - /// An argument is null. - /// tmux reported the command failed. - [UnsupportedOSPlatform("windows")] - public static Task ExecuteAsync( - this NewSessionRequest request, - Server server, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(server); - return server.Chain().Then(request.ToCommand()).ExecuteAsync(cancellationToken); - } - - /// Runs a window request on its own. - /// The window to create. - /// The session that will hold it. - /// Cancels the tmux command. - /// What tmux printed, which names the created window. - /// An argument is null. - /// tmux reported the command failed. - [UnsupportedOSPlatform("windows")] - public static Task ExecuteAsync( - this NewWindowRequest request, - Session session, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(session); - return session.Server - .Chain() - .Then(request.ToCommand(session.Id.ToString())) - .ExecuteAsync(cancellationToken); - } - - /// Runs a key request on its own. - /// The keys to send. - /// The pane that receives them. - /// Cancels the tmux command. - /// What tmux printed, which is nothing for an ordinary send. - /// An argument is null. - /// tmux reported the command failed. - [UnsupportedOSPlatform("windows")] - public static Task ExecuteAsync( - this SendKeysRequest request, - Pane pane, - CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(pane); - return pane.Server.Chain().Then(request.ToCommand(pane)).ExecuteAsync(cancellationToken); - } - private static TmuxCommand Command(string[] arguments) => new(arguments[0], arguments[1..]); } From 70854535249f3652c3773d59a7eddfb31f4460c1 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 14:49:22 -0500 Subject: [PATCH 064/129] ControlMode(fix[eof]): Reject truncated control streams why: tmux ends the control protocol with %exit. Treating raw pipe EOF as a normal exit hid killed clients and invented an exit event tmux never sent. what: - Require %exit unless caller-requested disposal closed the stream - Fault events, pending commands, and disposal on truncated output - Prove normal and killed-client exits on both target frameworks --- src/LibTmux/ControlMode/ControlModeSession.cs | 20 ++++- .../ControlMode/ControlModeSessionTests.cs | 43 +++++++++ .../ControlModeSessionFailureTests.cs | 88 +++++++++++++++++-- 3 files changed, 140 insertions(+), 11 deletions(-) diff --git a/src/LibTmux/ControlMode/ControlModeSession.cs b/src/LibTmux/ControlMode/ControlModeSession.cs index ed2421a..1000196 100644 --- a/src/LibTmux/ControlMode/ControlModeSession.cs +++ b/src/LibTmux/ControlMode/ControlModeSession.cs @@ -433,6 +433,7 @@ private void ThrowIfStopping() => private async Task PumpAsync() { + bool sawExit = false; string? exitReason = null; Exception? pumpFailure = null; try @@ -466,12 +467,19 @@ private async Task PumpAsync() (string name, IReadOnlyList arguments) = SplitNotification(line); if (string.Equals(name, "exit", StringComparison.Ordinal)) { + sawExit = true; exitReason = arguments.Count == 0 ? null : string.Join(' ', arguments); break; } _events.TryWrite(ToEvent(name, arguments)); } + + if (!sawExit && Volatile.Read(ref _stopRequested) == 0) + { + throw new EndOfStreamException(WithStandardError( + "The tmux control stream ended without an %exit notification.")); + } } catch (Exception error) { @@ -480,11 +488,17 @@ private async Task PumpAsync() } finally { - _events.TryWrite(new TmuxExitEvent(exitReason)); + if (pumpFailure is null) + { + _events.TryWrite(new TmuxExitEvent(exitReason)); + } + _events.Complete(pumpFailure); + string terminalMessage = _ready.Task.IsCompletedSuccessfully + ? "The tmux control client exited before answering a pending command." + : "The tmux control client exited before it finished attaching."; Exception terminalFailure = pumpFailure ?? new InvalidOperationException( - WithStandardError( - "The tmux control client exited before it finished attaching.")); + WithStandardError(terminalMessage)); _ready.TrySetException(terminalFailure); StopAndFailPending(terminalFailure); } diff --git a/tests/LibTmux.IntegrationTests/ControlMode/ControlModeSessionTests.cs b/tests/LibTmux.IntegrationTests/ControlMode/ControlModeSessionTests.cs index b1bc1ad..48d6f71 100644 --- a/tests/LibTmux.IntegrationTests/ControlMode/ControlModeSessionTests.cs +++ b/tests/LibTmux.IntegrationTests/ControlMode/ControlModeSessionTests.cs @@ -298,6 +298,49 @@ public async Task The_event_stream_ends_with_an_exit() await control.DisposeAsync(); } + [UnixFact] + public async Task A_killed_control_client_faults_without_an_exit_event() + { + await using RawTmuxTestContext raw = await RawTmuxTestContext.StartAsync( + TestContext.Current.CancellationToken); + CancellationToken token = TestContext.Current.CancellationToken; + Server server = await ConnectAsync(raw, token); + IControlModeSession control = await server.EnterControlModeAsync( + cancellationToken: token); + RawTmuxResult clients = await raw.ExecuteAsync( + ["list-clients", "-F", "#{client_pid}\t#{client_control_mode}"], + token); + string client = Assert.Single( + clients.StandardOutputLines, + static line => line.EndsWith("\t1", StringComparison.Ordinal)); + string[] fields = client.Split('\t'); + Assert.True(int.TryParse(fields[0], out int clientProcessId)); + + using (Process process = Process.GetProcessById(clientProcessId)) + { + process.Kill(entireProcessTree: false); + await process.WaitForExitAsync(token); + } + + var observed = new List(); + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(token); + timeout.CancelAfter(TimeSpan.FromSeconds(15)); + EndOfStreamException eventFailure = + await Assert.ThrowsAsync(async () => + { + await foreach (TmuxEvent item in control.Events.WithCancellation(timeout.Token)) + { + observed.Add(item); + } + }); + + Assert.DoesNotContain(observed, static item => item is TmuxExitEvent); + EndOfStreamException disposalFailure = + await Assert.ThrowsAsync( + () => control.DisposeAsync().AsTask()); + Assert.Same(eventFailure, disposalFailure); + } + [UnixFact] public async Task Startup_rejects_a_server_restart_between_discovery_and_attach() { diff --git a/tests/LibTmux.UnitTests/ControlMode/ControlModeSessionFailureTests.cs b/tests/LibTmux.UnitTests/ControlMode/ControlModeSessionFailureTests.cs index ffd6c1b..3cde6d8 100644 --- a/tests/LibTmux.UnitTests/ControlMode/ControlModeSessionFailureTests.cs +++ b/tests/LibTmux.UnitTests/ControlMode/ControlModeSessionFailureTests.cs @@ -141,22 +141,79 @@ public async Task A_canceled_write_lock_wait_releases_its_unenqueued_slot() } [Fact] - public async Task Terminal_eof_rejects_commands_when_the_process_still_claims_to_run() + public async Task Raw_eof_faults_without_a_synthetic_exit_event() { CancellationToken token = TestContext.Current.CancellationToken; var process = new TerminalWhileRunningProcess(); var session = new ControlModeSession(process); await session.WaitForReadyAsync(token); - Task eventsCompleted = DrainEventsAsync(session.Events, token); + await using IAsyncEnumerator events = + session.Events.GetAsyncEnumerator(token); process.EndOutput(); - await eventsCompleted.WaitAsync(token); + EndOfStreamException eventFailure = + await Assert.ThrowsAsync(async () => + await events.MoveNextAsync().AsTask().WaitAsync(token)); Assert.False(session.IsRunning); await Assert.ThrowsAsync( () => session.SendAsync( TmuxCommand.Create("display-message", "-p", "too-late"), token)); + EndOfStreamException disposalFailure = + await Assert.ThrowsAsync( + () => session.DisposeAsync().AsTask()); + Assert.Same(eventFailure, disposalFailure); + Assert.True(process.DisposeCalled); + } + + [Fact] + public async Task Disposal_induced_eof_completes_with_an_exit_event() + { + CancellationToken token = TestContext.Current.CancellationToken; + var process = new TerminalWhileRunningProcess(); + var session = new ControlModeSession(process); + + await session.WaitForReadyAsync(token); + await session.DisposeAsync().AsTask().WaitAsync(token); + + await using IAsyncEnumerator events = + session.Events.GetAsyncEnumerator(token); + Assert.True(await events.MoveNextAsync()); + TmuxExitEvent exit = Assert.IsType(events.Current); + Assert.Null(exit.Reason); + Assert.False(await events.MoveNextAsync()); + Assert.True(process.DisposeCalled); + } + + [Fact] + public async Task Exit_after_attach_reports_the_unanswered_command() + { + CancellationToken token = TestContext.Current.CancellationToken; + var process = new TerminalWhileRunningProcess(); + var session = new ControlModeSession(process); + + await session.WaitForReadyAsync(token); + Task> command = session.SendAsync( + TmuxCommand.Create("display-message", "-p", "unanswered"), + token); + await process.Flushed.Task.WaitAsync(token); + process.Exit("server stopped"); + + InvalidOperationException failure = + await Assert.ThrowsAsync(async () => await command); + Assert.Equal( + "The tmux control client exited before answering a pending command.", + failure.Message); + + var observed = new List(); + await foreach (TmuxEvent item in session.Events.WithCancellation(token)) + { + observed.Add(item); + } + + TmuxExitEvent exit = Assert.IsType(Assert.Single(observed)); + Assert.Equal("server stopped", exit.Reason); await session.DisposeAsync(); Assert.True(process.DisposeCalled); } @@ -188,7 +245,7 @@ await Assert.ThrowsAsync( } [Fact] - public async Task Terminal_eof_during_final_check_cannot_escape_the_pending_sweep() + public async Task Raw_eof_during_final_check_cannot_escape_the_pending_sweep() { CancellationToken token = TestContext.Current.CancellationToken; var process = new TerminalWhileRunningProcess(endDuringFinalCheck: true); @@ -196,15 +253,20 @@ public async Task Terminal_eof_during_final_check_cannot_escape_the_pending_swee await session.WaitForReadyAsync(token); TmuxCommand command = TmuxCommand.Create("display-message", "-p", "racing"); - InvalidOperationException terminalFailure = - await Assert.ThrowsAsync(async () => + EndOfStreamException terminalFailure = + await Assert.ThrowsAsync(async () => await session.SendAsync(command, token) .WaitAsync(TimeSpan.FromSeconds(2), token)); - Assert.Contains("exited before", terminalFailure.Message, StringComparison.Ordinal); + Assert.Equal( + "The tmux control stream ended without an %exit notification.", + terminalFailure.Message); Assert.False(session.IsRunning); Assert.Equal([ControlModeCommandRenderer.Render(command)], process.WriteAttempts); - await session.DisposeAsync(); + EndOfStreamException disposalFailure = + await Assert.ThrowsAsync( + () => session.DisposeAsync().AsTask()); + Assert.Same(terminalFailure, disposalFailure); Assert.True(process.DisposeCalled); } @@ -674,6 +736,9 @@ internal TerminalWhileRunningProcess(bool endDuringFinalCheck = false) internal bool DisposeCalled { get; private set; } + internal TaskCompletionSource Flushed { get; } = new( + TaskCreationOptions.RunContinuationsAsynchronously); + internal List WriteAttempts { get; } = []; public bool HasExited @@ -703,6 +768,7 @@ public Task WriteLineAsync( public Task FlushAsync(CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); + Flushed.TrySetResult(); return Task.CompletedTask; } @@ -737,6 +803,12 @@ public Task WaitForExitAsync(CancellationToken cancellationToken = default) => public void Dispose() => DisposeCalled = true; + internal void Exit(string reason) + { + _output.Writer.TryWrite($"%exit {reason}"); + EndOutput(); + } + internal void EndOutput(Exception? failure = null) => _output.Writer.TryComplete(failure); } From 11a76f049f7b35b9bb144ec7e79c1f15969f2cec Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 14:56:33 -0500 Subject: [PATCH 065/129] Query(fix[structure]): Bound direct document traversal why: Directly constructed documents could bypass JSON's v1 ceilings and drive recursive compilation or snapshot-depth traversal beyond safe bounds.\n\nwhat:\n- Add one iterative, fail-closed guard for 32 levels and 512 node occurrences\n- Share those frozen ceilings with JSON and check cancellation during the walk\n- Cover every AST edge, malformed shapes, both entry points, and deliberate red mutations --- .../QueryJsonSerializerContext.cs | 4 +- src/LibTmux/Query/QueryDocument.cs | 12 +- .../Query/QueryDocumentStructuralGuard.cs | 106 ++++++++++ src/LibTmux/Query/QueryDocumentValidator.cs | 3 +- src/LibTmux/Query/QueryInterpreter.cs | 2 +- .../QueryDocumentStructuralGuardTests.cs | 194 ++++++++++++++++++ 6 files changed, 316 insertions(+), 5 deletions(-) create mode 100644 src/LibTmux/Query/QueryDocumentStructuralGuard.cs create mode 100644 tests/LibTmux.UnitTests/Query/QueryDocumentStructuralGuardTests.cs diff --git a/src/LibTmux.Query.Json/QueryJsonSerializerContext.cs b/src/LibTmux.Query.Json/QueryJsonSerializerContext.cs index 31b53f0..f92675a 100644 --- a/src/LibTmux.Query.Json/QueryJsonSerializerContext.cs +++ b/src/LibTmux.Query.Json/QueryJsonSerializerContext.cs @@ -23,8 +23,8 @@ public sealed record QueryJsonLimits( { /// The frozen v1 ceilings. public static QueryJsonLimits V1 { get; } = new( - MaximumDepth: 32, - MaximumNodes: 512, + MaximumDepth: QueryDocumentStructuralGuard.MaximumDepth, + MaximumNodes: QueryDocumentStructuralGuard.MaximumNodeOccurrences, MaximumStringLength: 4096, MaximumPatternLength: QueryRegexSemantics.MaximumPatternLength, MaximumUtf8Bytes: 262144); diff --git a/src/LibTmux/Query/QueryDocument.cs b/src/LibTmux/Query/QueryDocument.cs index 49966c0..832a8d7 100644 --- a/src/LibTmux/Query/QueryDocument.cs +++ b/src/LibTmux/Query/QueryDocument.cs @@ -27,7 +27,17 @@ public sealed record QueryDocument( /// A quantifier over a relation cannot be answered by a shallower capture, /// so the depth is derived from the predicate rather than assumed. /// - public SnapshotDepth RequiredSnapshotDepth => Depth(Predicate, Target); + /// + /// The predicate is malformed or exceeds the version-one structural limits. + /// + public SnapshotDepth RequiredSnapshotDepth + { + get + { + QueryDocumentStructuralGuard.Validate(Predicate); + return Depth(Predicate, Target); + } + } private static SnapshotDepth Depth(QueryNode node, QueryTarget target) => node switch { diff --git a/src/LibTmux/Query/QueryDocumentStructuralGuard.cs b/src/LibTmux/Query/QueryDocumentStructuralGuard.cs new file mode 100644 index 0000000..ef8ead6 --- /dev/null +++ b/src/LibTmux/Query/QueryDocumentStructuralGuard.cs @@ -0,0 +1,106 @@ +namespace LibTmux.Query; + +internal static class QueryDocumentStructuralGuard +{ + internal const int MaximumDepth = 32; + internal const int MaximumNodeOccurrences = 512; + + internal static void Validate(QueryNode? predicate, Action? check = null) + { + var pending = new Stack<(QueryNode Node, int Depth)>(); + int occurrences = 0; + Push(predicate, depth: 1); + + while (pending.Count > 0) + { + (QueryNode node, int depth) = pending.Pop(); + int childDepth = depth + 1; + switch (node) + { + case AndNode and: + PushOperands(and.Operands, childDepth); + break; + case OrNode or: + PushOperands(or.Operands, childDepth); + break; + case NotNode not: + Push(not.Operand, childDepth); + break; + case ComparisonNode comparison: + Push(comparison.Right, childDepth); + Push(comparison.Left, childDepth); + break; + case StringNode text: + Push(text.Right, childDepth); + Push(text.Left, childDepth); + break; + case RegexNode regex: + Push(regex.Input, childDepth); + break; + case QuantifierNode quantifier: + Push(quantifier.Predicate, childDepth); + Push(quantifier.Relation, childDepth); + break; + case ConstantNode constant: + ValidateConstant(constant.Value); + break; + case FieldNode: + break; + default: + throw Unsupported($"Node '{node.GetType().Name}' is not supported."); + } + } + + void PushOperands(IReadOnlyList operands, int depth) + { + for (int index = operands.Count - 1; index >= 0; index--) + { + Push(operands[index], depth); + } + } + + void Push(QueryNode? node, int depth) + { + check?.Invoke(); + if (node is null) + { + throw Unsupported("Query document contains a null node."); + } + + if (depth > MaximumDepth) + { + throw Unsupported( + $"Query document exceeds the maximum nesting depth of {MaximumDepth}."); + } + + if (++occurrences > MaximumNodeOccurrences) + { + throw Unsupported( + "Query document exceeds the maximum node count of " + + $"{MaximumNodeOccurrences}."); + } + + pending.Push((node, depth)); + } + } + + private static void ValidateConstant(QueryConstant? constant) + { + switch (constant) + { + case NullConstant: + case BooleanConstant: + case Int64Constant: + case StringConstant: + case TypedIdConstant: + return; + case null: + throw Unsupported("Query document contains a null constant."); + default: + throw Unsupported( + $"Constant '{constant.GetType().Name}' is not supported."); + } + } + + private static UnsupportedQueryExpressionException Unsupported(string message) => new(message); +} diff --git a/src/LibTmux/Query/QueryDocumentValidator.cs b/src/LibTmux/Query/QueryDocumentValidator.cs index b5e430a..8cd138c 100644 --- a/src/LibTmux/Query/QueryDocumentValidator.cs +++ b/src/LibTmux/Query/QueryDocumentValidator.cs @@ -4,7 +4,7 @@ namespace LibTmux.Query; internal static class QueryDocumentValidator { - internal static QueryValidationResult Validate(QueryDocument document) + internal static QueryValidationResult Validate(QueryDocument document, Action? check = null) { ArgumentNullException.ThrowIfNull(document); if (!string.Equals( @@ -17,6 +17,7 @@ internal static QueryValidationResult Validate(QueryDocument document) } _ = Target(document.Target); + QueryDocumentStructuralGuard.Validate(document.Predicate, check); QueryValidationResult result = new(); ValidatePredicate(document.Predicate, document.Target, result); return result; diff --git a/src/LibTmux/Query/QueryInterpreter.cs b/src/LibTmux/Query/QueryInterpreter.cs index 12e3da8..b2ab90f 100644 --- a/src/LibTmux/Query/QueryInterpreter.cs +++ b/src/LibTmux/Query/QueryInterpreter.cs @@ -43,7 +43,7 @@ private static Func Compile( Action? check) { ArgumentNullException.ThrowIfNull(document); - QueryValidationResult validation = QueryDocumentValidator.Validate(document); + QueryValidationResult validation = QueryDocumentValidator.Validate(document, check); QueryPlanBindings bindings = new(validation); Func predicate = BindPredicate( document.Predicate, diff --git a/tests/LibTmux.UnitTests/Query/QueryDocumentStructuralGuardTests.cs b/tests/LibTmux.UnitTests/Query/QueryDocumentStructuralGuardTests.cs new file mode 100644 index 0000000..58b4a1c --- /dev/null +++ b/tests/LibTmux.UnitTests/Query/QueryDocumentStructuralGuardTests.cs @@ -0,0 +1,194 @@ +using System.Text.RegularExpressions; +using LibTmux.Query; + +namespace LibTmux.UnitTests.Query; + +public sealed class QueryDocumentStructuralGuardTests +{ + private const int VersionOneMaximumDepth = 32; + private const int VersionOneMaximumNodeOccurrences = 512; + private static readonly QueryNode True = new ConstantNode(new BooleanConstant(true)); + + private sealed record Row(string SessionName); + + private sealed record UnknownNode : QueryNode; + + private sealed record UnknownConstant : QueryConstant; + + public static TheoryData NodesWithDeepChildren + { + get + { + QueryNode deep = NestedNot(True, VersionOneMaximumDepth - 1); + return new() + { + { "and operand", new AndNode([deep]) }, + { "or operand", new OrNode([deep]) }, + { "not operand", new NotNode(deep) }, + { + "comparison left", + new ComparisonNode(QueryComparison.Equal, deep, True) + }, + { + "comparison right", + new ComparisonNode(QueryComparison.Equal, True, deep) + }, + { + "string left", + new StringNode(QueryStringOperation.EqualsOrdinal, deep, True) + }, + { + "string right", + new StringNode(QueryStringOperation.EqualsOrdinal, True, deep) + }, + { + "regex input", + new RegexNode(deep, QueryRegexSemantics.Dialect, "x", RegexOptions.None) + }, + { + "quantifier predicate", + new QuantifierNode( + QueryQuantifier.Any, + new FieldNode(QueryTarget.Session, "session_windows"), + deep) + }, + }; + } + } + + public static TheoryData MalformedShapes => + new() + { + { "root", null!, "null" }, + { + "quantifier relation", + new QuantifierNode(QueryQuantifier.Any, null!, True), + "null" + }, + { "constant value", new ConstantNode(null!), "null" }, + { "unknown node", new UnknownNode(), "not supported" }, + { + "unknown constant", + new ConstantNode(new UnknownConstant()), + "not supported" + }, + }; + + [Fact] + public void Depth_limit_accepts_the_boundary_and_rejects_the_next_level() + { + QueryDocument atLimit = Document( + NestedNot(True, VersionOneMaximumDepth - 1)); + + Assert.NotNull(atLimit.Compile()); + Assert.Equal(SnapshotDepth.Sessions, atLimit.RequiredSnapshotDepth); + AssertRejected(new NotNode(atLimit.Predicate), "nesting depth"); + } + + [Fact] + public void Size_limit_counts_shared_nodes_as_occurrences() + { + QueryDocument atLimit = Document( + new OrNode( + [.. Enumerable.Repeat(True, VersionOneMaximumNodeOccurrences - 1)])); + + Assert.NotNull(atLimit.Compile()); + Assert.Equal(SnapshotDepth.Sessions, atLimit.RequiredSnapshotDepth); + + AssertRejected( + new OrNode( + [.. Enumerable.Repeat(True, VersionOneMaximumNodeOccurrences)]), + "node count"); + } + + [Theory] + [MemberData(nameof(NodesWithDeepChildren))] + public void Guard_visits_every_query_node_edge(string edge, QueryNode predicate) + { + Assert.NotEmpty(edge); + + AssertRejected(predicate, "nesting depth"); + } + + [Theory] + [MemberData(nameof(MalformedShapes))] + public void Entry_points_reject_malformed_shapes( + string shape, + QueryNode predicate, + string messageFragment) + { + Assert.NotEmpty(shape); + + AssertRejected(predicate, messageFragment); + } + + [Fact] + public void Cancellation_is_checked_during_the_structural_walk() + { + QueryDocument document = Document(new AndNode([True, True, True, True])); + int checks = 0; + + Assert.Throws( + () => QueryDocumentValidator.Validate( + document, + () => + { + if (++checks == 3) + { + throw new OperationCanceledException(); + } + })); + + Assert.Equal(3, checks); + } + + [Fact] + public void Compilation_preserves_the_cancellation_token() + { + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + + OperationCanceledException failure = Assert.Throws( + () => QueryInterpreter.Compile(Document(True), cancellation.Token)); + + Assert.Equal(cancellation.Token, failure.CancellationToken); + } + + private static QueryDocument Document(QueryNode predicate) => + new( + QueryDocument.CurrentSchema, + QueryDocument.CurrentVersion, + QueryTarget.Session, + predicate); + + private static QueryNode NestedNot(QueryNode operand, int levels) + { + QueryNode result = operand; + for (int level = 0; level < levels; level++) + { + result = new NotNode(result); + } + + return result; + } + + private static void AssertRejected(QueryNode predicate, string messageFragment) + { + QueryDocument document = Document(predicate); + UnsupportedQueryExpressionException compilationFailure = + Assert.Throws( + () => document.Compile()); + UnsupportedQueryExpressionException depthFailure = + Assert.Throws( + () => document.RequiredSnapshotDepth); + + Assert.Contains( + messageFragment, + compilationFailure.Message, + StringComparison.OrdinalIgnoreCase); + Assert.Contains( + messageFragment, + depthFailure.Message, + StringComparison.OrdinalIgnoreCase); + } +} From cdc4f333895d5915b1df0848e3586bf30e87d4af Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 15:07:32 -0500 Subject: [PATCH 066/129] Workspace(fix[readiness]): Keep probes out of pane input why: In-band acknowledgements could appear in scrollback, be consumed by startup readers, and left the first pane on options applied too late. what: - Add Auto, Always, and Never policies backed by passive pane polling - Spawn the real first window after session options take effect - Document heuristic limits and prove them on tmux 3.2a and both frameworks --- src/LibTmux.Workspace/PaneReadiness.cs | 14 ++ src/LibTmux.Workspace/PaneReadinessWaiter.cs | 91 ++++++++ src/LibTmux.Workspace/PublicAPI.Unshipped.txt | 6 +- src/LibTmux.Workspace/README.md | 28 ++- src/LibTmux.Workspace/WorkspaceBuilder.cs | 208 ++++++++++++------ .../Workspace/WorkspaceBuilderTests.cs | 177 +++++++++++++-- 6 files changed, 434 insertions(+), 90 deletions(-) create mode 100644 src/LibTmux.Workspace/PaneReadiness.cs create mode 100644 src/LibTmux.Workspace/PaneReadinessWaiter.cs diff --git a/src/LibTmux.Workspace/PaneReadiness.cs b/src/LibTmux.Workspace/PaneReadiness.cs new file mode 100644 index 0000000..88fede2 --- /dev/null +++ b/src/LibTmux.Workspace/PaneReadiness.cs @@ -0,0 +1,14 @@ +namespace LibTmux.Workspace; + +/// Controls whether workspace panes wait for a prompt-like state. +public enum PaneReadiness +{ + /// Waits before workspace commands only when the session default shell is zsh. + Auto = 0, + + /// Waits before commands sent to every pane that runs the session default shell. + Always = 1, + + /// Sends workspace commands without a readiness wait. + Never = 2, +} diff --git a/src/LibTmux.Workspace/PaneReadinessWaiter.cs b/src/LibTmux.Workspace/PaneReadinessWaiter.cs new file mode 100644 index 0000000..ac16219 --- /dev/null +++ b/src/LibTmux.Workspace/PaneReadinessWaiter.cs @@ -0,0 +1,91 @@ +using System.Globalization; +using System.Runtime.Versioning; + +namespace LibTmux.Workspace; + +internal static class PaneReadinessWaiter +{ + private const string Format = + "#{pane_current_command}\t#{cursor_x}\t#{cursor_y}"; + private static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(50); + + internal static string? SelectShell( + PaneReadiness paneReadiness, + string defaultCommand, + string defaultShell) + { + if (paneReadiness == PaneReadiness.Never || defaultCommand.Length > 0) + { + return null; + } + + string shellCommand = Path.GetFileName(defaultShell); + return paneReadiness == PaneReadiness.Always + || string.Equals(shellCommand, "zsh", StringComparison.Ordinal) + ? shellCommand + : null; + } + + [UnsupportedOSPlatform("windows")] + internal static async Task WaitAsync( + Pane pane, + string expectedShellCommand, + TimeSpan timeoutInterval, + CancellationToken cancellationToken) + { + using CancellationTokenSource timeout = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken); + timeout.CancelAfter(timeoutInterval); + + try + { + while (true) + { + IReadOnlyList? sample = await pane.DisplayMessageAsync( + new DisplayMessageRequest(returnText: true, format: Format), + timeout.Token) + .ConfigureAwait(false); + if (IsReady(sample, expectedShellCommand)) + { + return; + } + + await Task.Delay(PollInterval, timeout.Token).ConfigureAwait(false); + } + } + catch (OperationCanceledException failure) when ( + timeout.IsCancellationRequested && !cancellationToken.IsCancellationRequested) + { + throw new TmuxWaitTimeoutException( + $"Pane {pane.Id} did not reach a prompt-like state within " + + $"{timeoutInterval.TotalSeconds:0.###} seconds.", + timeoutInterval, + failure); + } + } + + private static bool IsReady( + IReadOnlyList? sample, + string expectedShellCommand) + { + if (sample is not { Count: 1 }) + { + return false; + } + + string[] fields = sample[0].Split('\t'); + return fields.Length == 3 + && string.Equals(fields[0], expectedShellCommand, StringComparison.Ordinal) + && uint.TryParse( + fields[1], + NumberStyles.None, + CultureInfo.InvariantCulture, + out uint cursorX) + && uint.TryParse( + fields[2], + NumberStyles.None, + CultureInfo.InvariantCulture, + out uint cursorY) + && (cursorX != 0 || cursorY != 0); + } +} diff --git a/src/LibTmux.Workspace/PublicAPI.Unshipped.txt b/src/LibTmux.Workspace/PublicAPI.Unshipped.txt index 726ae8f..37c0923 100644 --- a/src/LibTmux.Workspace/PublicAPI.Unshipped.txt +++ b/src/LibTmux.Workspace/PublicAPI.Unshipped.txt @@ -1,7 +1,11 @@ #nullable enable +LibTmux.Workspace.PaneReadiness +LibTmux.Workspace.PaneReadiness.Always = 1 -> LibTmux.Workspace.PaneReadiness +LibTmux.Workspace.PaneReadiness.Auto = 0 -> LibTmux.Workspace.PaneReadiness +LibTmux.Workspace.PaneReadiness.Never = 2 -> LibTmux.Workspace.PaneReadiness LibTmux.Workspace.WorkspaceBuilder LibTmux.Workspace.WorkspaceBuilder.BuildAsync(LibTmux.Workspace.WorkspaceFile! workspace, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! -LibTmux.Workspace.WorkspaceBuilder.WorkspaceBuilder(LibTmux.Server! server, System.TimeSpan? shellReadyTimeout = null) -> void +LibTmux.Workspace.WorkspaceBuilder.WorkspaceBuilder(LibTmux.Server! server, System.TimeSpan? readinessTimeout = null, LibTmux.Workspace.PaneReadiness paneReadiness = LibTmux.Workspace.PaneReadiness.Auto) -> void LibTmux.Workspace.WorkspaceFile LibTmux.Workspace.WorkspaceFile.Options.get -> System.Collections.Generic.IReadOnlyDictionary! LibTmux.Workspace.WorkspaceFile.SessionName.get -> string? diff --git a/src/LibTmux.Workspace/README.md b/src/LibTmux.Workspace/README.md index 847ecc0..59951c6 100644 --- a/src/LibTmux.Workspace/README.md +++ b/src/LibTmux.Workspace/README.md @@ -68,12 +68,28 @@ rebased to the directory containing `session.yaml`. contains only layouts that tmux rejected; those windows remain usable. Other tmux failures throw and can leave a partially built session. The builder -is not transactional. Before sending the first workspace command to a pane, it -waits up to ten seconds for that pane's shell to acknowledge input. Pass a -different timeout to the `WorkspaceBuilder` constructor when startup needs a -different budget. An expired wait raises `TmuxWaitTimeoutException` before a -workspace command reaches that pane. A missing session name or empty window -list raises `WorkspaceFormatException` before creating anything. +is not transactional. Before sending workspace commands, `PaneReadiness.Auto`, +the default, waits only for panes using a zsh session `default-shell`. +`PaneReadiness.Always` waits before commands sent to every default-shell pane; +`PaneReadiness.Never` sends them immediately. A nonempty session +`default-command` skips the wait under every policy because that command is not +treated as an interactive shell. + +A wait polls the targeted pane's `pane_current_command`, `cursor_x`, and +`cursor_y` for up to ten seconds. It sends no keys and creates no `wait-for` +channel. The result is a prompt heuristic, not an input acknowledgement: +startup output can move the cursor before a prompt exists, while a prompt left +at `(0, 0)` times out. Pass a different timeout to the `WorkspaceBuilder` +constructor when startup needs a different budget. An expired wait raises +`TmuxWaitTimeoutException` before a workspace command reaches that pane. + +tmux starts a session's first pane before session options can be set. The +builder therefore creates one transient bootstrap window, applies the options, +creates the described first window under them, and removes the bootstrap. +tmux hooks can observe that extra window lifecycle. Readiness polling uses +targeted `display-message` calls, so an `after-display-message` hook can also +observe each sample. A missing session name or empty window list raises +`WorkspaceFormatException` before creating anything. ## What is in scope diff --git a/src/LibTmux.Workspace/WorkspaceBuilder.cs b/src/LibTmux.Workspace/WorkspaceBuilder.cs index 8bbd265..8ed050f 100644 --- a/src/LibTmux.Workspace/WorkspaceBuilder.cs +++ b/src/LibTmux.Workspace/WorkspaceBuilder.cs @@ -6,28 +6,43 @@ namespace LibTmux.Workspace; [UnsupportedOSPlatform("windows")] public sealed class WorkspaceBuilder { - private static readonly TimeSpan DefaultShellReadyTimeout = TimeSpan.FromSeconds(10); + private const string BootstrapWindowName = "libtmux-bootstrap"; + private static readonly TimeSpan DefaultReadinessTimeout = TimeSpan.FromSeconds(10); private readonly Server _server; - private readonly TimeSpan _shellReadyTimeout; + private readonly PaneReadiness _paneReadiness; + private readonly TimeSpan _readinessTimeout; /// Initializes a builder against one server. /// The server the session is built on. - /// - /// How long a pane may take to acknowledge shell input, or null for ten seconds. + /// + /// How long a pane may take to reach a prompt-like state, or null for ten seconds. /// - public WorkspaceBuilder(Server server, TimeSpan? shellReadyTimeout = null) + /// Which default-shell panes wait for readiness. + public WorkspaceBuilder( + Server server, + TimeSpan? readinessTimeout = null, + PaneReadiness paneReadiness = PaneReadiness.Auto) { ArgumentNullException.ThrowIfNull(server); - if (shellReadyTimeout is TimeSpan timeout && timeout <= TimeSpan.Zero) + if (readinessTimeout is TimeSpan timeout && timeout <= TimeSpan.Zero) { throw new ArgumentOutOfRangeException( - nameof(shellReadyTimeout), - shellReadyTimeout, - "A shell needs time to become ready."); + nameof(readinessTimeout), + readinessTimeout, + "A readiness timeout must be positive."); + } + + if (!Enum.IsDefined(paneReadiness)) + { + throw new ArgumentOutOfRangeException( + nameof(paneReadiness), + paneReadiness, + "The pane-readiness policy is not defined."); } _server = server; - _shellReadyTimeout = shellReadyTimeout ?? DefaultShellReadyTimeout; + _readinessTimeout = readinessTimeout ?? DefaultReadinessTimeout; + _paneReadiness = paneReadiness; } /// Builds a session from a workspace. @@ -36,8 +51,13 @@ public WorkspaceBuilder(Server server, TimeSpan? shellReadyTimeout = null) /// What was built, and what could not be. /// The workspace describes no session. /// - /// A pane did not acknowledge shell input before its readiness timeout. + /// A pane did not reach a prompt-like state before its readiness timeout. /// + /// + /// Readiness is inferred from the pane's current command and cursor position. + /// Startup output can resemble a prompt, and a prompt left at the origin can + /// time out; the builder never writes a readiness probe to the pane. + /// public async Task BuildAsync( WorkspaceFile workspace, CancellationToken cancellationToken = default) @@ -55,25 +75,61 @@ public async Task BuildAsync( List unsupported = []; - // tmux makes a session with one window, so the file's first window is - // that one rather than an extra. + // tmux creates the first pane before session options exist. This + // bootstrap keeps the session alive until the described first window + // can be spawned under those options. WorkspaceWindow first = workspace.Windows[0]; Session session = await _server.CreateSessionAsync( new NewSessionRequest( name: workspace.SessionName, - windowName: first.WindowName, - startDirectory: StartDirectoryFor(first, workspace)), + windowName: BootstrapWindowName, + startDirectory: StartDirectoryFor(first, workspace), + command: "/bin/sh"), cancellationToken) .ConfigureAwait(false); await ApplyOptionsAsync(session.Options, workspace.Options, cancellationToken) .ConfigureAwait(false); - List windows = []; - IReadOnlyList existing = await session.GetWindowsAsync(cancellationToken) + IReadOnlyList bootstrapWindows = await session.GetWindowsAsync(cancellationToken) + .ConfigureAwait(false); + Window bootstrap = bootstrapWindows.Count == 1 + ? bootstrapWindows[0] + : throw new InvalidDataException( + "tmux did not report exactly one bootstrap window."); + string firstIndex = await ReadOptionAsync( + session.Options, + "base-index", + false, + cancellationToken) .ConfigureAwait(false); - windows.Add(await FillAsync(existing[0], first, workspace, unsupported, cancellationToken) - .ConfigureAwait(false)); + string? expectedShellCommand = await ResolveReadinessShellAsync( + session, + cancellationToken) + .ConfigureAwait(false); + Window firstWindow = await session.CreateWindowAsync( + new NewWindowRequest( + name: first.WindowName, + startDirectory: StartDirectoryFor(first, workspace), + index: firstIndex, + killExisting: true), + cancellationToken) + .ConfigureAwait(false); + if (firstWindow.Index != bootstrap.Index) + { + await bootstrap.KillAsync(cancellationToken: cancellationToken).ConfigureAwait(false); + } + + List windows = []; + windows.Add( + await FillAsync( + firstWindow, + first, + workspace, + unsupported, + expectedShellCommand, + cancellationToken) + .ConfigureAwait(false)); foreach (WorkspaceWindow described in workspace.Windows.Skip(1)) { @@ -84,14 +140,23 @@ await ApplyOptionsAsync(session.Options, workspace.Options, cancellationToken) cancellationToken) .ConfigureAwait(false); windows.Add( - await FillAsync(window, described, workspace, unsupported, cancellationToken) + await FillAsync( + window, + described, + workspace, + unsupported, + expectedShellCommand, + cancellationToken) .ConfigureAwait(false)); } // Selecting last means the file's focus wins over the side effects of // building, which leave whatever was made most recently selected. await SelectFocusedAsync(workspace, windows, cancellationToken).ConfigureAwait(false); - return new WorkspaceResult(session, windows, unsupported); + return new WorkspaceResult( + await session.RefreshAsync(cancellationToken).ConfigureAwait(false), + windows, + unsupported); } private static string? StartDirectoryFor( @@ -113,6 +178,58 @@ await options.SetAsync(new SetOptionRequest(name, value), cancellationToken) } } + private async Task ResolveReadinessShellAsync( + Session session, + CancellationToken cancellationToken) + { + if (_paneReadiness == PaneReadiness.Never) + { + return null; + } + + string defaultCommand = await ReadOptionAsync( + session.Options, + "default-command", + true, + cancellationToken) + .ConfigureAwait(false); + if (defaultCommand.Length > 0) + { + return null; + } + + string defaultShell = await ReadOptionAsync( + session.Options, + "default-shell", + false, + cancellationToken) + .ConfigureAwait(false); + return PaneReadinessWaiter.SelectShell(_paneReadiness, defaultCommand, defaultShell); + } + + private static async Task ReadOptionAsync( + TmuxOptions options, + string name, + bool allowEmpty, + CancellationToken cancellationToken) + { + IReadOnlyList reported = await options.GetAsync( + new GetOptionRequest(name, includeInherited: true), + cancellationToken) + .ConfigureAwait(false); + if (reported.Count != 1) + { + throw new InvalidDataException( + $"tmux did not report exactly one value for '{name}'."); + } + + return reported[0].Value.Raw + ?? (allowEmpty + ? "" + : throw new InvalidDataException( + $"tmux reported no value for '{name}'.")); + } + private static async Task SelectFocusedAsync( WorkspaceFile workspace, List windows, @@ -136,6 +253,7 @@ private async Task FillAsync( WorkspaceWindow described, WorkspaceFile workspace, List unsupported, + string? expectedShellCommand, CancellationToken cancellationToken) { string? directory = described.StartDirectory ?? workspace.StartDirectory; @@ -156,9 +274,14 @@ private async Task FillAsync( cancellationToken) .ConfigureAwait(false); - if (pane.ShellCommands.Count > 0) + if (expectedShellCommand is not null && pane.ShellCommands.Count > 0) { - await WaitForShellAsync(target, cancellationToken).ConfigureAwait(false); + await PaneReadinessWaiter.WaitAsync( + target, + expectedShellCommand, + _readinessTimeout, + cancellationToken) + .ConfigureAwait(false); } foreach (string command in pane.ShellCommands) @@ -213,43 +336,4 @@ await made[index].SelectAsync(cancellationToken: cancellationToken) return await window.RefreshAsync(cancellationToken).ConfigureAwait(false); } - - private async Task WaitForShellAsync( - Pane pane, - CancellationToken cancellationToken) - { - string channel = $"libtmux-workspace-ready-{Guid.NewGuid():N}"; - string binary = ShellQuote(pane.Server.ConnectionOptions.TmuxBinaryPath); - string signal = $"{binary} wait-for -S {channel}"; - using CancellationTokenSource timeout = CancellationTokenSource.CreateLinkedTokenSource( - cancellationToken); - timeout.CancelAfter(_shellReadyTimeout); - - try - { - await pane.SendKeysAsync( - new SendKeysRequest( - text: signal, - suppressHistory: true, - literal: true), - timeout.Token) - .ConfigureAwait(false); - await pane.Server.WaitForAsync( - new WaitForRequest(channel, TmuxWaitMode.Wait), - timeout.Token) - .ConfigureAwait(false); - } - catch (OperationCanceledException failure) when ( - timeout.IsCancellationRequested && !cancellationToken.IsCancellationRequested) - { - throw new TmuxWaitTimeoutException( - $"Pane {pane.Id} did not accept shell input within " - + $"{_shellReadyTimeout.TotalSeconds:0.###} seconds.", - _shellReadyTimeout, - failure); - } - } - - private static string ShellQuote(string value) => - $"'{value.Replace("'", "'\"'\"'", StringComparison.Ordinal)}'"; } diff --git a/tests/LibTmux.IntegrationTests/Workspace/WorkspaceBuilderTests.cs b/tests/LibTmux.IntegrationTests/Workspace/WorkspaceBuilderTests.cs index 72102e3..11945c9 100644 --- a/tests/LibTmux.IntegrationTests/Workspace/WorkspaceBuilderTests.cs +++ b/tests/LibTmux.IntegrationTests/Workspace/WorkspaceBuilderTests.cs @@ -33,7 +33,7 @@ public sealed class WorkspaceBuilderTests """; [Fact] - public void Shell_ready_timeout_must_be_positive() + public void Readiness_timeout_must_be_positive() { Server server = Server.Open(); @@ -41,6 +41,27 @@ public void Shell_ready_timeout_must_be_positive() () => new WorkspaceBuilder(server, TimeSpan.Zero)); Assert.Throws( () => new WorkspaceBuilder(server, TimeSpan.FromTicks(-1))); + Assert.Throws( + () => new WorkspaceBuilder( + server, + paneReadiness: (PaneReadiness)int.MaxValue)); + } + + [Theory] + [InlineData(PaneReadiness.Auto, "", "/bin/zsh", "zsh")] + [InlineData(PaneReadiness.Auto, "", "/bin/bash", null)] + [InlineData(PaneReadiness.Always, "", "/bin/bash", "bash")] + [InlineData(PaneReadiness.Never, "", "/bin/zsh", null)] + [InlineData(PaneReadiness.Always, "top", "/bin/zsh", null)] + public void Readiness_policy_selects_default_shell_panes( + PaneReadiness policy, + string defaultCommand, + string defaultShell, + string? expected) + { + Assert.Equal( + expected, + PaneReadinessWaiter.SelectShell(policy, defaultCommand, defaultShell)); } [UnixFact] @@ -59,6 +80,8 @@ public async Task A_workspace_file_becomes_a_session() Assert.Equal("libtmux-workspace", result.Session.Name); Assert.Equal(2, result.Windows.Count); Assert.Equal(["editor", "shell"], result.Windows.Select(window => window.Name).ToArray()); + Assert.Equal([1, 2], result.Windows.Select(window => window.Index).ToArray()); + Assert.Equal(result.Windows[0].Id, result.Session.ActiveWindow.Id); // The window options in the file are the ones tmux holds afterwards. Assert.Equal( @@ -155,28 +178,18 @@ await pane.CaptureAsync(cancellationToken: cancellation)) } [UnixFact] - public async Task A_shell_that_consumes_the_probe_times_out_before_user_commands() + public async Task Readiness_timeout_writes_nothing_to_the_pane() { CancellationToken token = TestContext.Current.CancellationToken; string directory = Directory.CreateTempSubdirectory("libtmux-workspace-timeout").FullName; - string received = Path.Combine(directory, "received"); try { - string shell = Path.Combine(directory, "sh"); - await File.WriteAllTextAsync( - shell, - $$""" - #!/bin/sh - set -eu - IFS= read -r first - printf '%s\n' "$first" > {{ShellQuote(received)}} - exec /bin/sh - """, + (string shell, string received) = await WriteReceiverAsync( + directory, + "sh", + writeStartup: false, token); - File.SetUnixFileMode( - shell, - UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); TmuxTestFactory factory = new(); await using TemporaryServerScope scope = await factory.CreateServerAsync( HarnessOptions(shell), @@ -189,13 +202,14 @@ await File.WriteAllTextAsync( """); TmuxWaitTimeoutException failure = await Assert.ThrowsAsync( - () => new WorkspaceBuilder(scope.Server, TimeSpan.FromSeconds(1)) + () => new WorkspaceBuilder( + scope.Server, + TimeSpan.FromMilliseconds(250), + PaneReadiness.Always) .BuildAsync(workspace, token)); - Assert.Equal(TimeSpan.FromSeconds(1), failure.Timeout); - string firstInput = await File.ReadAllTextAsync(received, token); - Assert.Contains("wait-for -S libtmux-workspace-ready-", firstInput, StringComparison.Ordinal); - Assert.DoesNotContain("WORKSPACE_USER_COMMAND", firstInput, StringComparison.Ordinal); + Assert.Equal(TimeSpan.FromMilliseconds(250), failure.Timeout); + Assert.False(File.Exists(received)); Server server = await scope.Server.ConnectAsync(token); Session session = Assert.Single(await server.GetSessionsAsync(token)); Window window = Assert.Single(await session.GetWindowsAsync(token)); @@ -211,6 +225,107 @@ await File.WriteAllTextAsync( } } + [UnixFact] + public async Task Startup_output_can_look_ready_before_a_prompt_exists() + { + CancellationToken token = TestContext.Current.CancellationToken; + string directory = Directory.CreateTempSubdirectory("libtmux-workspace-heuristic").FullName; + + try + { + (string shell, string received) = await WriteReceiverAsync( + directory, + "sh", + writeStartup: true, + token); + TmuxTestFactory factory = new(); + await using TemporaryServerScope scope = await factory.CreateServerAsync( + HarnessOptions(shell), + token); + WorkspaceFile workspace = WorkspaceFile.Parse(""" + session_name: libtmux-shell-false-positive + windows: + - panes: + - shell_command: WORKSPACE_USER_COMMAND + """); + + _ = await new WorkspaceBuilder( + scope.Server, + TimeSpan.FromSeconds(2), + PaneReadiness.Always) + .BuildAsync(workspace, token); + + string firstInput = await TmuxWait.UntilAsync( + async cancellation => File.Exists(received) + ? await File.ReadAllTextAsync(received, cancellation) + : "", + input => input.Length > 0, + TimeSpan.FromSeconds(2), + TimeSpan.FromMilliseconds(20), + token); + Assert.Equal("WORKSPACE_USER_COMMAND\n", firstInput); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + [UnixFact] + public async Task Session_options_launch_the_real_first_pane() + { + CancellationToken token = TestContext.Current.CancellationToken; + string directory = Directory.CreateTempSubdirectory("libtmux-workspace-first-pane").FullName; + + try + { + (string command, string received) = await WriteReceiverAsync( + directory, + "receiver", + writeStartup: false, + token); + TmuxTestFactory factory = new(); + await using TemporaryServerScope scope = await factory.CreateServerAsync( + HarnessOptions(), + token); + WorkspaceFile workspace = new( + sessionName: "libtmux-first-pane-options", + options: new Dictionary + { + ["base-index"] = "3", + ["default-command"] = ShellQuote(command), + }, + windows: + [ + new WorkspaceWindow( + windowName: "configured", + panes: [new WorkspacePane(["WORKSPACE_USER_COMMAND"])]) + ]); + + WorkspaceResult result = await new WorkspaceBuilder( + scope.Server, + paneReadiness: PaneReadiness.Always) + .BuildAsync(workspace, token); + + string firstInput = await TmuxWait.UntilAsync( + async cancellation => File.Exists(received) + ? await File.ReadAllTextAsync(received, cancellation) + : "", + input => input.Length > 0, + TimeSpan.FromSeconds(2), + TimeSpan.FromMilliseconds(20), + token); + Window window = Assert.Single(result.Windows); + Assert.Equal(3, window.Index); + Assert.Equal("configured", window.Name); + Assert.Equal("WORKSPACE_USER_COMMAND\n", firstInput); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + [UnixFact] public async Task First_pane_directory_controls_window_creation() { @@ -303,6 +418,26 @@ private static TmuxTestOptions HarnessOptions(string? shell = null) => ? null : new Dictionary { ["SHELL"] = shell })); + private static async Task<(string Program, string Received)> WriteReceiverAsync( + string directory, + string name, + bool writeStartup, + CancellationToken cancellationToken) + { + string program = Path.Combine(directory, name); + string received = Path.Combine(directory, "received"); + string startup = writeStartup ? "printf 'startup output\\n'\n" : ""; + await File.WriteAllTextAsync( + program, + $"#!/bin/sh\nset -eu\n{startup}IFS= read -r first\n" + + $"printf '%s\\n' \"$first\" > {ShellQuote(received)}\nexec /bin/sh\n", + cancellationToken); + File.SetUnixFileMode( + program, + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + return (program, received); + } + private static string ShellQuote(string value) => $"'{value.Replace("'", "'\"'\"'", StringComparison.Ordinal)}'"; } From abceb15a80e2b00a437958b5e0332c59ed89a272 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 15:16:09 -0500 Subject: [PATCH 067/129] Compatibility(feat[tmux]): Prove tmux 3.7c why: tmux 3.7c is the current stable patch release, while the package, CI, and build tooling stopped at 3.7b. what: - extend the required matrix, runtime metadata, API contract, and current documentation through 3.7c - accept new eight-release evidence without invalidating complete 3.7b historical bundles - add a validated tmux build-worker limit and verify 3.7c on both target frameworks --- .github/CONTRIBUTING.md | 6 +-- .github/ISSUE_TEMPLATE/bug_report.yml | 2 +- .github/workflows/dotnet-tmux.yml | 2 +- README.md | 8 +-- docs/README.md | 2 +- docs/parity/parity-ledger.json | 2 +- docs/public-api.json | 12 +++-- docs/public-api.md | 13 ++--- eng/evidence/tests/test_transactions.py | 21 ++++++++ eng/evidence/tests/test_validate.py | 53 ++++++++++++++++--- eng/evidence/validate.py | 41 +++++++++++--- eng/parity/reconcile_versions.py | 49 +++++++++++++---- eng/parity/render_public_api.py | 2 +- eng/parity/tests/test_public_api.py | 15 +++--- eng/parity/tests/test_reconcile_versions.py | 20 ++++--- eng/parity/verify_public_api.py | 12 +++-- eng/parity/verify_workflows.py | 11 +++- eng/tmux/build-version.sh | 18 +++++-- eng/tmux/run-matrix.sh | 4 +- src/LibTmux.Mcp/README.md | 2 +- src/LibTmux/Constants/TmuxConstants.cs | 2 +- src/LibTmux/README.md | 6 +-- .../Parity/Component03ParityTests.cs | 2 +- .../Packaging/WorkflowContractTests.cs | 2 +- .../Versioning/TmuxCapabilitiesTests.cs | 6 ++- 25 files changed, 233 insertions(+), 80 deletions(-) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index cdbdf7e..4b07255 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -303,7 +303,7 @@ either of those changes what a user sees on their own screen. being right about tmux, and only tmux can say whether it is. **A version-dependent behaviour needs a row in the ledger.** Anything that -differs between 3.2a and 3.7b goes through the capability model, and each +differs between 3.2a and 3.7c goes through the capability model, and each difference names the test that proves it in [`docs/parity/version-deltas.json`](../docs/parity/version-deltas.json). @@ -480,13 +480,13 @@ $ uv run python eng/parity/reconcile_versions.py \ Commit the bundle and the rewritten `version-deltas.json` together, because the fingerprint is of the tree that commit produces. A tmux build takes about forty -seconds here and the matrix runs the suite fourteen times, so budget half an +seconds here and the matrix runs the suite sixteen times, so budget half an hour. ## Compatibility Stable tmux **3.2a and newer**, on **net8.0** and **net10.0**. The required -Linux matrix covers 3.2a through 3.7b; the advisory macOS lane uses the current +Linux matrix covers 3.2a through 3.7c; the advisory macOS lane uses the current Homebrew tmux. Windows is unsupported. The `LibTmux` core package is trim- and ahead-of-time-analyzer gated and has a Linux NativeAOT execution smoke test. Optional packages make narrower compatibility claims in their project files diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 3bdf9f2..b00fbe7 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -20,7 +20,7 @@ body: attributes: label: tmux version description: The output of `tmux -V`. - placeholder: tmux 3.7b + placeholder: tmux 3.7c validations: required: true - type: dropdown diff --git a/.github/workflows/dotnet-tmux.yml b/.github/workflows/dotnet-tmux.yml index efa3c50..93a5005 100644 --- a/.github/workflows/dotnet-tmux.yml +++ b/.github/workflows/dotnet-tmux.yml @@ -34,7 +34,7 @@ jobs: # readable when the other lanes still run. fail-fast: false matrix: - tmux: ['3.2a', '3.3a', '3.4', '3.5', '3.6', '3.7a', '3.7b'] + tmux: ['3.2a', '3.3a', '3.4', '3.5', '3.6', '3.7a', '3.7b', '3.7c'] framework: ['net8.0', 'net10.0'] steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/README.md b/README.md index b3bf657..787e955 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![LibTmux](https://img.shields.io/nuget/vpre/LibTmux?logo=nuget&label=LibTmux)](https://www.nuget.org/packages/LibTmux) [![downloads](https://img.shields.io/nuget/dt/LibTmux?logo=nuget&label=downloads)](https://www.nuget.org/packages/LibTmux) [![build](https://github.com/libtmux/libtmux-dotnet/actions/workflows/dotnet.yml/badge.svg)](https://github.com/libtmux/libtmux-dotnet/actions/workflows/dotnet.yml) -[![tmux 3.2a – 3.7b](https://github.com/libtmux/libtmux-dotnet/actions/workflows/dotnet-tmux.yml/badge.svg)](https://github.com/libtmux/libtmux-dotnet/actions/workflows/dotnet-tmux.yml) +[![tmux 3.2a – 3.7c](https://github.com/libtmux/libtmux-dotnet/actions/workflows/dotnet-tmux.yml/badge.svg)](https://github.com/libtmux/libtmux-dotnet/actions/workflows/dotnet-tmux.yml) [![license](https://img.shields.io/badge/license-MIT-blue)](LICENSE) Drive [tmux](https://github.com/tmux/tmux) from .NET. Servers, sessions, @@ -228,9 +228,9 @@ TmuxVersion? version = server.Version; Console.WriteLine($"tmux {version?.Raw} 3.4-or-newer={version?.IsAtLeast(TmuxVersion.Parse("3.4"))}"); ``` -Every measured difference between 3.2a and 3.7b is [recorded with the test that proves +Every measured difference between 3.2a and 3.7c is [recorded with the test that proves it](docs/parity/version-deltas.json), and [dotnet-tmux.yml](.github/workflows/dotnet-tmux.yml) -builds all seven from source on every commit. +builds all eight from source on every commit. ## Testing your own code @@ -297,7 +297,7 @@ never reaches the model's list. | | | |---|---| -| tmux | Stable 3.2a and newer. CI builds 3.2a, 3.3a, 3.4, 3.5, 3.6, 3.7a, and 3.7b; development, release-candidate, and `next-*` versions have unknown capability state | +| tmux | Stable 3.2a and newer. CI builds 3.2a, 3.3a, 3.4, 3.5, 3.6, 3.7a, 3.7b, and 3.7c; development, release-candidate, and `next-*` versions have unknown capability state | | .NET | net8.0, net10.0 | | OS | Linux, macOS. The bounded [`Psmux*` native-Windows and WSL query preview](docs/psmux.md) is experimental; its release gate runs both paths on net8.0 and net10.0 | | Trimming / NativeAOT | `LibTmux` core is analyzer-gated and its smoke app is published and run for `linux-x64` on net8.0 and net10.0. `Compile` and `Matching` resolve properties by name, so they warn trimmed callers to preserve the filtered types' public properties. The proof does not cover the other packages, macOS, or native Windows/psmux | diff --git a/docs/README.md b/docs/README.md index af9cc30..d6f3e75 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,7 +1,7 @@ # LibTmux > **Alpha.** Stable tmux 3.2a and newer are supported; the required matrix -> builds 3.2a through 3.7b. The API shape is not settled. The +> builds 3.2a through 3.7c. The API shape is not settled. The > [psmux preview](psmux.md) is experimental and query-only. A .NET class library for tmux. The three ordinary execution modes reach a real diff --git a/docs/parity/parity-ledger.json b/docs/parity/parity-ledger.json index 0e5220f..04109e6 100644 --- a/docs/parity/parity-ledger.json +++ b/docs/parity/parity-ledger.json @@ -729,7 +729,7 @@ "tmuxVersions": "3.2a-3.7b" }, { - "behavior": "Semantic adaptation: map Python TMUX_MAX_VERSION 3.7 to MaximumTestedTmuxVersion 3.7b, the highest required tested version", + "behavior": "Semantic adaptation: map Python TMUX_MAX_VERSION 3.7 to MaximumTestedTmuxVersion 3.7c, the highest required tested version", "component": "common", "componentId": 3, "csharpDestination": "P:LibTmux.LibTmuxInfo.MaximumTestedTmuxVersion", diff --git a/docs/public-api.json b/docs/public-api.json index 8cc9f99..d9a185f 100644 --- a/docs/public-api.json +++ b/docs/public-api.json @@ -31,7 +31,8 @@ "3.5", "3.6", "3.7a", - "3.7b" + "3.7b", + "3.7c" ], "advisory": "master", "advisoryStatus": "unknown" @@ -2678,6 +2679,7 @@ "3.7": null, "3.3.7": "7", "3.7b": "b", + "3.7c": "c", "3.0-rc3": "rc3", "3.3a-openbsd": "a-openbsd", "next-3.8": "next" @@ -2717,9 +2719,9 @@ "exactIdentity": "CompareTo returns zero if and only if equality is true", "examples": [ "next-3.7 < 3.7-dev < 3.7-dev.0 < 3.7-rc1 < 3.7-rc2", - "3.7-rc2 < 3.7 < 3.7-openbsd < 3.7a < 3.7a-openbsd < 3.7b", + "3.7-rc2 < 3.7 < 3.7-openbsd < 3.7a < 3.7a-openbsd < 3.7b < 3.7c", "3.3 < 3.3.1 < 3.3.10 < 3.3a", - "3.7b < next-3.8 < 3.8" + "3.7c < next-3.8 < 3.8" ], "invalidOperands": "CompareTo, <, <=, >, >=, IsAtLeast, and EnsureAtLeast throw InvalidOperationException if either operand is invalid", "ensureAtLeastFailure": "a valid value below a valid minimum throws TmuxVersionTooLowException" @@ -2748,10 +2750,10 @@ "support": { "minimum": "3.2a", "minimumInclusive": true, - "maximumTested": "3.7b", + "maximumTested": "3.7c", "maximumTestedSemantics": "informational; not a support ceiling", "minimumChecks": "enforce only the minimum; newer untested versions may satisfy them", - "exactVersionIdentity": "3.7, 3.7a, and 3.7b are distinct", + "exactVersionIdentity": "3.7, 3.7a, 3.7b, and 3.7c are distinct", "capabilitySelection": "named support intervals apply to every stable release at or above the minimum; capabilities without a recorded end remain supported on later stable releases", "unknownCapabilityVersion": "invalid, below-minimum, development, release-candidate, and next versions have unknown capability state" } diff --git a/docs/public-api.md b/docs/public-api.md index 7e9e6f4..0dd1d2b 100644 --- a/docs/public-api.md +++ b/docs/public-api.md @@ -5,7 +5,7 @@ The API targets `net8.0` and `net10.0`. Stable tmux releases from `3.2a` onward are supported. The required compatibility matrix covers -3.2a, 3.3a, 3.4, 3.5, 3.6, 3.7a, 3.7b; tmux master is advisory and `unknown`. +3.2a, 3.3a, 3.4, 3.5, 3.6, 3.7a, 3.7b, 3.7c; tmux master is advisory and `unknown`. Native Windows tmux execution is unsupported. IDs, snapshots, local query evaluation, JSON, and pure test helpers remain portable. @@ -16,7 +16,7 @@ zero-argument methods. ## TmuxVersion semantic contract -Minimum support is `3.2a` inclusive; `3.7b` is informational, not a support ceiling. +Minimum support is `3.2a` inclusive; `3.7c` is informational, not a support ceiling. Stable releases use named capability intervals. The detection line starts with the exact lowercase prefix `tmux `. The complete parsing, ordering, detection, and support contract follows. @@ -41,6 +41,7 @@ The complete parsing, ordering, detection, and support contract follows. "3.7": null, "3.3.7": "7", "3.7b": "b", + "3.7c": "c", "3.0-rc3": "rc3", "3.3a-openbsd": "a-openbsd", "next-3.8": "next" @@ -80,9 +81,9 @@ The complete parsing, ordering, detection, and support contract follows. "exactIdentity": "CompareTo returns zero if and only if equality is true", "examples": [ "next-3.7 < 3.7-dev < 3.7-dev.0 < 3.7-rc1 < 3.7-rc2", - "3.7-rc2 < 3.7 < 3.7-openbsd < 3.7a < 3.7a-openbsd < 3.7b", + "3.7-rc2 < 3.7 < 3.7-openbsd < 3.7a < 3.7a-openbsd < 3.7b < 3.7c", "3.3 < 3.3.1 < 3.3.10 < 3.3a", - "3.7b < next-3.8 < 3.8" + "3.7c < next-3.8 < 3.8" ], "invalidOperands": "CompareTo, <, <=, >, >=, IsAtLeast, and EnsureAtLeast throw InvalidOperationException if either operand is invalid", "ensureAtLeastFailure": "a valid value below a valid minimum throws TmuxVersionTooLowException" @@ -111,10 +112,10 @@ The complete parsing, ordering, detection, and support contract follows. "support": { "minimum": "3.2a", "minimumInclusive": true, - "maximumTested": "3.7b", + "maximumTested": "3.7c", "maximumTestedSemantics": "informational; not a support ceiling", "minimumChecks": "enforce only the minimum; newer untested versions may satisfy them", - "exactVersionIdentity": "3.7, 3.7a, and 3.7b are distinct", + "exactVersionIdentity": "3.7, 3.7a, 3.7b, and 3.7c are distinct", "capabilitySelection": "named support intervals apply to every stable release at or above the minimum; capabilities without a recorded end remain supported on later stable releases", "unknownCapabilityVersion": "invalid, below-minimum, development, release-candidate, and next versions have unknown capability state" } diff --git a/eng/evidence/tests/test_transactions.py b/eng/evidence/tests/test_transactions.py index 6e3cfc4..9832632 100644 --- a/eng/evidence/tests/test_transactions.py +++ b/eng/evidence/tests/test_transactions.py @@ -1084,6 +1084,27 @@ def test_build_version_accepts_only_complete_valid_cache( assert f"commit={commit}" in result.stdout +def test_build_version_rejects_invalid_worker_limit(tmp_path: pathlib.Path) -> None: + """Reject a worker limit that cannot bound make concurrency.""" + artifacts, _binary, _commit = _cached_tmux_fixture(tmp_path) + script = pathlib.Path(__file__).parents[2] / "tmux" / "build-version.sh" + + result = subprocess.run( + [str(script), "3.2a"], + check=False, + capture_output=True, + text=True, + env={ + **os.environ, + "LIBTMUX_BUILD_JOBS": "0", + "LIBTMUX_TMUX_ARTIFACT_DIRECTORY": str(artifacts), + }, + ) + + assert result.returncode == 2 + assert "LIBTMUX_BUILD_JOBS must be a positive integer" in result.stderr + + @pytest.mark.parametrize("digest_tool", ["sha256sum", "shasum"]) def test_build_version_selects_portable_digest_tool( tmp_path: pathlib.Path, diff --git a/eng/evidence/tests/test_validate.py b/eng/evidence/tests/test_validate.py index 905f4b4..481db95 100644 --- a/eng/evidence/tests/test_validate.py +++ b/eng/evidence/tests/test_validate.py @@ -33,8 +33,18 @@ "tokens", "usernames", ] -REQUIRED_TMUX_VERSIONS = ["3.2a", "3.3a", "3.4", "3.5", "3.6", "3.7a", "3.7b"] +REQUIRED_TMUX_VERSIONS = [ + "3.2a", + "3.3a", + "3.4", + "3.5", + "3.6", + "3.7a", + "3.7b", + "3.7c", +] REQUIRED_FRAMEWORKS = ["net10.0", "net8.0"] +REQUIRED_MATRIX_ROWS = len(REQUIRED_TMUX_VERSIONS) * len(REQUIRED_FRAMEWORKS) TRANSITION_TMUX_SOURCE_COMMIT = "7" * 40 EVALUATED_COMMIT_TREE = "e" * 40 CAPABILITY_COHORT = "0001" @@ -200,7 +210,7 @@ def test_matrix_runner_skips_transition_outside_component_three_cohort( observations = [ line.split("|", maxsplit=3) for line in log.read_text().splitlines() ] - assert len(observations) == 14 + assert len(observations) == REQUIRED_MATRIX_ROWS assert {row[1] for row in observations} == {""} assert {row[2] for row in observations} == {""} assert not transition_install.exists() @@ -232,7 +242,7 @@ def test_matrix_runner_runs_exact_source_bound_tmux_3_7_transition( ] ordinary = [row for row in observations if "--filter-method" not in row[3]] transition = [row for row in observations if "--filter-method" in row[3]] - assert len(ordinary) == 14 + assert len(ordinary) == REQUIRED_MATRIX_ROWS assert {row[0] for row in ordinary} == set(REQUIRED_TMUX_VERSIONS) assert len(transition) == 4 assert {row[0] for row in transition} == {"3.7", "3.7a"} @@ -249,7 +259,10 @@ def test_matrix_runner_runs_exact_source_bound_tmux_3_7_transition( lines = transcript.read_text(encoding="utf-8").splitlines() assert len(lines) == 4 assert all(validate.BREAK_PANE_TRANSCRIPT_PATTERN.fullmatch(line) for line in lines) - assert len(list((evidence / "results.ndjson").read_text().splitlines())) == 14 + assert ( + len(list((evidence / "results.ndjson").read_text().splitlines())) + == REQUIRED_MATRIX_ROWS + ) environment_observation = json.loads( (evidence / "environment.json").read_text(encoding="utf-8") ) @@ -282,7 +295,7 @@ def test_matrix_runner_does_not_infer_cohort_from_evidence_basename( observations = [ line.split("|", maxsplit=3) for line in log.read_text().splitlines() ] - assert len(observations) == 14 + assert len(observations) == REQUIRED_MATRIX_ROWS assert all("--filter-method" not in row[3] for row in observations) recorded_environment = json.loads( (evidence / "environment.json").read_text(encoding="utf-8") @@ -315,7 +328,7 @@ def test_matrix_runner_records_wrapper_policy_closure_without_transition( observations = [ line.split("|", maxsplit=3) for line in log.read_text().splitlines() ] - assert len(observations) == 14 + assert len(observations) == REQUIRED_MATRIX_ROWS assert all("--filter-method" not in row[3] for row in observations) recorded_environment = json.loads( (evidence / "environment.json").read_text(encoding="utf-8") @@ -326,7 +339,10 @@ def test_matrix_runner_records_wrapper_policy_closure_without_transition( assert not ( evidence / "protocol-transcripts" / "break-pane-transition.txt" ).exists() - assert len((evidence / "results.ndjson").read_text().splitlines()) == 14 + assert ( + len((evidence / "results.ndjson").read_text().splitlines()) + == REQUIRED_MATRIX_ROWS + ) source_commands = (tmp_path / "source-identity.txt").read_text().splitlines() assert len(source_commands) == 2 assert all(f"--exclude-root {evidence}" in command for command in source_commands) @@ -497,6 +513,28 @@ def test_matrix_phase_accepts_exact_break_pane_transition_proof( validate.validate_bundle(bundle, phase="matrix") +def test_matrix_phase_retains_previous_complete_release_set( + tmp_path: pathlib.Path, +) -> None: + """Keep recorded 3.7b evidence valid after the required matrix widens.""" + bundle = _matrix_bundle(tmp_path) + environment_path = bundle / "environment.json" + environment = json.loads(environment_path.read_text(encoding="utf-8")) + environment["tmuxVersions"] = REQUIRED_TMUX_VERSIONS[:-1] + environment_path.write_text( + json.dumps(environment, sort_keys=True) + "\n", + encoding="utf-8", + ) + rows = [ + row + for row in _matrix_rows(COMMIT) + if row["tmuxVersion"] != REQUIRED_TMUX_VERSIONS[-1] + ] + _write_rows(bundle, rows) + + validate.validate_bundle(bundle, phase="matrix") + + @pytest.mark.parametrize( "mutation", ["missing-marker", "missing-transition", "unknown-marker"], @@ -813,6 +851,7 @@ def test_matrix_phase_requires_all_release_framework_rows( [ ("frameworks", ["net8.0", "net10.0"]), ("tmuxVersions", ["3.7b"]), + ("tmuxVersions", [{}]), ("schemaVersion", 2), ("sourceState", "maybe"), ("sourceTreeFingerprint", "short"), diff --git a/eng/evidence/validate.py b/eng/evidence/validate.py index 6f68e6d..03d2af4 100644 --- a/eng/evidence/validate.py +++ b/eng/evidence/validate.py @@ -17,7 +17,21 @@ import sys import typing as t -REQUIRED_TMUX_VERSIONS = ("3.2a", "3.3a", "3.4", "3.5", "3.6", "3.7a", "3.7b") +REQUIRED_TMUX_VERSIONS = ( + "3.2a", + "3.3a", + "3.4", + "3.5", + "3.6", + "3.7a", + "3.7b", + "3.7c", +) +LEGACY_REQUIRED_TMUX_VERSIONS = REQUIRED_TMUX_VERSIONS[:-1] +KNOWN_REQUIRED_TMUX_VERSION_SETS = { + LEGACY_REQUIRED_TMUX_VERSIONS, + REQUIRED_TMUX_VERSIONS, +} REQUIRED_FRAMEWORKS = ("net10.0", "net8.0") REDACTION_CATEGORIES = ( "absolute-paths", @@ -400,7 +414,7 @@ def _validate_decision_transcripts(root: pathlib.Path) -> None: def _validate_environment( environment: dict[str, t.Any], -) -> tuple[str, bool, str | None, dict[str, str] | None]: +) -> tuple[str, bool, str | None, dict[str, str] | None, tuple[str, ...]]: if set(environment) not in { frozenset(LEGACY_ENVIRONMENT_KEYS), frozenset(MARKED_COHORT_ENVIRONMENT_KEYS), @@ -413,6 +427,13 @@ def _validate_environment( transition_commits = environment.get("transitionTmuxSourceCommits") capability_cohort = environment.get("capabilityCohort") evaluated_tree = environment.get("evaluatedCommitTree") + tmux_versions = environment.get("tmuxVersions") + required_versions = ( + tuple(tmux_versions) + if isinstance(tmux_versions, list) + and all(isinstance(version, str) for version in tmux_versions) + else () + ) if capability_cohort is not None and ( not isinstance(evaluated_tree, str) or COMMIT_PATTERN.fullmatch(evaluated_tree) is None @@ -422,7 +443,7 @@ def _validate_environment( environment["schemaVersion"] != 1 or environment["frameworks"] != list(REQUIRED_FRAMEWORKS) or not isinstance(environment["includeMasterAdvisory"], bool) - or environment["tmuxVersions"] != list(REQUIRED_TMUX_VERSIONS) + or required_versions not in KNOWN_REQUIRED_TMUX_VERSION_SETS or environment["platform"] not in {"linux", "macos"} or environment["redactionProof"] is not True or environment["sdkVersion"] != "10.0.302" @@ -459,6 +480,7 @@ def _validate_environment( "dict[str, str] | None", transition_commits, ), + required_versions, ) @@ -467,6 +489,7 @@ def _validate_matrix_rows( commit: str, include_master_advisory: bool, capability_cohort: str | None, + required_versions: tuple[str, ...], ) -> dict[str, str]: observed: dict[tuple[str, str], dict[str, t.Any]] = {} for row in rows: @@ -477,7 +500,7 @@ def _validate_matrix_rows( if not isinstance(version, str) or not isinstance(framework, str): raise EvidenceValidationError("matrix row identity is invalid") if ( - version not in {*REQUIRED_TMUX_VERSIONS, "master"} + version not in {*required_versions, "master"} or framework not in REQUIRED_FRAMEWORKS ): raise EvidenceValidationError("matrix contains an unknown row") @@ -491,7 +514,7 @@ def _validate_matrix_rows( if not isinstance(count, int) or isinstance(count, bool): raise EvidenceValidationError("matrix row observation is invalid") source_commit = row["tmuxSourceCommit"] - if version in REQUIRED_TMUX_VERSIONS: + if version in required_versions: if ( row["advisory"] is not False or row["status"] != "passed" @@ -517,7 +540,7 @@ def _validate_matrix_rows( raise EvidenceValidationError("master matrix row observation is invalid") required = { (version, framework) - for version in REQUIRED_TMUX_VERSIONS + for version in required_versions for framework in REQUIRED_FRAMEWORKS } if not required.issubset(observed): @@ -527,10 +550,10 @@ def _validate_matrix_rows( and set(observed) != required ): raise EvidenceValidationError( - "capability cohort matrix must contain exactly fourteen required rows" + "capability cohort matrix must contain exactly the required rows" ) source_commits: dict[str, str] = {} - for version in REQUIRED_TMUX_VERSIONS: + for version in required_versions: commits = { observed[(version, framework)]["tmuxSourceCommit"] for framework in REQUIRED_FRAMEWORKS @@ -634,6 +657,7 @@ def validate_matrix(root: pathlib.Path) -> str: include_master_advisory, capability_cohort, transition_commits, + required_versions, ) = _validate_environment(environment) proof = load_json(root / "redaction-proof.json") if ( @@ -647,6 +671,7 @@ def validate_matrix(root: pathlib.Path) -> str: commit, include_master_advisory, capability_cohort, + required_versions, ) _validate_transcripts(root) transition_path = root / "protocol-transcripts" / BREAK_PANE_TRANSCRIPT diff --git a/eng/parity/reconcile_versions.py b/eng/parity/reconcile_versions.py index eaf77f4..496b787 100644 --- a/eng/parity/reconcile_versions.py +++ b/eng/parity/reconcile_versions.py @@ -222,7 +222,21 @@ "hook_scope_pane_window_show", } REQUIRED_FRAMEWORKS = ("net10.0", "net8.0") -REQUIRED_TMUX_VERSIONS = ("3.2a", "3.3a", "3.4", "3.5", "3.6", "3.7a", "3.7b") +REQUIRED_TMUX_VERSIONS = ( + "3.2a", + "3.3a", + "3.4", + "3.5", + "3.6", + "3.7a", + "3.7b", + "3.7c", +) +LEGACY_REQUIRED_TMUX_VERSIONS = REQUIRED_TMUX_VERSIONS[:-1] +KNOWN_REQUIRED_TMUX_VERSION_SETS = { + LEGACY_REQUIRED_TMUX_VERSIONS, + REQUIRED_TMUX_VERSIONS, +} TMUX_SOURCE_ENDPOINTS = { "3.2a": "https://github.com/tmux/tmux/tree/3.2a", "3.7b": "https://github.com/tmux/tmux/tree/3.7b", @@ -359,6 +373,16 @@ def is_tmux_version_bound(value: object) -> bool: ) +def _known_required_tmux_versions(value: object) -> tuple[str, ...] | None: + """Return an exact current or retained historical release set.""" + versions = ( + tuple(value) + if isinstance(value, list) and all(isinstance(version, str) for version in value) + else () + ) + return versions if versions in KNOWN_REQUIRED_TMUX_VERSION_SETS else None + + def is_real_server_test(value: object) -> bool: """Return whether a named test follows the real-server convention. @@ -515,6 +539,7 @@ def _validate_reconciled_evidence( tests = value["tests"] content_fingerprint = value["sourceContentFingerprint"] fingerprint = value["sourceTreeFingerprint"] + required_versions = _known_required_tmux_versions(value["tmuxVersions"]) base_valid = ( isinstance(commit, str) and COMMIT_PATTERN.fullmatch(commit) is not None @@ -539,14 +564,14 @@ def _validate_reconciled_evidence( for test in tests ) and len(tests) == len(set(tests)) + and required_versions is not None and isinstance(commits, dict) - and set(commits) == set(REQUIRED_TMUX_VERSIONS) + and set(commits) == set(required_versions) and all( isinstance(source_commit, str) and COMMIT_PATTERN.fullmatch(source_commit) is not None for source_commit in commits.values() ) - and value["tmuxVersions"] == list(REQUIRED_TMUX_VERSIONS) ) return base_valid @@ -835,6 +860,7 @@ def _load_environment(path: pathlib.Path) -> dict[str, t.Any]: _fail("matrix environment schema is not exact") commit = environment["evaluatedCommit"] fingerprint = environment["sourceTreeFingerprint"] + required_versions = _known_required_tmux_versions(environment["tmuxVersions"]) if ( not isinstance(commit, str) or COMMIT_PATTERN.fullmatch(commit) is None @@ -848,7 +874,7 @@ def _load_environment(path: pathlib.Path) -> dict[str, t.Any]: or environment["sourceState"] not in {"clean", "uncommitted"} or not isinstance(fingerprint, str) or FINGERPRINT_PATTERN.fullmatch(fingerprint) is None - or environment["tmuxVersions"] != list(REQUIRED_TMUX_VERSIONS) + or required_versions is None ): _fail("matrix environment observations are invalid") if cohort == CAPABILITY_COHORT: @@ -967,6 +993,11 @@ def _inspect_matrix(path: pathlib.Path) -> dict[str, t.Any]: >>> callable(_inspect_matrix) True """ + environment = _load_environment(path.with_name("environment.json")) + required_versions = t.cast( + "tuple[str, ...]", + _known_required_tmux_versions(environment["tmuxVersions"]), + ) observed: dict[tuple[str, str], dict[str, t.Any]] = {} for row in _load_matrix(path): if set(row) != MATRIX_ROW_KEYS: @@ -976,7 +1007,7 @@ def _inspect_matrix(path: pathlib.Path) -> dict[str, t.Any]: if ( not isinstance(version, str) or not isinstance(framework, str) - or version not in {*REQUIRED_TMUX_VERSIONS, "master"} + or version not in {*required_versions, "master"} or framework not in REQUIRED_FRAMEWORKS ): _fail("matrix contains an unknown row") @@ -991,7 +1022,7 @@ def _inspect_matrix(path: pathlib.Path) -> dict[str, t.Any]: _fail("matrix evaluated commit is invalid") count = row["testCount"] source_commit = row["tmuxSourceCommit"] - if version in REQUIRED_TMUX_VERSIONS: + if version in required_versions: if ( row["advisory"] is not False or row["status"] != "passed" @@ -1023,7 +1054,7 @@ def _inspect_matrix(path: pathlib.Path) -> dict[str, t.Any]: observed[pair] = row required = { (version, framework) - for version in REQUIRED_TMUX_VERSIONS + for version in required_versions for framework in REQUIRED_FRAMEWORKS } if not required.issubset(observed): @@ -1034,7 +1065,7 @@ def _inspect_matrix(path: pathlib.Path) -> dict[str, t.Any]: if len(commits) != 1 or len(counts) != 1: _fail("required matrix observations are invalid") source_commits: dict[str, str] = {} - for version in REQUIRED_TMUX_VERSIONS: + for version in required_versions: version_commits = { t.cast(str, observed[(version, framework)]["tmuxSourceCommit"]) for framework in REQUIRED_FRAMEWORKS @@ -1056,7 +1087,7 @@ def _inspect_matrix(path: pathlib.Path) -> dict[str, t.Any]: "frameworks": list(REQUIRED_FRAMEWORKS), "testCount": counts.pop(), "tmuxSourceCommits": source_commits, - "tmuxVersions": list(REQUIRED_TMUX_VERSIONS), + "tmuxVersions": list(required_versions), } diff --git a/eng/parity/render_public_api.py b/eng/parity/render_public_api.py index 9e29521..a6d752c 100644 --- a/eng/parity/render_public_api.py +++ b/eng/parity/render_public_api.py @@ -261,7 +261,7 @@ def render(contract: dict[str, t.Any]) -> str: "## TmuxVersion semantic contract", "", ( - "Minimum support is `3.2a` inclusive; `3.7b` is informational, " + "Minimum support is `3.2a` inclusive; `3.7c` is informational, " "not a support ceiling." ), "Stable releases use named capability intervals.", diff --git a/eng/parity/tests/test_public_api.py b/eng/parity/tests/test_public_api.py index 75b39aa..822a000 100644 --- a/eng/parity/tests/test_public_api.py +++ b/eng/parity/tests/test_public_api.py @@ -30,6 +30,7 @@ "3.7": None, "3.3.7": "7", "3.7b": "b", + "3.7c": "c", "3.0-rc3": "rc3", "3.3a-openbsd": "a-openbsd", "next-3.8": "next", @@ -75,9 +76,9 @@ "exactIdentity": "CompareTo returns zero if and only if equality is true", "examples": [ "next-3.7 < 3.7-dev < 3.7-dev.0 < 3.7-rc1 < 3.7-rc2", - "3.7-rc2 < 3.7 < 3.7-openbsd < 3.7a < 3.7a-openbsd < 3.7b", + "3.7-rc2 < 3.7 < 3.7-openbsd < 3.7a < 3.7a-openbsd < 3.7b < 3.7c", "3.3 < 3.3.1 < 3.3.10 < 3.3a", - "3.7b < next-3.8 < 3.8", + "3.7c < next-3.8 < 3.8", ], "invalidOperands": ( "CompareTo, <, <=, >, >=, IsAtLeast, and EnsureAtLeast throw " @@ -118,12 +119,12 @@ "support": { "minimum": "3.2a", "minimumInclusive": True, - "maximumTested": "3.7b", + "maximumTested": "3.7c", "maximumTestedSemantics": "informational; not a support ceiling", "minimumChecks": ( "enforce only the minimum; newer untested versions may satisfy them" ), - "exactVersionIdentity": "3.7, 3.7a, and 3.7b are distinct", + "exactVersionIdentity": "3.7, 3.7a, 3.7b, and 3.7c are distinct", "capabilitySelection": ( "named support intervals apply to every stable release at or above the " "minimum; capabilities without a recorded end remain supported on later " @@ -137,7 +138,7 @@ } TMUX_MAX_VERSION_ADAPTATION = ( "Semantic adaptation: map Python TMUX_MAX_VERSION 3.7 to " - "MaximumTestedTmuxVersion 3.7b, the highest required tested version" + "MaximumTestedTmuxVersion 3.7c, the highest required tested version" ) C4_FRAMING_VALIDATION = ( "row := value{projection.Fields.Count}, each value terminated by " @@ -1194,7 +1195,7 @@ def test_rendered_api_exposes_declaration_and_invariant_details() -> None: assert "Compiler-generated by the record struct." in markdown assert "## TmuxVersion semantic contract" in markdown assert "`3.2a` inclusive" in markdown - assert "`3.7b` is informational, not a support ceiling" in markdown + assert "`3.7c` is informational, not a support ceiling" in markdown assert "Stable releases use named capability intervals." in markdown assert "the exact lowercase prefix `tmux `" in markdown assert '"nonzeroExit": "TmuxCommandException carrying Result"' in markdown @@ -2112,7 +2113,7 @@ def test_tmux_version_semantics_are_canonical_and_ledger_adaptation_is_explicit( ) required = public_api["supportedTmuxVersions"]["required"] assert support["minimum"] == required[0] == "3.2a" - assert support["maximumTested"] == required[-1] == "3.7b" + assert support["maximumTested"] == required[-1] == "3.7c" max_row = next( row for row in ledger["rows"] diff --git a/eng/parity/tests/test_reconcile_versions.py b/eng/parity/tests/test_reconcile_versions.py index a7e1d9a..5c0fe93 100644 --- a/eng/parity/tests/test_reconcile_versions.py +++ b/eng/parity/tests/test_reconcile_versions.py @@ -20,6 +20,16 @@ EVALUATED_COMMIT_TREE = "e" * 40 CAPABILITY_COHORT = "0001" CLOSURE_COHORT = "closure" +REQUIRED_TMUX_VERSIONS = [ + "3.2a", + "3.3a", + "3.4", + "3.5", + "3.6", + "3.7a", + "3.7b", + "3.7c", +] def load_reconciler() -> dict[str, t.Any]: @@ -57,10 +67,7 @@ def matrix_rows(commit: str = "c" * 40) -> list[dict[str, t.Any]]: "tmuxSourceCommit": str(index) * 40, "tmuxVersion": version, } - for index, version in enumerate( - ["3.2a", "3.3a", "3.4", "3.5", "3.6", "3.7a", "3.7b"], - start=1, - ) + for index, version in enumerate(REQUIRED_TMUX_VERSIONS, start=1) for framework in ["net10.0", "net8.0"] ] @@ -173,7 +180,7 @@ def write_environment( "transitionTmuxSourceCommits": { "3.7": TRANSITION_TMUX_SOURCE_COMMIT, }, - "tmuxVersions": ["3.2a", "3.3a", "3.4", "3.5", "3.6", "3.7a", "3.7b"], + "tmuxVersions": REQUIRED_TMUX_VERSIONS, } results.with_name("environment.json").write_text( json.dumps(environment, indent=2, sort_keys=True) + "\n", @@ -432,11 +439,12 @@ def test_uncommitted_evidence_uses_fingerprinted_worktree_test( [ ({"evaluatedCommit": "f" * 40}, "environment commit differs from matrix"), ({"sourceTreeFingerprint": "f" * 64}, "source fingerprint differs"), + ({"tmuxVersions": [{}]}, "matrix environment observations are invalid"), ], ) def test_reconciliation_rejects_environment_source_drift( tmp_path: pathlib.Path, - environment_change: dict[str, str], + environment_change: dict[str, t.Any], error: str, ) -> None: """Reject matrix metadata that is not bound to its current source tree.""" diff --git a/eng/parity/verify_public_api.py b/eng/parity/verify_public_api.py index 997ee81..e3f10b0 100644 --- a/eng/parity/verify_public_api.py +++ b/eng/parity/verify_public_api.py @@ -74,6 +74,7 @@ "3.7": None, "3.3.7": "7", "3.7b": "b", + "3.7c": "c", "3.0-rc3": "rc3", "3.3a-openbsd": "a-openbsd", "next-3.8": "next", @@ -119,9 +120,9 @@ "exactIdentity": "CompareTo returns zero if and only if equality is true", "examples": [ "next-3.7 < 3.7-dev < 3.7-dev.0 < 3.7-rc1 < 3.7-rc2", - "3.7-rc2 < 3.7 < 3.7-openbsd < 3.7a < 3.7a-openbsd < 3.7b", + "3.7-rc2 < 3.7 < 3.7-openbsd < 3.7a < 3.7a-openbsd < 3.7b < 3.7c", "3.3 < 3.3.1 < 3.3.10 < 3.3a", - "3.7b < next-3.8 < 3.8", + "3.7c < next-3.8 < 3.8", ], "invalidOperands": ( "CompareTo, <, <=, >, >=, IsAtLeast, and EnsureAtLeast throw " @@ -162,12 +163,12 @@ "support": { "minimum": "3.2a", "minimumInclusive": True, - "maximumTested": "3.7b", + "maximumTested": "3.7c", "maximumTestedSemantics": "informational; not a support ceiling", "minimumChecks": ( "enforce only the minimum; newer untested versions may satisfy them" ), - "exactVersionIdentity": "3.7, 3.7a, and 3.7b are distinct", + "exactVersionIdentity": "3.7, 3.7a, 3.7b, and 3.7c are distinct", "capabilitySelection": ( "named support intervals apply to every stable release at or above the " "minimum; capabilities without a recorded end remain supported on later " @@ -181,7 +182,7 @@ } TMUX_MAX_VERSION_ADAPTATION = ( "Semantic adaptation: map Python TMUX_MAX_VERSION 3.7 to " - "MaximumTestedTmuxVersion 3.7b, the highest required tested version" + "MaximumTestedTmuxVersion 3.7c, the highest required tested version" ) C4_FRAMING_VALIDATION = ( "row := value{projection.Fields.Count}, each value terminated by " @@ -448,6 +449,7 @@ def validate_header(contract: dict[str, t.Any], violations: list[str]) -> None: "3.6", "3.7a", "3.7b", + "3.7c", ]: violations.append("invalid required tmux versions") if ( diff --git a/eng/parity/verify_workflows.py b/eng/parity/verify_workflows.py index 490f339..03d4a9c 100644 --- a/eng/parity/verify_workflows.py +++ b/eng/parity/verify_workflows.py @@ -12,7 +12,16 @@ import pathlib import sys -SUPPORTED_TMUX_VERSIONS = ("3.2a", "3.3a", "3.4", "3.5", "3.6", "3.7a", "3.7b") +SUPPORTED_TMUX_VERSIONS = ( + "3.2a", + "3.3a", + "3.4", + "3.5", + "3.6", + "3.7a", + "3.7b", + "3.7c", +) TARGET_FRAMEWORKS = ("net8.0", "net10.0") #: Checks a change has to pass locally. A workflow missing one of these would diff --git a/eng/tmux/build-version.sh b/eng/tmux/build-version.sh index e310e3a..d940ffc 100755 --- a/eng/tmux/build-version.sh +++ b/eng/tmux/build-version.sh @@ -6,6 +6,12 @@ readonly SCRIPT_DIRECTORY="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" readonly CSHARP_DIRECTORY="$(cd -- "${SCRIPT_DIRECTORY}/../.." && pwd)" readonly ARTIFACT_DIRECTORY="${LIBTMUX_TMUX_ARTIFACT_DIRECTORY:-${CSHARP_DIRECTORY}/artifacts/tmux}" +if [[ -n "${LIBTMUX_BUILD_JOBS:-}" \ + && ! "${LIBTMUX_BUILD_JOBS}" =~ ^[1-9][0-9]*$ ]]; then + echo "LIBTMUX_BUILD_JOBS must be a positive integer" >&2 + exit 2 +fi + sha256_file() { local path="$1" if command -v sha256sum >/dev/null 2>&1; then @@ -21,13 +27,13 @@ sha256_file() { } if [[ $# -ne 1 ]]; then - echo "usage: build-version.sh <3.2a|3.3a|3.4|3.5|3.6|3.7|3.7a|3.7b|master>" >&2 + echo "usage: build-version.sh <3.2a|3.3a|3.4|3.5|3.6|3.7|3.7a|3.7b|3.7c|master>" >&2 exit 2 fi readonly VERSION="$1" case "${VERSION}" in - 3.2a|3.3a|3.4|3.5|3.6|3.7|3.7a|3.7b|master) ;; + 3.2a|3.3a|3.4|3.5|3.6|3.7|3.7a|3.7b|3.7c|master) ;; *) echo "unsupported tmux version: ${VERSION}" >&2 exit 2 @@ -107,11 +113,17 @@ if [[ "${VERSION}" != master ]]; then fi fi +readonly BUILD_JOBS="${LIBTMUX_BUILD_JOBS:-$(getconf _NPROCESSORS_ONLN)}" +if [[ ! "${BUILD_JOBS}" =~ ^[1-9][0-9]*$ ]]; then + echo "detected build worker count must be a positive integer" >&2 + exit 1 +fi + ( cd "${SOURCE_DIRECTORY}" sh autogen.sh ./configure --prefix="${INSTALL_DIRECTORY}" - make -j"$(getconf _NPROCESSORS_ONLN)" + make -j"${BUILD_JOBS}" make install ) >&2 diff --git a/eng/tmux/run-matrix.sh b/eng/tmux/run-matrix.sh index 79a63d8..c7ca3d6 100755 --- a/eng/tmux/run-matrix.sh +++ b/eng/tmux/run-matrix.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -readonly REQUIRED_VERSIONS=(3.2a 3.3a 3.4 3.5 3.6 3.7a 3.7b) +readonly REQUIRED_VERSIONS=(3.2a 3.3a 3.4 3.5 3.6 3.7a 3.7b 3.7c) readonly FRAMEWORKS=(net10.0 net8.0) readonly COMPONENT_THREE_COHORT=0001 readonly CLOSURE_COHORT=closure @@ -359,7 +359,7 @@ if [[ -n "${candidate}" ]]; then --argjson capabilityCohortPresent "${capability_cohort_json}" \ --argjson includeMasterAdvisory "${include_master_json}" \ --argjson transitionProof "${transition_proof_json}" \ - '({evaluatedCommit:$evaluatedCommit,frameworks:["net10.0","net8.0"],includeMasterAdvisory:$includeMasterAdvisory,platform:$platform,redactionProof:true,schemaVersion:1,sdkVersion:$sdkVersion,sourceState:$sourceState,sourceTreeFingerprint:$sourceTreeFingerprint,tmuxVersions:["3.2a","3.3a","3.4","3.5","3.6","3.7a","3.7b"]} + (if $capabilityCohortPresent then {capabilityCohort:$capabilityCohort,evaluatedCommitTree:$evaluatedCommitTree} else {} end) + (if $transitionProof then {transitionTmuxSourceCommits:{"3.7":$transitionTmuxSourceCommit}} else {} end))' \ + '({evaluatedCommit:$evaluatedCommit,frameworks:["net10.0","net8.0"],includeMasterAdvisory:$includeMasterAdvisory,platform:$platform,redactionProof:true,schemaVersion:1,sdkVersion:$sdkVersion,sourceState:$sourceState,sourceTreeFingerprint:$sourceTreeFingerprint,tmuxVersions:["3.2a","3.3a","3.4","3.5","3.6","3.7a","3.7b","3.7c"]} + (if $capabilityCohortPresent then {capabilityCohort:$capabilityCohort,evaluatedCommitTree:$evaluatedCommitTree} else {} end) + (if $transitionProof then {transitionTmuxSourceCommits:{"3.7":$transitionTmuxSourceCommit}} else {} end))' \ > "${candidate}/environment.json" jq -n '{passed:true,rejected:["absolute-paths","emails","environment-values","executable-paths","hostnames","socket-names","temporary-directories","terminal-device-names","tokens","usernames"]}' \ > "${candidate}/redaction-proof.json" diff --git a/src/LibTmux.Mcp/README.md b/src/LibTmux.Mcp/README.md index afb66b5..922740f 100644 --- a/src/LibTmux.Mcp/README.md +++ b/src/LibTmux.Mcp/README.md @@ -102,7 +102,7 @@ the pause imitates. ## Which tmux it drives Whatever `tmux` resolves to on the path, or the binary `LIBTMUX_TMUX` names. -The supported range is 3.2a to 3.7b, proven from source on every commit. +The supported range is 3.2a to 3.7c, proven from source on every commit. If you install the SDK through a version manager rather than system-wide, an agent that spawns this server will not inherit your shell and the launcher will diff --git a/src/LibTmux/Constants/TmuxConstants.cs b/src/LibTmux/Constants/TmuxConstants.cs index 0c80306..2d103da 100644 --- a/src/LibTmux/Constants/TmuxConstants.cs +++ b/src/LibTmux/Constants/TmuxConstants.cs @@ -4,7 +4,7 @@ namespace LibTmux; public static class LibTmuxInfo { private static readonly TmuxVersion Minimum = TmuxVersion.Parse("3.2a"); - private static readonly TmuxVersion MaximumTested = TmuxVersion.Parse("3.7b"); + private static readonly TmuxVersion MaximumTested = TmuxVersion.Parse("3.7c"); /// Gets the library assembly version. public static Version Version => typeof(LibTmuxInfo).Assembly.GetName().Version!; diff --git a/src/LibTmux/README.md b/src/LibTmux/README.md index e1874ca..9915145 100644 --- a/src/LibTmux/README.md +++ b/src/LibTmux/README.md @@ -2,11 +2,11 @@ A typed, async-first [tmux](https://github.com/tmux/tmux) client for .NET. Servers, sessions, windows, panes, clients, options, hooks and buffers, against -every tmux from **3.2a to 3.7b**, on **net8.0** and **net10.0**. +every tmux from **3.2a to 3.7c**, on **net8.0** and **net10.0**. > **Alpha.** The public API is not settled and can change between prereleases > without notice, so pin an exact version. The behaviour is proven against all -> seven supported tmux versions on every commit. +> eight supported tmux versions on every commit. ```console $ dotnet package add LibTmux --prerelease @@ -312,7 +312,7 @@ broke, and `Unknown` is the default for exactly that reason. A | | | |---|---| -| tmux | 3.2a to 3.7b | +| tmux | 3.2a to 3.7c | | .NET | net8.0, net10.0 | | OS | Linux and macOS. `Server`, `Session`, `Window` and `Pane` are annotated unsupported on Windows, because their lifecycle, mutation and control-mode contracts need a real tmux | | Trimming / NativeAOT | Core APIs are analyzer-gated. Query `Compile` and `Matching` resolve properties by name, so they warn trimmed callers to preserve the filtered types' public properties | diff --git a/tests/LibTmux.IntegrationTests/Parity/Component03ParityTests.cs b/tests/LibTmux.IntegrationTests/Parity/Component03ParityTests.cs index ad4e001..f84916a 100644 --- a/tests/LibTmux.IntegrationTests/Parity/Component03ParityTests.cs +++ b/tests/LibTmux.IntegrationTests/Parity/Component03ParityTests.cs @@ -90,7 +90,7 @@ public async Task Owned_parity_row_has_version_or_catalog_behavior( bool proved = pythonSymbolId switch { "libtmux.common:TMUX_MAX_VERSION" => - LibTmuxInfo.MaximumTestedTmuxVersion == TmuxVersion.Parse("3.7b"), + LibTmuxInfo.MaximumTestedTmuxVersion == TmuxVersion.Parse("3.7c"), "libtmux.common:TMUX_MIN_VERSION" => LibTmuxInfo.MinimumTmuxVersion == TmuxVersion.Parse("3.2a"), "libtmux.common:get_libtmux_version" => LibTmuxInfo.Version.Major >= 0, diff --git a/tests/LibTmux.UnitTests/Packaging/WorkflowContractTests.cs b/tests/LibTmux.UnitTests/Packaging/WorkflowContractTests.cs index d0b6b43..f5fce19 100644 --- a/tests/LibTmux.UnitTests/Packaging/WorkflowContractTests.cs +++ b/tests/LibTmux.UnitTests/Packaging/WorkflowContractTests.cs @@ -9,7 +9,7 @@ namespace LibTmux.UnitTests.Packaging; public sealed class WorkflowContractTests { private static readonly string[] SupportedTmuxVersions = - ["3.2a", "3.3a", "3.4", "3.5", "3.6", "3.7a", "3.7b"]; + ["3.2a", "3.3a", "3.4", "3.5", "3.6", "3.7a", "3.7b", "3.7c"]; private static readonly string[] TargetFrameworks = ["net8.0", "net10.0"]; diff --git a/tests/LibTmux.UnitTests/Versioning/TmuxCapabilitiesTests.cs b/tests/LibTmux.UnitTests/Versioning/TmuxCapabilitiesTests.cs index 2ebacb1..9aa11cf 100644 --- a/tests/LibTmux.UnitTests/Versioning/TmuxCapabilitiesTests.cs +++ b/tests/LibTmux.UnitTests/Versioning/TmuxCapabilitiesTests.cs @@ -24,6 +24,7 @@ public void Comparisons_cover_equal_older_and_newer_versions() [InlineData("3.7", 3, 7, null)] [InlineData("3.3.7", 3, 3, "7")] [InlineData("3.7b", 3, 7, "b")] + [InlineData("3.7c", 3, 7, "c")] [InlineData("3.0-rc3", 3, 0, "rc3")] [InlineData("3.3a-openbsd", 3, 3, "a-openbsd")] [InlineData("3.7-openbsd", 3, 7, "openbsd")] @@ -106,8 +107,9 @@ public void Parsing_distinguishes_null_and_normalizes_default() [InlineData("3.3.10", "3.3a")] [InlineData("3.7a", "3.7a-openbsd")] [InlineData("3.7a-openbsd", "3.7b")] + [InlineData("3.7b", "3.7c")] [InlineData("3.7z", "3.7aa")] - [InlineData("3.7b", "next-3.8")] + [InlineData("3.7c", "next-3.8")] [InlineData("next-3.8", "3.8")] [InlineData("3.9", "4.0")] public void Ordering_follows_the_frozen_total_order(string olderRaw, string newerRaw) @@ -160,7 +162,7 @@ public void Ensure_at_least_retains_required_and_actual_versions() public void Package_support_metadata_is_inclusive_and_not_a_ceiling() { Assert.Equal(TmuxVersion.Parse("3.2a"), LibTmuxInfo.MinimumTmuxVersion); - Assert.Equal(TmuxVersion.Parse("3.7b"), LibTmuxInfo.MaximumTestedTmuxVersion); + Assert.Equal(TmuxVersion.Parse("3.7c"), LibTmuxInfo.MaximumTestedTmuxVersion); Assert.NotNull(LibTmuxInfo.Version); Assert.True(TmuxVersion.Parse("next-3.8").IsAtLeast(LibTmuxInfo.MinimumTmuxVersion)); } From 3fa133d7587594e6f477b979e00053118f4922ae Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 15:21:14 -0500 Subject: [PATCH 068/129] Hierarchy(refactor[delivery]): Separate subscriber state why: Endpoint recovery and per-subscriber coalescing had independent lifecycles in one 917-line file. what: - move subscriber resource, pending, and delivery state to an internal type - preserve callback isolation, retirement, and failure observation --- .../Streaming/HierarchyEndpointSubscriber.cs | 124 ++++++++++++++++ .../Streaming/HierarchyEndpointWatch.cs | 140 ++---------------- 2 files changed, 136 insertions(+), 128 deletions(-) create mode 100644 src/LibTmux.Mcp/Streaming/HierarchyEndpointSubscriber.cs diff --git a/src/LibTmux.Mcp/Streaming/HierarchyEndpointSubscriber.cs b/src/LibTmux.Mcp/Streaming/HierarchyEndpointSubscriber.cs new file mode 100644 index 0000000..5e83888 --- /dev/null +++ b/src/LibTmux.Mcp/Streaming/HierarchyEndpointSubscriber.cs @@ -0,0 +1,124 @@ +namespace LibTmux.Mcp; + +/// Coalesces hierarchy invalidations for one subscriber. +internal sealed class HierarchyEndpointSubscriber( + Func, Task> announce, + Action reportFailure) +{ + private readonly object _deliveryGate = new(); + private readonly HashSet _pending = new(StringComparer.Ordinal); + private readonly TaskCompletionSource _retirement = new( + TaskCreationOptions.RunContinuationsAsynchronously); + private bool _delivering; + private bool _retired; + + internal Dictionary Resources { get; } = new(StringComparer.Ordinal); + + internal void Enqueue(IReadOnlyList resources) + { + bool startDelivery = false; + lock (_deliveryGate) + { + if (_retired) + { + return; + } + + _pending.UnionWith(resources); + if (!_delivering) + { + _delivering = true; + startDelivery = true; + } + } + + if (startDelivery) + { + _ = ObserveDeliveryAsync(Task.Run(DeliverAsync)); + } + } + + internal void RemovePending(string uri) + { + lock (_deliveryGate) + { + _pending.Remove(uri); + } + } + + internal void Retire() + { + bool cancel; + lock (_deliveryGate) + { + cancel = !_retired; + _retired = true; + _pending.Clear(); + } + + if (cancel) + { + _retirement.TrySetResult(); + } + } + + private async Task DeliverAsync() + { + while (true) + { + string[] resources; + lock (_deliveryGate) + { + if (_retired || _pending.Count == 0) + { + _delivering = false; + return; + } + + resources = [.. _pending]; + _pending.Clear(); + } + + try + { + Task delivery = announce(resources); + if (await Task.WhenAny(delivery, _retirement.Task).ConfigureAwait(false) + != delivery) + { + _ = ObserveDeliveryAsync(delivery); + return; + } + + await delivery.ConfigureAwait(false); + } + catch (Exception error) + { + Report(error); + } + } + } + + private async Task ObserveDeliveryAsync(Task delivery) + { + try + { + await delivery.ConfigureAwait(false); + } + catch (Exception error) + { + Report(error); + } + } + + private void Report(Exception error) + { + try + { + reportFailure(error); + } + catch (Exception) + { + // A logger cannot be allowed to fault detached delivery. + } + } +} diff --git a/src/LibTmux.Mcp/Streaming/HierarchyEndpointWatch.cs b/src/LibTmux.Mcp/Streaming/HierarchyEndpointWatch.cs index e976f95..efa1cd5 100644 --- a/src/LibTmux.Mcp/Streaming/HierarchyEndpointWatch.cs +++ b/src/LibTmux.Mcp/Streaming/HierarchyEndpointWatch.cs @@ -16,7 +16,7 @@ internal sealed class HierarchyEndpointWatch : IAsyncDisposable private readonly Action? _recoveryOutcomeObserved; private readonly CancellationTokenSource _lifetime = new(); private readonly SemaphoreSlim _subscriptionGate = new(1, 1); - private readonly Dictionary _subscribers = new( + private readonly Dictionary _subscribers = new( ReferenceEqualityComparer.Instance); private TaskCompletionSource _subscriberAvailable = NewSignal(); private Func>? _startSession; @@ -62,9 +62,13 @@ internal bool TryAddReference( } bool hadNoSubscribers = _subscribers.Count == 0; - if (!_subscribers.TryGetValue(subscriberKey, out Subscriber? subscriber)) + if (!_subscribers.TryGetValue( + subscriberKey, + out HierarchyEndpointSubscriber? subscriber)) { - subscriber = new Subscriber(announce, ReportSubscriberFailure); + subscriber = new HierarchyEndpointSubscriber( + announce, + ReportSubscriberFailure); _subscribers.Add(subscriberKey, subscriber); } @@ -295,7 +299,7 @@ public async ValueTask DisposeAsync() lock (_gate) { _retired = true; - foreach (Subscriber subscriber in _subscribers.Values) + foreach (HierarchyEndpointSubscriber subscriber in _subscribers.Values) { subscriber.Retire(); } @@ -712,7 +716,9 @@ private static async Task DisposeRunAsync(WatchRun run) private bool RemoveReferenceLocked(string uri, object subscriberKey) { - if (!_subscribers.TryGetValue(subscriberKey, out Subscriber? subscriber) + if (!_subscribers.TryGetValue( + subscriberKey, + out HierarchyEndpointSubscriber? subscriber) || !subscriber.Resources.Remove(uri)) { return false; @@ -736,130 +742,8 @@ private bool RemoveReferenceLocked(string uri, object subscriberKey) private static TaskCompletionSource NewSignal() => new( TaskCreationOptions.RunContinuationsAsynchronously); - private sealed class Subscriber( - Func, Task> announce, - Action reportFailure) - { - private readonly object _deliveryGate = new(); - private readonly HashSet _pending = new(StringComparer.Ordinal); - private readonly TaskCompletionSource _retirement = new( - TaskCreationOptions.RunContinuationsAsynchronously); - private bool _delivering; - private bool _retired; - - internal Dictionary Resources { get; } = new(StringComparer.Ordinal); - - internal void Enqueue(IReadOnlyList resources) - { - bool startDelivery = false; - lock (_deliveryGate) - { - if (_retired) - { - return; - } - - _pending.UnionWith(resources); - if (!_delivering) - { - _delivering = true; - startDelivery = true; - } - } - - if (startDelivery) - { - _ = ObserveDeliveryAsync(Task.Run(DeliverAsync)); - } - } - - internal void RemovePending(string uri) - { - lock (_deliveryGate) - { - _pending.Remove(uri); - } - } - - internal void Retire() - { - bool cancel; - lock (_deliveryGate) - { - cancel = !_retired; - _retired = true; - _pending.Clear(); - } - - if (cancel) - { - _retirement.TrySetResult(); - } - } - - private async Task DeliverAsync() - { - while (true) - { - string[] resources; - lock (_deliveryGate) - { - if (_retired || _pending.Count == 0) - { - _delivering = false; - return; - } - - resources = [.. _pending]; - _pending.Clear(); - } - - try - { - Task delivery = announce(resources); - if (await Task.WhenAny(delivery, _retirement.Task).ConfigureAwait(false) - != delivery) - { - _ = ObserveDeliveryAsync(delivery); - return; - } - - await delivery.ConfigureAwait(false); - } - catch (Exception error) - { - Report(error); - } - } - } - - private async Task ObserveDeliveryAsync(Task delivery) - { - try - { - await delivery.ConfigureAwait(false); - } - catch (Exception error) - { - Report(error); - } - } - - private void Report(Exception error) - { - try - { - reportFailure(error); - } - catch (Exception) - { - // A logger cannot be allowed to fault detached delivery. - } - } - } - private sealed record SubscriberNotification( - Subscriber Subscriber, + HierarchyEndpointSubscriber Subscriber, IReadOnlyList Resources); private sealed class StartTransition(WatchRun? staleRun) From 481a3c8ae6e9c632f218de5fcbe1c48daea4d9d4 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 15:22:00 -0500 Subject: [PATCH 069/129] Parity(fix[generation]): Preserve version adaptation why: Regeneration replaced the reviewed tmux maximum-version mapping with literal constant preservation and made the freshness check fail. what: - emit the maximum-version row from the canonical semantic contract - cover regeneration and restore a clean parity inventory check --- eng/parity/generate_inventory.py | 9 ++++++++- eng/parity/tests/test_inventory.py | 23 +++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/eng/parity/generate_inventory.py b/eng/parity/generate_inventory.py index 09667d1..d49ac6d 100644 --- a/eng/parity/generate_inventory.py +++ b/eng/parity/generate_inventory.py @@ -14,6 +14,7 @@ sys.path.insert(0, str(REPOSITORY_ROOT)) from eng.parity import python_source # noqa: E402 +from eng.parity.verify_public_api import TMUX_MAX_VERSION_ADAPTATION # noqa: E402 SOURCE_REVISION = python_source.REVISION SOURCE_BASE_URL = python_source.BLOB_URL_PREFIX.rstrip("/") @@ -652,6 +653,9 @@ def component(module: str) -> str: C4_PARITY_TEST_PATH = ( "tests/LibTmux.IntegrationTests/Parity/Component04ParityTests.cs" ) +LEDGER_BEHAVIOR_OVERRIDES = { + "libtmux.common:TMUX_MAX_VERSION": TMUX_MAX_VERSION_ADAPTATION, +} def preserve_ledger_reconciliation( @@ -699,7 +703,10 @@ def build_ledger( module = t.cast(str, symbol["module"]) rows.append( { - "behavior": f"Preserve {symbol['kind']} {symbol['qualifiedName']}", + "behavior": LEDGER_BEHAVIOR_OVERRIDES.get( + symbol["id"], + f"Preserve {symbol['kind']} {symbol['qualifiedName']}", + ), "component": component(module), "csharpDestination": ( "LibTmux.Internal.Materialization" diff --git a/eng/parity/tests/test_inventory.py b/eng/parity/tests/test_inventory.py index ba7792e..c84da7e 100644 --- a/eng/parity/tests/test_inventory.py +++ b/eng/parity/tests/test_inventory.py @@ -299,6 +299,29 @@ def test_ledger_regeneration_preserves_only_source_bound_approval() -> None: assert "componentId" not in reset +def test_ledger_generation_emits_maximum_version_adaptation() -> None: + """Generate the reviewed semantic mapping for Python's version ceiling.""" + generator = load_generator() + inventory = { + "symbols": [ + { + "id": "libtmux.common:TMUX_MAX_VERSION", + "kind": "constant", + "module": "libtmux.common", + "qualifiedName": "TMUX_MAX_VERSION", + "sourceUrl": "https://example.invalid/pinned", + } + ] + } + + row = generator["build_ledger"](inventory)["rows"][0] + + assert row["behavior"] == ( + "Semantic adaptation: map Python TMUX_MAX_VERSION 3.7 to " + "MaximumTestedTmuxVersion 3.7c, the highest required tested version" + ) + + def test_ledger_regeneration_moves_only_canonical_window_and_pane_lookup() -> None: """Move lookup materialization to C4 without erasing unrelated evidence.""" generator = load_generator() From 9137c070b46ec204010bb344eab5ba886ca4b58c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 15:22:51 -0500 Subject: [PATCH 070/129] Query(fix[depth]): Validate the complete document why: Snapshot depth accepted unknown schemas, versions, targets, and fields and returned a plausible depth for malformed documents. what: - run semantic document validation before deriving relation depth - cover invalid schema, version, target, and catalog fields --- src/LibTmux/Query/QueryDocument.cs | 2 +- .../QueryDocumentStructuralGuardTests.cs | 29 +++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/LibTmux/Query/QueryDocument.cs b/src/LibTmux/Query/QueryDocument.cs index 832a8d7..b6a43a5 100644 --- a/src/LibTmux/Query/QueryDocument.cs +++ b/src/LibTmux/Query/QueryDocument.cs @@ -34,7 +34,7 @@ public SnapshotDepth RequiredSnapshotDepth { get { - QueryDocumentStructuralGuard.Validate(Predicate); + _ = QueryDocumentValidator.Validate(this); return Depth(Predicate, Target); } } diff --git a/tests/LibTmux.UnitTests/Query/QueryDocumentStructuralGuardTests.cs b/tests/LibTmux.UnitTests/Query/QueryDocumentStructuralGuardTests.cs index 58b4a1c..71404f2 100644 --- a/tests/LibTmux.UnitTests/Query/QueryDocumentStructuralGuardTests.cs +++ b/tests/LibTmux.UnitTests/Query/QueryDocumentStructuralGuardTests.cs @@ -142,6 +142,35 @@ public void Cancellation_is_checked_during_the_structural_walk() Assert.Equal(3, checks); } + [Fact] + public void Snapshot_depth_rejects_semantically_invalid_documents() + { + QueryDocument[] malformed = + [ + new("someone-else", QueryDocument.CurrentVersion, QueryTarget.Session, True), + new( + QueryDocument.CurrentSchema, + QueryDocument.CurrentVersion + 1, + QueryTarget.Session, + True), + new( + QueryDocument.CurrentSchema, + QueryDocument.CurrentVersion, + (QueryTarget)int.MaxValue, + True), + new( + QueryDocument.CurrentSchema, + QueryDocument.CurrentVersion, + QueryTarget.Session, + new FieldNode(QueryTarget.Session, "unknown_field")), + ]; + + Assert.All( + malformed, + document => Assert.Throws( + () => document.RequiredSnapshotDepth)); + } + [Fact] public void Compilation_preserves_the_cancellation_token() { From d5e2e40c329ab34ef8e0fc91e0390b0c8e2efcf1 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 15:25:07 -0500 Subject: [PATCH 071/129] Hierarchy(fix[invalidation]): Track selection changes why: Active-window and client-session changes alter subscribed resource payloads but were absent from the notification allowlist. what: - invalidate hierarchy resources for session-window and client-session notifications - prove both changes through live tmux subscriptions --- src/LibTmux.Mcp/Streaming/HierarchyWatcher.cs | 2 + .../Mcp/HierarchyWatcherTests.cs | 95 +++++++++++++++++++ 2 files changed, 97 insertions(+) diff --git a/src/LibTmux.Mcp/Streaming/HierarchyWatcher.cs b/src/LibTmux.Mcp/Streaming/HierarchyWatcher.cs index 7726e99..fc1c6fb 100644 --- a/src/LibTmux.Mcp/Streaming/HierarchyWatcher.cs +++ b/src/LibTmux.Mcp/Streaming/HierarchyWatcher.cs @@ -32,6 +32,7 @@ public sealed class HierarchyWatcher : IAsyncDisposable { "session-changed", "session-renamed", + "session-window-changed", "sessions-changed", "window-add", "window-close", @@ -42,6 +43,7 @@ public sealed class HierarchyWatcher : IAsyncDisposable "unlinked-window-close", "pane-mode-changed", "client-detached", + "client-session-changed", }; private readonly object _endpointsGate = new(); diff --git a/tests/LibTmux.IntegrationTests/Mcp/HierarchyWatcherTests.cs b/tests/LibTmux.IntegrationTests/Mcp/HierarchyWatcherTests.cs index f7dcc1a..618ca57 100644 --- a/tests/LibTmux.IntegrationTests/Mcp/HierarchyWatcherTests.cs +++ b/tests/LibTmux.IntegrationTests/Mcp/HierarchyWatcherTests.cs @@ -69,6 +69,99 @@ await scope.Session.CreateWindowAsync( Assert.Contains("tmux://hierarchy", await told.Task); } + [UnixFact] + public async Task Selecting_a_window_reaches_a_subscriber() + { + CancellationToken token = TestContext.Current.CancellationToken; + TmuxTestFactory factory = new(); + TmuxTestOptions options = new(new ServerConnectionOptions( + tmuxBinaryPath: System.Environment.GetEnvironmentVariable("LIBTMUX_TMUX") ?? "tmux", + socketName: $"ltw-{Guid.NewGuid():N}"[..20], + configurationFile: "/dev/null")); + await using TemporaryHierarchyScope scope = await factory.CreateHierarchyAsync( + options, + token); + _ = await scope.Session.CreateWindowAsync( + new NewWindowRequest(name: "selected", attach: true), + token); + + await using HierarchyWatcher watcher = new(); + TaskCompletionSource> told = new( + TaskCreationOptions.RunContinuationsAsynchronously); + int armed = 0; + await watcher.SubscribeAsync( + "tmux://hierarchy", + changed => + { + if (Volatile.Read(ref armed) != 0) + { + told.TrySetResult(changed); + } + + return Task.CompletedTask; + }, + scope.Session.Server, + token); + Volatile.Write(ref armed, 1); + + _ = await scope.Window.SelectAsync(token); + + Task finished = await Task.WhenAny( + told.Task, + Task.Delay(TimeSpan.FromSeconds(20), token)); + Assert.True(finished == told.Task, "the watcher missed the active-window change"); + Assert.Contains("tmux://hierarchy", await told.Task); + } + + [UnixFact] + public async Task Moving_a_client_to_another_session_reaches_a_subscriber() + { + CancellationToken token = TestContext.Current.CancellationToken; + TmuxTestFactory factory = new(); + TmuxTestOptions options = new(new ServerConnectionOptions( + tmuxBinaryPath: System.Environment.GetEnvironmentVariable("LIBTMUX_TMUX") ?? "tmux", + socketName: $"ltw-{Guid.NewGuid():N}"[..20], + configurationFile: "/dev/null")); + await using TemporaryHierarchyScope scope = await factory.CreateHierarchyAsync( + options, + token); + Session other = await scope.Session.Server.CreateSessionAsync( + new NewSessionRequest(name: "other"), + token); + await using IControlModeSession moving = await scope.Session.Server.EnterControlModeAsync( + scope.Session.Id.ToString(), + token); + + await using HierarchyWatcher watcher = new(); + TaskCompletionSource> told = new( + TaskCreationOptions.RunContinuationsAsynchronously); + int armed = 0; + await watcher.SubscribeAsync( + "tmux://sessions", + changed => + { + if (Volatile.Read(ref armed) != 0) + { + told.TrySetResult(changed); + } + + return Task.CompletedTask; + }, + scope.Session.Server, + token); + Volatile.Write(ref armed, 1); + + _ = await moving.SendAsync( + TmuxCommand.Create("switch-client", "-t", other.Id.ToString()), + token); + + Task finished = await Task.WhenAny( + told.Task, + Task.Delay(TimeSpan.FromSeconds(20), token)); + Assert.True(finished == told.Task, "the watcher missed the client-session change"); + Assert.Contains("tmux://sessions", await told.Task); + } + [UnixFact] public async Task Dropping_the_last_subscriber_stops_the_control_client() { @@ -189,6 +282,8 @@ await Task.WhenAny(bothTold, Task.Delay(TimeSpan.FromSeconds(20), token)) == bot [InlineData("window-add", true)] [InlineData("layout-change", true)] [InlineData("session-renamed", true)] + [InlineData("session-window-changed", true)] + [InlineData("client-session-changed", true)] [InlineData("output", false)] [InlineData("continue", false)] // A bell or a byte of pane output is not a change to the hierarchy. Waking From 027ebf8416ed9623b7bb5fbcee8ac9860e53170d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 15:33:42 -0500 Subject: [PATCH 072/129] Waiting(feat[channels]): Hold a wait across timed attempts why: tmux gives a signal to whoever is registered on a channel and raises the channel's pending flag only when nobody is. A waiter whose client is killed to enforce a timeout stays registered and takes the next signal, because tmux clears waiters when the server exits and at no other time. what: - add TmuxWaitChannel, whose registration outlives an expired attempt - withdraw a waiter by signalling the channel, leaving no pending flag - report a withdrawn wait as unsignalled rather than as finished - let a cancelled caller win over a waiter that finished in the same moment, so the outcome does not depend on which raced first - prove both behaviours against a live tmux server --- docs/public-api.json | 131 ++++++++++++++++ docs/public-api.md | 11 ++ src/LibTmux/PublicAPI.Unshipped.txt | 6 + src/LibTmux/Server.Execution.cs | 11 ++ src/LibTmux/Waiting/TmuxWaitChannel.cs | 141 ++++++++++++++++++ .../Waiting/TmuxWaitChannelTests.cs | 66 ++++++++ 6 files changed, 366 insertions(+) create mode 100644 src/LibTmux/Waiting/TmuxWaitChannel.cs create mode 100644 tests/LibTmux.IntegrationTests/Waiting/TmuxWaitChannelTests.cs diff --git a/docs/public-api.json b/docs/public-api.json index d9a185f..fe0b9f4 100644 --- a/docs/public-api.json +++ b/docs/public-api.json @@ -3183,6 +3183,24 @@ "ownership": "value", "state": [], "summary": "Turns a request record into a command a chain can carry." + }, + { + "id": "T:LibTmux.TmuxWaitChannel", + "namespace": "LibTmux", + "name": "TmuxWaitChannel", + "kind": "class", + "package": "LibTmux", + "modifiers": [ + "public", + "sealed" + ], + "baseType": "object", + "interfaces": [ + "IAsyncDisposable" + ], + "ownership": "owned", + "state": [], + "summary": "Holds a tmux wait-for registration across timed attempts." } ], "members": [ @@ -27581,6 +27599,119 @@ "signature": "static Task ExecuteAsync(this SetHooksRequest request, TmuxHooks hooks, Server server, CancellationToken cancellationToken = default)", "portable": true, "summary": "Runs a multi-entry hook request in one invocation." + }, + { + "id": "M:LibTmux.Server.OpenWaitChannel(String)", + "declaringType": "T:LibTmux.Server", + "name": "OpenWaitChannel", + "kind": "method", + "visibility": "public", + "package": "LibTmux", + "static": false, + "genericParameters": [], + "returnType": "TmuxWaitChannel", + "parameters": [ + { + "name": "channel", + "type": "string" + } + ], + "signature": "TmuxWaitChannel LibTmux.Server.OpenWaitChannel(string channel)", + "performsIO": false, + "processBacked": true, + "portable": false, + "platformAnnotations": [ + "UnsupportedOSPlatform(\"windows\")" + ], + "summary": "Opens a wait that survives a timed attempt." + }, + { + "id": "P:LibTmux.TmuxWaitChannel.Channel", + "declaringType": "T:LibTmux.TmuxWaitChannel", + "name": "Channel", + "kind": "property", + "visibility": "public", + "package": "LibTmux", + "static": false, + "returnType": "string", + "parameters": [], + "signature": "string LibTmux.TmuxWaitChannel.Channel { get; }", + "portable": true, + "summary": "Gets the channel being waited on." + }, + { + "id": "P:LibTmux.TmuxWaitChannel.Signalled", + "declaringType": "T:LibTmux.TmuxWaitChannel", + "name": "Signalled", + "kind": "property", + "visibility": "public", + "package": "LibTmux", + "static": false, + "returnType": "bool", + "parameters": [], + "signature": "bool LibTmux.TmuxWaitChannel.Signalled { get; }", + "portable": true, + "summary": "Gets whether something really signalled the channel." + }, + { + "id": "M:LibTmux.TmuxWaitChannel.WaitAsync(TimeSpan,CancellationToken)", + "declaringType": "T:LibTmux.TmuxWaitChannel", + "name": "WaitAsync", + "kind": "method", + "visibility": "public", + "package": "LibTmux", + "static": false, + "genericParameters": [], + "returnType": "Task", + "parameters": [ + { + "name": "budget", + "type": "TimeSpan" + }, + { + "name": "cancellationToken", + "type": "CancellationToken", + "default": "default" + } + ], + "signature": "Task LibTmux.TmuxWaitChannel.WaitAsync(TimeSpan budget, CancellationToken cancellationToken = default)", + "performsIO": true, + "processBacked": true, + "portable": false, + "platformAnnotations": [ + "UnsupportedOSPlatform(\"windows\")" + ], + "summary": "Waits for the signal, giving this attempt a budget." + }, + { + "id": "M:LibTmux.TmuxWaitChannel.DisposeAsync()", + "declaringType": "T:LibTmux.TmuxWaitChannel", + "name": "DisposeAsync", + "kind": "method", + "visibility": "public", + "package": "LibTmux", + "static": false, + "genericParameters": [], + "returnType": "ValueTask", + "parameters": [], + "signature": "ValueTask LibTmux.TmuxWaitChannel.DisposeAsync()", + "performsIO": false, + "processBacked": true, + "portable": false, + "platformAnnotations": [ + "UnsupportedOSPlatform(\"windows\")" + ], + "summary": "Withdraws the waiter from tmux." + }, + { + "id": "T:LibTmux.TmuxWaitChannel", + "declaringType": "T:LibTmux.TmuxWaitChannel", + "name": "TmuxWaitChannel", + "kind": "type", + "visibility": "public", + "package": "LibTmux", + "signature": "class LibTmux.TmuxWaitChannel", + "portable": true } ] } diff --git a/docs/public-api.md b/docs/public-api.md index 0dd1d2b..7968144 100644 --- a/docs/public-api.md +++ b/docs/public-api.md @@ -446,6 +446,7 @@ internal static class Program | `T:LibTmux.TmuxCommand` | record | `public, sealed` | None | `object` | value | One tmux command and the arguments it carries. | `LibTmux` | | `T:LibTmux.TmuxChain` | class | `public, sealed` | None | `object` | reference | Commands tmux runs together, in one process. | `LibTmux` | | `T:LibTmux.TmuxChaining` | class | `public, static` | None | `object` | value | Turns a request record into a command a chain can carry. | `LibTmux` | +| `T:LibTmux.TmuxWaitChannel` | class | `public, sealed` | `IAsyncDisposable` | `object` | owned | Holds a tmux wait-for registration across timed attempts. | `LibTmux` | ## Public members @@ -1444,6 +1445,7 @@ internal static class Program | `M:LibTmux.Server.LockAsync(CancellationToken)` | `Task LibTmux.Server.LockAsync(CancellationToken cancellationToken = default)` | Public | No | `UnsupportedOSPlatform("windows")` | Performs Lock. | | `M:LibTmux.Server.LockClientAsync(string?,CancellationToken)` | `Task LibTmux.Server.LockClientAsync(string? targetClient = null, CancellationToken cancellationToken = default)` | Public | No | `UnsupportedOSPlatform("windows")` | Performs LockClient. | | `M:LibTmux.Server.Open(ServerConnectionOptions?)` | `static Server LibTmux.Server.Open(ServerConnectionOptions? options = null)` | Public | Yes | Portable | Opens an unmaterialized connection handle without starting a process. | +| `M:LibTmux.Server.OpenWaitChannel(String)` | `TmuxWaitChannel LibTmux.Server.OpenWaitChannel(string channel)` | Public | No | `UnsupportedOSPlatform("windows")` | Opens a wait that survives a timed attempt. | | `M:LibTmux.Server.RaiseIfDeadAsync(CancellationToken)` | `Task LibTmux.Server.RaiseIfDeadAsync(CancellationToken cancellationToken = default)` | Public | No | `UnsupportedOSPlatform("windows")` | Performs RaiseIfDead. | | `M:LibTmux.Server.RefreshClientAsync(string?,bool,CancellationToken)` | `Task LibTmux.Server.RefreshClientAsync(string? targetClient = null, bool requestClipboard = false, CancellationToken cancellationToken = default)` | Public | No | `UnsupportedOSPlatform("windows")` | Performs RefreshClient. | | `M:LibTmux.Server.RunShellAsync(RunShellRequest,CancellationToken)` | `Task?> LibTmux.Server.RunShellAsync(RunShellRequest request, CancellationToken cancellationToken = default)` | Public | No | `UnsupportedOSPlatform("windows")` | Performs RunShell. | @@ -2129,6 +2131,15 @@ internal static class Program | `P:LibTmux.TmuxVersionTooLowException.ActualVersion` | `TmuxVersion LibTmux.TmuxVersionTooLowException.ActualVersion { get; }` | Public | No | Portable | Gets ActualVersion. | | `P:LibTmux.TmuxVersionTooLowException.RequiredVersion` | `TmuxVersion LibTmux.TmuxVersionTooLowException.RequiredVersion { get; }` | Public | No | Portable | Gets RequiredVersion. | +### `T:LibTmux.TmuxWaitChannel` + +| Member ID | Declaration | Visibility | Static | Platform | Notes | +| --- | --- | --- | --- | --- | --- | +| `M:LibTmux.TmuxWaitChannel.DisposeAsync()` | `ValueTask LibTmux.TmuxWaitChannel.DisposeAsync()` | Public | No | `UnsupportedOSPlatform("windows")` | Withdraws the waiter from tmux. | +| `M:LibTmux.TmuxWaitChannel.WaitAsync(TimeSpan,CancellationToken)` | `Task LibTmux.TmuxWaitChannel.WaitAsync(TimeSpan budget, CancellationToken cancellationToken = default)` | Public | No | `UnsupportedOSPlatform("windows")` | Waits for the signal, giving this attempt a budget. | +| `P:LibTmux.TmuxWaitChannel.Channel` | `string LibTmux.TmuxWaitChannel.Channel { get; }` | Public | No | Portable | Gets the channel being waited on. | +| `P:LibTmux.TmuxWaitChannel.Signalled` | `bool LibTmux.TmuxWaitChannel.Signalled { get; }` | Public | No | Portable | Gets whether something really signalled the channel. | + ### `T:LibTmux.TmuxWaitMode` | Member ID | Declaration | Visibility | Static | Platform | Notes | diff --git a/src/LibTmux/PublicAPI.Unshipped.txt b/src/LibTmux/PublicAPI.Unshipped.txt index 20a6bd4..cda08f6 100644 --- a/src/LibTmux/PublicAPI.Unshipped.txt +++ b/src/LibTmux/PublicAPI.Unshipped.txt @@ -1912,3 +1912,9 @@ virtual LibTmux.Query.QueryNode.PrintMembers(System.Text.StringBuilder! builder) virtual LibTmux.TmuxEvent.EqualityContract.get -> System.Type! virtual LibTmux.TmuxEvent.Equals(LibTmux.TmuxEvent? other) -> bool virtual LibTmux.TmuxEvent.PrintMembers(System.Text.StringBuilder! builder) -> bool +LibTmux.Server.OpenWaitChannel(string! channel) -> LibTmux.TmuxWaitChannel! +LibTmux.TmuxWaitChannel +LibTmux.TmuxWaitChannel.Channel.get -> string! +LibTmux.TmuxWaitChannel.DisposeAsync() -> System.Threading.Tasks.ValueTask +LibTmux.TmuxWaitChannel.Signalled.get -> bool +LibTmux.TmuxWaitChannel.WaitAsync(System.TimeSpan budget, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! diff --git a/src/LibTmux/Server.Execution.cs b/src/LibTmux/Server.Execution.cs index a6fa09f..18e0d09 100644 --- a/src/LibTmux/Server.Execution.cs +++ b/src/LibTmux/Server.Execution.cs @@ -117,6 +117,17 @@ public Task WaitForAsync(WaitForRequest request, CancellationToken cancellationT return RunUtilityAsync(BuildWaitForArguments(request), cancellationToken); } + /// Opens a wait on a channel that survives a timed attempt. + /// The channel to wait on. + /// The open wait, which must be disposed to withdraw it. + /// + /// Prefer this to whenever the wait has a + /// deadline. Cancelling a waiting wait-for kills its client while + /// tmux keeps the registration, and that registration eats the next signal. + /// + [UnsupportedOSPlatform("windows")] + public TmuxWaitChannel OpenWaitChannel(string channel) => new(this, channel); + internal static List BuildWaitForArguments(WaitForRequest request) { List arguments = ["wait-for"]; diff --git a/src/LibTmux/Waiting/TmuxWaitChannel.cs b/src/LibTmux/Waiting/TmuxWaitChannel.cs new file mode 100644 index 0000000..94a4ec7 --- /dev/null +++ b/src/LibTmux/Waiting/TmuxWaitChannel.cs @@ -0,0 +1,141 @@ +using System.Runtime.Versioning; + +namespace LibTmux; + +/// An open wait on a tmux wait-for channel. +/// +/// +/// tmux gives a signal to whoever is registered on the channel and raises the +/// channel's pending flag only when nobody is. A waiter whose client dies stays +/// registered — tmux clears waiters when the server exits and at no other time +/// — so it goes on eating signals that can no longer reach anybody. Killing a +/// waiting client to enforce a timeout therefore destroys the next signal, and +/// each timed-out retry leaves another corpse to destroy the one after that. +/// +/// +/// So this never abandons a live waiter. returning +/// false means the signal has not arrived yet, not that waiting stopped: the +/// registration still stands, and the next attempt sees a signal that landed in +/// between. Disposing withdraws the waiter deliberately, which is the only safe +/// way to stop. +/// +/// +[UnsupportedOSPlatform("windows")] +public sealed class TmuxWaitChannel : IAsyncDisposable +{ + private readonly Server _server; + private readonly Task _waiter; + private int _disposed; + private bool _withdrew; + + internal TmuxWaitChannel(Server server, string channel) + { + ArgumentNullException.ThrowIfNull(server); + ArgumentException.ThrowIfNullOrWhiteSpace(channel); + _server = server; + Channel = channel; + + // Deliberately unbound to any caller's token: the waiter outlives every + // individual attempt, and only Dispose withdraws it. + _waiter = server.WaitForAsync( + new WaitForRequest(channel, TmuxWaitMode.Wait), + CancellationToken.None); + } + + /// Gets the channel being waited on. + public string Channel { get; } + + /// Gets whether something really signalled the channel. + /// + /// Withdrawing signals the channel too, so finishing is not the same as + /// having been signalled: this stays false for a wait that was withdrawn. + /// + public bool Signalled => _waiter.IsCompletedSuccessfully && !_withdrew; + + /// Waits for the signal, giving this attempt a budget. + /// How long this attempt may take. + /// Abandons this attempt, not the waiter. + /// True when the channel was signalled, false when the budget ran out. + public async Task WaitAsync( + TimeSpan budget, + CancellationToken cancellationToken = default) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(budget.Ticks); + ObjectDisposedException.ThrowIf(_disposed != 0, this); + cancellationToken.ThrowIfCancellationRequested(); + if (_waiter.IsCompleted) + { + await _waiter.ConfigureAwait(false); + return true; + } + + using var attempt = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + Task expiry = Task.Delay(budget, attempt.Token); + Task first = await Task.WhenAny(_waiter, expiry).ConfigureAwait(false); + await attempt.CancelAsync().ConfigureAwait(false); + + // A cancelled caller wins over a waiter that happened to finish in the + // same moment, so the outcome does not depend on which raced first. + // Nothing is lost by that: the waiter is still registered, and only + // disposal withdraws it. + cancellationToken.ThrowIfCancellationRequested(); + if (first != _waiter) + { + return false; + } + + await _waiter.ConfigureAwait(false); + return true; + } + + /// Withdraws the waiter from tmux. + /// + /// + /// Signalling the channel is how a waiter withdraws: tmux wakes the + /// registered waiters and, because the list was not empty, leaves the + /// pending flag down. Nothing else can deregister one. + /// + /// + /// A signal landing between the check below and the withdrawal is woken by + /// this waiter and then re-raised by the withdrawal itself, because by then + /// no waiter is left to take it. That leaves the channel pending rather + /// than empty — an extra wake for the next caller, never a lost one. + /// + /// + public async ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + if (!_waiter.IsCompleted) + { + _withdrew = true; + try + { + await _server.WaitForAsync( + new WaitForRequest(Channel, TmuxWaitMode.Signal), + CancellationToken.None) + .ConfigureAwait(false); + } + catch (TmuxCommandException) + { + // The server is gone, which withdraws every waiter it held. + } + } + + try + { + await _waiter.ConfigureAwait(false); + } + catch (TmuxCommandException) + { + // Disposal reports nothing; the waiter's outcome stopped mattering. + } + catch (OperationCanceledException) + { + // Same: the wait is being withdrawn, not observed. + } + } +} diff --git a/tests/LibTmux.IntegrationTests/Waiting/TmuxWaitChannelTests.cs b/tests/LibTmux.IntegrationTests/Waiting/TmuxWaitChannelTests.cs new file mode 100644 index 0000000..e94c99c --- /dev/null +++ b/tests/LibTmux.IntegrationTests/Waiting/TmuxWaitChannelTests.cs @@ -0,0 +1,66 @@ +using System.Runtime.Versioning; +using LibTmux.IntegrationTests.Infrastructure; +using LibTmux.IntegrationTests.Transport; + +namespace LibTmux.IntegrationTests.Waiting; + +[UnsupportedOSPlatform("windows")] +public sealed class TmuxWaitChannelTests +{ + private static readonly TimeSpan Attempt = TimeSpan.FromMilliseconds(300); + private static readonly TimeSpan Arrival = TimeSpan.FromSeconds(5); + + [UnixFact] + public async Task An_expired_attempt_still_sees_a_signal_that_lands_afterwards() + { + await using RawTmuxTestContext raw = await RawTmuxTestContext.StartAsync( + TestContext.Current.CancellationToken); + CancellationToken token = TestContext.Current.CancellationToken; + Server server = await ConnectAsync(raw, token); + const string Channel = "libtmux-survives"; + + TmuxWaitChannel wait = server.OpenWaitChannel(Channel); + + // Nothing has signalled yet, so the attempt expires. The waiter stays + // registered, which is the whole point: tmux hands a signal to whoever + // is registered, and a waiter killed to enforce a timeout eats it. + Assert.False(await wait.WaitAsync(Attempt, token)); + + await server.WaitForAsync(new WaitForRequest(Channel, TmuxWaitMode.Signal), token); + + Assert.True(await wait.WaitAsync(Arrival, token)); + await wait.DisposeAsync(); + + // Disposal signals the channel to withdraw, so it must not turn a wait + // that really was signalled into one that merely stopped. + Assert.True(wait.Signalled); + } + + [UnixFact] + public async Task Withdrawing_a_wait_reports_it_was_never_signalled() + { + await using RawTmuxTestContext raw = await RawTmuxTestContext.StartAsync( + TestContext.Current.CancellationToken); + CancellationToken token = TestContext.Current.CancellationToken; + Server server = await ConnectAsync(raw, token); + const string Channel = "libtmux-withdrawn"; + + TmuxWaitChannel abandoned = server.OpenWaitChannel(Channel); + Assert.False(await abandoned.WaitAsync(Attempt, token)); + await abandoned.DisposeAsync(); + Assert.False(abandoned.Signalled); + + // Withdrawing signals the channel to deregister, so it must not leave + // the channel looking as though something had really signalled it. + await using TmuxWaitChannel next = server.OpenWaitChannel(Channel); + Assert.False(await next.WaitAsync(Attempt, token)); + } + + private static Task ConnectAsync(RawTmuxTestContext raw, CancellationToken token) => + Server.ConnectAsync( + new ServerConnectionOptions( + tmuxBinaryPath: raw.TmuxBinaryPath, + socketPath: raw.SocketPath, + configurationFile: "/dev/null"), + token); +} From cc7436ac4597343be76d7ae1585b46319b8391ff Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 15:38:27 -0500 Subject: [PATCH 073/129] Mcp(fix[wait]): Stop handing signals to abandoned waiters why: tmux_wait_for_channel and tmux_run each cancelled a waiting client to enforce their budget, so every expired attempt left a registration that took the next signal. The timeout message told the caller to try again, and each retry leaked another waiter to eat the signal after it. what: - wait through TmuxWaitChannel so an expired attempt withdraws cleanly - move tmux_wait_for_channel to the mutating tier, since taking a channel's one pending signal is a change another process can see - mark the tool destructive, as removing that signal is not additive - say the wait was withdrawn instead of claiming nothing changed --- docs/mcp/tools.md | 2 +- src/LibTmux.Mcp/Tools/ReadTools.Wait.cs | 76 --------------- src/LibTmux.Mcp/Tools/WriteTools.Run.cs | 19 ++-- src/LibTmux.Mcp/Tools/WriteTools.Wait.cs | 93 +++++++++++++++++++ .../Mcp/WaitInputBudgetTests.cs | 12 +-- 5 files changed, 108 insertions(+), 94 deletions(-) create mode 100644 src/LibTmux.Mcp/Tools/WriteTools.Wait.cs diff --git a/docs/mcp/tools.md b/docs/mcp/tools.md index a9e5c35..7366334 100644 --- a/docs/mcp/tools.md +++ b/docs/mcp/tools.md @@ -53,7 +53,7 @@ a tool annotated read-only, which a client may use to skip a confirmation. | `tmux_split_pane` | mutating | | Split a pane and return the NEW pane's id. | | `tmux_start_job` | mutating | | Start a shell command in a pane and return a job handle IMMEDIATELY, without waiting. | | `tmux_tail_pane` | readonly | yes | Read only what a pane has printed since the last call. | -| `tmux_wait_for_channel` | readonly | yes | Block until something signals a tmux wait-for channel with 'tmux wait-for -S '. | +| `tmux_wait_for_channel` | mutating | | Block until something signals a tmux wait-for channel with 'tmux wait-for -S '. | | `tmux_wait_for_text` | readonly | yes | Wait until a pane prints something matching one of these patterns, then return. | | `tmux_whoami` | readonly | yes | Answer which pane this MCP server is running inside, or null when it is not running in tmux. | diff --git a/src/LibTmux.Mcp/Tools/ReadTools.Wait.cs b/src/LibTmux.Mcp/Tools/ReadTools.Wait.cs index cf648ef..b05d2cc 100644 --- a/src/LibTmux.Mcp/Tools/ReadTools.Wait.cs +++ b/src/LibTmux.Mcp/Tools/ReadTools.Wait.cs @@ -194,56 +194,6 @@ await _activity.WaitForActivityAsync( } } - /// Waits on a tmux wait-for channel. - /// The channel name. - /// How long to wait, before the server's ceiling. - /// The tmux socket, or null for the default. - /// Stops waiting. - /// What happened. - /// - /// tmux's own rendezvous, exposed for a shell command a caller composed - /// themselves. tmux_run uses this internally, so reach for this only - /// when the command's shape does not fit that tool. - /// - [McpServerTool(Name = "tmux_wait_for_channel", ReadOnly = true, OpenWorld = false, UseStructuredContent = true)] - [Description( - "Block until something signals a tmux wait-for channel with " - + "'tmux wait-for -S '. Use when you composed a shell command that " - + "signals it. For an ordinary command whose completion you want, tmux_run " - + "already does this and also reports the exit status.")] - public async Task WaitForChannelAsync( - [Description("The channel name to wait on, at most 4096 UTF-8 bytes.")] string channel, - [Description("Seconds to wait. Lowered to the server's ceiling.")] - double? timeoutSeconds = null, - [Description("The tmux socket to read. Omit for the default server.")] - string? socketName = null, - CancellationToken cancellationToken = default) - { - ArgumentException.ThrowIfNullOrWhiteSpace(channel); - ValidateChannel(channel, _policy.MaxBytes); - Server server = await ServerAsync(socketName, cancellationToken).ConfigureAwait(false); - TimeSpan budget = _policy.EffectiveTimeout( - timeoutSeconds is double seconds ? TimeSpan.FromSeconds(seconds) : null); - - using CancellationTokenSource expiry = CancellationTokenSource.CreateLinkedTokenSource( - cancellationToken); - expiry.CancelAfter(budget); - - try - { - await server.WaitForAsync( - new WaitForRequest(channel, TmuxWaitMode.Wait), - expiry.Token) - .ConfigureAwait(false); - return new ActionResult($"Channel '{channel}' was signalled."); - } - catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) - { - return new ActionResult( - $"Channel '{channel}' was not signalled within " - + $"{budget.TotalSeconds:0.#}s. Nothing was changed; call again to keep waiting."); - } - } /// Tells the client a wait is still running. /// Where to report, or null when the client asked for none. @@ -334,31 +284,6 @@ internal static void ValidateWaitPatterns( + $"and {MaximumWaitPatternBytesTotal} bytes across both lists."); } - internal static void ValidateChannel(string channel, int resultMaxBytes) - { - ArgumentException.ThrowIfNullOrWhiteSpace(channel); - ArgumentOutOfRangeException.ThrowIfNegativeOrZero(resultMaxBytes); - if (channel.Length > MaximumChannelBytes - || Encoding.UTF8.GetByteCount(channel) > MaximumChannelBytes) - { - throw new McpException( - $"A wait channel may use at most {MaximumChannelBytes} UTF-8 bytes."); - } - - ActionResult success = new($"Channel '{channel}' was signalled."); - ActionResult timeout = new( - $"Channel '{channel}' was not signalled within " - + $"{600d:0.#}s. Nothing was changed; call again to keep waiting."); - if (Utf8JsonBudget.GetStructuredToolResultByteCount(success, ToolJson.Options) - > resultMaxBytes - || Utf8JsonBudget.GetStructuredToolResultByteCount(timeout, ToolJson.Options) - > resultMaxBytes) - { - throw new McpException( - "The wait channel cannot fit in the configured result byte ceiling. " - + $"Use a shorter channel or raise {ServerPolicy.MaxBytesVariable}."); - } - } internal static string? Match( Regex[] patterns, @@ -424,5 +349,4 @@ private async Task FinishAsync( private const int MaximumWaitPatterns = 32; private const int MaximumWaitPatternBytes = 4_096; private const int MaximumWaitPatternBytesTotal = 16_384; - private const int MaximumChannelBytes = 4_096; } diff --git a/src/LibTmux.Mcp/Tools/WriteTools.Run.cs b/src/LibTmux.Mcp/Tools/WriteTools.Run.cs index f7deda1..b315567 100644 --- a/src/LibTmux.Mcp/Tools/WriteTools.Run.cs +++ b/src/LibTmux.Mcp/Tools/WriteTools.Run.cs @@ -314,19 +314,16 @@ internal static async Task AwaitChannelAsync( TimeSpan budget, CancellationToken cancellationToken) { - using CancellationTokenSource expiry = CancellationTokenSource.CreateLinkedTokenSource( - cancellationToken); - expiry.CancelAfter(budget); - try + // A command signals its channel once. Cancelling a waiting client to + // enforce the budget leaves tmux holding the registration, and that + // registration takes the signal instead of the next caller. + await using TmuxWaitChannel wait = server.OpenWaitChannel(channel); + if (!await wait.WaitAsync(budget, cancellationToken).ConfigureAwait(false)) { - await server.WaitForAsync(new WaitForRequest(channel, TmuxWaitMode.Wait), expiry.Token) - .ConfigureAwait(false); - return true; - } - catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) - { - return false; + await wait.DisposeAsync().ConfigureAwait(false); } + + return wait.Signalled; } internal static async Task ReadStatusAsync( diff --git a/src/LibTmux.Mcp/Tools/WriteTools.Wait.cs b/src/LibTmux.Mcp/Tools/WriteTools.Wait.cs new file mode 100644 index 0000000..02388ba --- /dev/null +++ b/src/LibTmux.Mcp/Tools/WriteTools.Wait.cs @@ -0,0 +1,93 @@ +using System.ComponentModel; +using System.Runtime.Versioning; +using System.Text; +using ModelContextProtocol; +using ModelContextProtocol.Server; + +namespace LibTmux.Mcp; + +/// Waiting on a tmux rendezvous channel. +/// +/// Waiting takes the channel's one pending signal, which another process can +/// be relying on, so this sits with the tools that change tmux rather than +/// with the ones that only read it. +/// +[UnsupportedOSPlatform("windows")] +public sealed partial class WriteTools +{ + /// Waits on a tmux wait-for channel. + /// The channel name. + /// How long to wait, before the server's ceiling. + /// The tmux socket, or null for the default. + /// Stops waiting. + /// What happened. + /// + /// tmux's own rendezvous, exposed for a shell command a caller composed + /// themselves. tmux_run uses this internally, so reach for this only + /// when the command's shape does not fit that tool. + /// + [McpServerTool(Name = "tmux_wait_for_channel", Destructive = true, OpenWorld = false, UseStructuredContent = true)] + [Description( + "Block until something signals a tmux wait-for channel with " + + "'tmux wait-for -S '. Use when you composed a shell command that " + + "signals it. For an ordinary command whose completion you want, tmux_run " + + "already does this and also reports the exit status.")] + public async Task WaitForChannelAsync( + [Description("The channel name to wait on, at most 4096 UTF-8 bytes.")] string channel, + [Description("Seconds to wait. Lowered to the server's ceiling.")] + double? timeoutSeconds = null, + [Description("The tmux socket to read. Omit for the default server.")] + string? socketName = null, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(channel); + ValidateChannel(channel, _policy.MaxBytes); + Server server = await ServerAsync(socketName, cancellationToken).ConfigureAwait(false); + TimeSpan budget = _policy.EffectiveTimeout( + timeoutSeconds is double seconds ? TimeSpan.FromSeconds(seconds) : null); + + await using TmuxWaitChannel wait = server.OpenWaitChannel(channel); + if (!await wait.WaitAsync(budget, cancellationToken).ConfigureAwait(false)) + { + // Withdraw before answering. A signal landing as the attempt ended + // was taken by this waiter, and only withdrawing settles whether + // that happened. + await wait.DisposeAsync().ConfigureAwait(false); + } + + return wait.Signalled + ? new ActionResult($"Channel '{channel}' was signalled.") + : new ActionResult(NotSignalled(channel, budget)); + } + + /// Says a wait ran out without claiming the channel is untouched. + private static string NotSignalled(string channel, TimeSpan budget) => + $"Channel '{channel}' was not signalled within {budget.TotalSeconds:0.#}s. " + + "The wait was withdrawn, so a signal arriving now still counts; call again."; + + internal static void ValidateChannel(string channel, int resultMaxBytes) + { + ArgumentException.ThrowIfNullOrWhiteSpace(channel); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(resultMaxBytes); + if (channel.Length > MaximumChannelBytes + || Encoding.UTF8.GetByteCount(channel) > MaximumChannelBytes) + { + throw new McpException( + $"A wait channel may use at most {MaximumChannelBytes} UTF-8 bytes."); + } + + ActionResult success = new($"Channel '{channel}' was signalled."); + ActionResult timeout = new(NotSignalled(channel, TimeSpan.FromSeconds(600))); + if (Utf8JsonBudget.GetStructuredToolResultByteCount(success, ToolJson.Options) + > resultMaxBytes + || Utf8JsonBudget.GetStructuredToolResultByteCount(timeout, ToolJson.Options) + > resultMaxBytes) + { + throw new McpException( + "The wait channel cannot fit in the configured result byte ceiling. " + + $"Use a shorter channel or raise {ServerPolicy.MaxBytesVariable}."); + } + } + + private const int MaximumChannelBytes = 4_096; +} diff --git a/tests/LibTmux.UnitTests/Mcp/WaitInputBudgetTests.cs b/tests/LibTmux.UnitTests/Mcp/WaitInputBudgetTests.cs index bc7f22d..0667049 100644 --- a/tests/LibTmux.UnitTests/Mcp/WaitInputBudgetTests.cs +++ b/tests/LibTmux.UnitTests/Mcp/WaitInputBudgetTests.cs @@ -16,7 +16,7 @@ public void Valid_patterns_and_channels_fit_the_minimum_policy() ["ready\\s+now"], ["error|failed"], resultMaxBytes: 4_000); - ReadTools.ValidateChannel("build-ready", resultMaxBytes: 4_000); + WriteTools.ValidateChannel("build-ready", resultMaxBytes: 4_000); } [Fact] @@ -67,15 +67,15 @@ public async Task Invalid_wait_inputs_are_rejected_before_tmux_dispatch() var server = new Server(connection, generation, "tmux 3.7"); using var accessor = new TmuxConnectionAccessor(server); await using var activity = new PaneActivityHub(); - var tools = new ReadTools( - accessor, - new ServerPolicy { MaxBytes = 4_000 }, - activity); + var policy = new ServerPolicy { MaxBytes = 4_000 }; + await using var jobs = new JobStore(); + var tools = new ReadTools(accessor, policy, activity); + var writes = new WriteTools(accessor, policy, activity, jobs); _ = await Assert.ThrowsAsync(() => tools.WaitForTextAsync( patterns: [new string('x', 4_097)], cancellationToken: TestContext.Current.CancellationToken)); - _ = await Assert.ThrowsAsync(() => tools.WaitForChannelAsync( + _ = await Assert.ThrowsAsync(() => writes.WaitForChannelAsync( new string('x', 4_097), cancellationToken: TestContext.Current.CancellationToken)); From a62e956c09362808ca10988edfed40fcae9bb020 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 15:39:14 -0500 Subject: [PATCH 074/129] Waiting(docs[channels]): Warn that withdrawal wakes every waiter why: A signal wakes all of a channel's waiters and tmux cannot deregister one on its own, so two open waits on one channel complete each other. That is a property of tmux, not of this type, and nothing said so. what: - say withdrawal completes any other wait open on the same channel - state the one-open-wait-per-channel rule that follows from it --- src/LibTmux/Waiting/TmuxWaitChannel.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/LibTmux/Waiting/TmuxWaitChannel.cs b/src/LibTmux/Waiting/TmuxWaitChannel.cs index 94a4ec7..7b74815 100644 --- a/src/LibTmux/Waiting/TmuxWaitChannel.cs +++ b/src/LibTmux/Waiting/TmuxWaitChannel.cs @@ -101,6 +101,11 @@ public async Task WaitAsync( /// no waiter is left to take it. That leaves the channel pending rather /// than empty — an extra wake for the next caller, never a lost one. /// + /// + /// A signal wakes every waiter on the channel and tmux offers no way to + /// deregister one on its own, so withdrawing here also completes any other + /// wait open on the same channel. Keep one open wait per channel. + /// /// public async ValueTask DisposeAsync() { From 25d2de3bce3b5579c46c2c8d995221317b653659 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 15:39:49 -0500 Subject: [PATCH 075/129] Jobs(refactor[disposal]): Drop the blocking dispose why: JobStore.Dispose blocked on DisposeAsync, and shutting the store down waits for tmux watchers to finish. Blocking on that wait is what deadlocks a caller holding a single-threaded context, and it was the one synchronous seam in a package that is otherwise asynchronous throughout. what: - implement IAsyncDisposable alone and say a host must await disposal - dispose the store asynchronously in the MCP tool fixture --- src/LibTmux.Mcp/Jobs/JobStore.cs | 15 +++++++++++---- .../Mcp/McpToolFixture.cs | 2 +- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/LibTmux.Mcp/Jobs/JobStore.cs b/src/LibTmux.Mcp/Jobs/JobStore.cs index 8f49f68..4a8e091 100644 --- a/src/LibTmux.Mcp/Jobs/JobStore.cs +++ b/src/LibTmux.Mcp/Jobs/JobStore.cs @@ -24,9 +24,13 @@ namespace LibTmux.Mcp; /// if this server is restarted the command carries on, and only the handle is /// lost. /// +/// +/// The store is asynchronous all the way down, disposal included. Whatever +/// owns one disposes it with await using. +/// /// [UnsupportedOSPlatform("windows")] -public sealed class JobStore : IDisposable, IAsyncDisposable +public sealed class JobStore : IAsyncDisposable { internal const int Capacity = 100; internal const string RecoveryJobIdDataKey = "LibTmux.Mcp.JobId"; @@ -46,9 +50,12 @@ public sealed class JobStore : IDisposable, IAsyncDisposable public JobStore(ILogger? logger = null) => _logger = logger; /// - public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult(); - - /// + /// + /// Shutting down waits for tmux watchers to finish, so there is no + /// synchronous disposal to offer: blocking on that wait is what deadlocks + /// a caller holding a single-threaded context. A container holding this + /// has to be disposed with DisposeAsync. + /// public ValueTask DisposeAsync() { lock (_gate) diff --git a/tests/LibTmux.IntegrationTests/Mcp/McpToolFixture.cs b/tests/LibTmux.IntegrationTests/Mcp/McpToolFixture.cs index fd09489..5035864 100644 --- a/tests/LibTmux.IntegrationTests/Mcp/McpToolFixture.cs +++ b/tests/LibTmux.IntegrationTests/Mcp/McpToolFixture.cs @@ -77,7 +77,7 @@ internal static McpToolFixture Create(ServerPolicy? policy = null) public async ValueTask DisposeAsync() { await Activity.DisposeAsync().ConfigureAwait(false); - Jobs.Dispose(); + await Jobs.DisposeAsync().ConfigureAwait(false); Connection.Dispose(); } } From bbbceb86d70d0da420651d8e39e54321a93e380e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 15:46:01 -0500 Subject: [PATCH 076/129] Engineering(build[python]): Lock what the validators run on why: The engineering gate named pytest and tomlkit on the command line, so every run resolved whatever had released that morning and an unrelated upload could turn the gate red. The .NET side has been restored against lock files all along. what: - add a PEP 723 test runner with a lock beside it, and run it with --locked - lock mcp_swap's tomlkit requirement the same way - say in CONTRIBUTING how to refresh a script lock, and which checkout the parity tests need before they can pass --- .github/CONTRIBUTING.md | 20 ++++- .github/workflows/dotnet.yml | 9 +- eng/mcp/mcp_swap.py.lock | 19 +++++ eng/run_tests.py | 50 +++++++++++ eng/run_tests.py.lock | 160 +++++++++++++++++++++++++++++++++++ 5 files changed, 253 insertions(+), 5 deletions(-) create mode 100644 eng/mcp/mcp_swap.py.lock create mode 100644 eng/run_tests.py create mode 100644 eng/run_tests.py.lock diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 4b07255..9f5d491 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -25,7 +25,25 @@ from `global-json-file` and calls `dotnet` directly, so a workflow file never carries the prefix. The validators are Python and run through [uv](https://docs.astral.sh/uv/). -There is no Python project file — each script carries its own PEP 723 header. +There is no Python project file — each script carries its own PEP 723 header, +and a script with dependencies carries a `.lock` beside it so the gate resolves +the same versions every run: + +```console +$ uv lock --script eng/run_tests.py +``` + +Run the engineering tests through that locked runner rather than naming pytest +on the command line, which resolves whatever released that morning: + +```console +$ uv run --locked --script eng/run_tests.py +``` + +Nine of those tests read a pinned revision of the Python library. Point +`LIBTMUX_PYTHON_REPOSITORY` at a checkout of +[tmux-python/libtmux](https://github.com/tmux-python/libtmux) that contains it, +or they fail saying which revision they wanted. You also need a real `tmux`, version 3.2a or newer. The suite drives one rather than mocking it, because this library's job is being right about tmux diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 7625f84..f8fd33d 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -176,10 +176,11 @@ jobs: - name: Engineering tests env: LIBTMUX_PYTHON_REPOSITORY: ${{ runner.temp }}/python-libtmux - # tomlkit is what mcp_swap edits Codex and Grok configs with, so its - # tests need it too. There is no Python project file here to declare - # it in — the scripts carry their own PEP 723 headers. - run: uv run --with pytest --with tomlkit python -m pytest eng --quiet + # Naming pytest and tomlkit on the command line resolved whatever + # released that morning, so an unrelated release could turn this red. + # The runner declares them in its own PEP 723 header and pins them in + # the lock beside it; --locked refuses to run against a stale one. + run: uv run --locked --script eng/run_tests.py --quiet - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 # A run that failed before packing has nothing to upload, and saying so diff --git a/eng/mcp/mcp_swap.py.lock b/eng/mcp/mcp_swap.py.lock new file mode 100644 index 0000000..8f52a01 --- /dev/null +++ b/eng/mcp/mcp_swap.py.lock @@ -0,0 +1,19 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[options] +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer-span = "P3D" + +[manifest] +requirements = [{ name = "tomlkit", specifier = ">=0.13" }] + +[[package]] +name = "tomlkit" +version = "0.15.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/96/e07752635b98536177fa1f37671c8f3cdde2e724c6bcf6034b2cfb571565/tomlkit-0.15.1.tar.gz", hash = "sha256:e25bbf38843005246210a12982776f27f99cb9be67160e14434d0c0d21ee1e97", size = 180129, upload-time = "2026-07-17T01:48:04.562Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/bc/8c13eb66537dce1d2bd3a57132902f38d0e7f5bb46fa9f4daed9fe9d76ee/tomlkit-0.15.1-py3-none-any.whl", hash = "sha256:177a05aece5a8ca5266fd3c448abb47b8d352f09d477d3ca8332db4d89b24304", size = 49449, upload-time = "2026-07-17T01:48:05.728Z" }, +] diff --git a/eng/run_tests.py b/eng/run_tests.py new file mode 100644 index 0000000..559534a --- /dev/null +++ b/eng/run_tests.py @@ -0,0 +1,50 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.10" +# dependencies = ["pytest>=8.3", "tomlkit>=0.13"] +# /// +"""Run the engineering test suite against locked dependencies. + +The validators are the gate for the parity, capability, API and MCP +documents, so what they run against has to be as fixed as the .NET side +already is. Naming pytest on the command line resolves whatever version +exists that morning, which turns an unrelated release into a red gate. + +The lock beside this file pins that resolution. Refresh it deliberately: + +```console +$ uv lock --script eng/run_tests.py +``` + +Arguments go through to pytest: + +```console +$ uv run eng/run_tests.py -k swap +``` +""" + +from __future__ import annotations + +import pathlib +import sys + +import pytest + + +def main(argv: list[str] | None = None) -> int: + """Run pytest over ``eng`` and answer its exit code.""" + arguments = sys.argv[1:] if argv is None else argv + engineering = pathlib.Path(__file__).resolve().parent + + # Some tests import their subject as ``eng.`` rather than loading it + # by path, which needs the repository root importable. Running pytest as a + # module got that from the working directory; a script has to say it. + root = str(engineering.parent) + if root not in sys.path: + sys.path.insert(0, root) + + return int(pytest.main([str(engineering), *arguments])) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/eng/run_tests.py.lock b/eng/run_tests.py.lock new file mode 100644 index 0000000..807a875 --- /dev/null +++ b/eng/run_tests.py.lock @@ -0,0 +1,160 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[options] +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer-span = "P3D" + +[manifest] +requirements = [ + { name = "pytest", specifier = ">=8.3" }, + { name = "tomlkit", specifier = ">=0.13" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "tomlkit" +version = "0.15.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/96/e07752635b98536177fa1f37671c8f3cdde2e724c6bcf6034b2cfb571565/tomlkit-0.15.1.tar.gz", hash = "sha256:e25bbf38843005246210a12982776f27f99cb9be67160e14434d0c0d21ee1e97", size = 180129, upload-time = "2026-07-17T01:48:04.562Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/bc/8c13eb66537dce1d2bd3a57132902f38d0e7f5bb46fa9f4daed9fe9d76ee/tomlkit-0.15.1-py3-none-any.whl", hash = "sha256:177a05aece5a8ca5266fd3c448abb47b8d352f09d477d3ca8332db4d89b24304", size = 49449, upload-time = "2026-07-17T01:48:05.728Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] From 6a70916305f4c941eca6058648d326cfab78a315 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 15:52:20 -0500 Subject: [PATCH 077/129] Compatibility(refactor[versions]): Decide the version list once why: The same eight tmux versions were written out in the workflow matrix, two shell scripts, two validators, the runtime constants and six documents. Adding 3.7c meant finding all of them, and the copy that got missed stayed invisible until a lane that never ran let something through. what: - add eng/tmux/versions.json as the one place the list is decided - read it in verify_workflows and the evidence validator - report every list that has not caught up, naming the file and the rendering it is missing - say in CONTRIBUTING that a new version starts in the manifest --- .github/CONTRIBUTING.md | 10 ++ .github/workflows/dotnet.yml | 1 + eng/evidence/validate.py | 20 ++-- eng/parity/tests/test_tmux_versions.py | 90 ++++++++++++++++++ eng/parity/tests/test_workflows.py | 14 ++- eng/parity/verify_tmux_versions.py | 125 +++++++++++++++++++++++++ eng/parity/verify_workflows.py | 24 ++--- eng/tmux/versions.json | 17 ++++ 8 files changed, 279 insertions(+), 22 deletions(-) create mode 100644 eng/parity/tests/test_tmux_versions.py create mode 100644 eng/parity/verify_tmux_versions.py create mode 100644 eng/tmux/versions.json diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 9f5d491..6b6df55 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -320,6 +320,16 @@ either of those changes what a user sees on their own screen. **A behaviour change needs a test against a real tmux.** This library's job is being right about tmux, and only tmux can say whether it is. +**A new tmux version starts in the manifest.** +[`eng/tmux/versions.json`](../eng/tmux/versions.json) decides which versions +this repository supports. The workflow matrix, both build scripts, the runtime +constants and every README repeat that list, and +`eng/parity/verify_tmux_versions.py` names each one that has not caught up: + +```console +$ uv run python eng/parity/verify_tmux_versions.py +``` + **A version-dependent behaviour needs a row in the ledger.** Anything that differs between 3.2a and 3.7c goes through the capability model, and each difference names the test that proves it in diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index f8fd33d..bdd3d14 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -163,6 +163,7 @@ jobs: uv run python eng/parity/render_public_api.py --check uv run python eng/parity/verify_capabilities.py uv run python eng/parity/verify_workflows.py + uv run python eng/parity/verify_tmux_versions.py uv run python eng/docs/render_api_reference.py --check uv run python eng/docs/sync_snippets.py --check uv run eng/mcp/dump_tools.py --check diff --git a/eng/evidence/validate.py b/eng/evidence/validate.py index 03d2af4..102ffd7 100644 --- a/eng/evidence/validate.py +++ b/eng/evidence/validate.py @@ -17,16 +17,16 @@ import sys import typing as t -REQUIRED_TMUX_VERSIONS = ( - "3.2a", - "3.3a", - "3.4", - "3.5", - "3.6", - "3.7a", - "3.7b", - "3.7c", -) +#: Read rather than repeated, so a new version is decided in one place. +TMUX_VERSION_MANIFEST = pathlib.Path(__file__).parents[1] / "tmux" / "versions.json" + + +def _supported_tmux_versions() -> tuple[str, ...]: + with TMUX_VERSION_MANIFEST.open(encoding="utf-8") as handle: + return tuple(json.load(handle)["supported"]) + + +REQUIRED_TMUX_VERSIONS = _supported_tmux_versions() LEGACY_REQUIRED_TMUX_VERSIONS = REQUIRED_TMUX_VERSIONS[:-1] KNOWN_REQUIRED_TMUX_VERSION_SETS = { LEGACY_REQUIRED_TMUX_VERSIONS, diff --git a/eng/parity/tests/test_tmux_versions.py b/eng/parity/tests/test_tmux_versions.py new file mode 100644 index 0000000..61d05fb --- /dev/null +++ b/eng/parity/tests/test_tmux_versions.py @@ -0,0 +1,90 @@ +"""Prove the version check notices a list that stopped agreeing.""" + +from __future__ import annotations + +import json +import pathlib +import runpy +import typing as t + + +def load_checker() -> dict[str, t.Any]: + """Load the version check as an import-free test namespace.""" + return runpy.run_path( + str(pathlib.Path(__file__).parents[1] / "verify_tmux_versions.py") + ) + + +def verify(root: pathlib.Path) -> list[str]: + """Run the version check against one repository root.""" + checked: list[str] = load_checker()["verify"](root) + return checked + + +def write(root: pathlib.Path, supported: list[str]) -> pathlib.Path: + """Lay out a repository whose every version list agrees.""" + checker = load_checker() + transition = "3.7" + manifest = { + "schemaVersion": 1, + "minimum": supported[0], + "maximumTested": supported[-1], + "transition": transition, + "supported": supported, + } + files = { + "eng/tmux/versions.json": json.dumps(manifest), + ".github/workflows/dotnet-tmux.yml": "tmux: [{}]".format( + ", ".join(f"'{version}'" for version in supported) + ), + "eng/tmux/run-matrix.sh": "REQUIRED_VERSIONS=({})\ntmuxVersions:[{}]".format( + " ".join(supported), + ",".join(f'"{version}"' for version in supported), + ), + "eng/tmux/build-version.sh": "<{}|master>".format( + "|".join(checker["buildable"](supported, transition)) + ), + } + both = f"{supported[0]} {supported[-1]}" + for named in ( + "src/LibTmux/Constants/TmuxConstants.cs", + "README.md", + "src/LibTmux/README.md", + "src/LibTmux.Mcp/README.md", + "docs/README.md", + ): + files[named] = both + files[".github/CONTRIBUTING.md"] = supported[0] + files[".github/ISSUE_TEMPLATE/bug_report.yml"] = supported[-1] + + for relative, content in files.items(): + path = root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + return root + + +VERSIONS = ["3.2a", "3.3a", "3.4", "3.5", "3.6", "3.7a", "3.7b", "3.7c"] + + +def test_the_repository_agrees_with_its_manifest() -> None: + """The check has to pass on the tree it ships in, or it says nothing.""" + assert verify(pathlib.Path(__file__).parents[3]) == [] + + +def test_a_version_no_consumer_carries_is_reported(tmp_path: pathlib.Path) -> None: + """Adding a version to the manifest alone is exactly what this catches.""" + root = write(tmp_path, [*VERSIONS, "3.8"]) + (root / "eng" / "tmux" / "run-matrix.sh").write_text( + "REQUIRED_VERSIONS=({})".format(" ".join(VERSIONS)), encoding="utf-8" + ) + + assert [line for line in verify(root) if "run-matrix.sh" in line] == [ + "eng/tmux/run-matrix.sh: does not carry REQUIRED_VERSIONS=({})".format( + " ".join([*VERSIONS, "3.8"]) + ), + 'eng/tmux/run-matrix.sh: does not carry tmuxVersions:[{}]'.format( + ",".join(f'"{version}"' for version in [*VERSIONS, "3.8"]) + ), + ] diff --git a/eng/parity/tests/test_workflows.py b/eng/parity/tests/test_workflows.py index dbc4045..3ee4b78 100644 --- a/eng/parity/tests/test_workflows.py +++ b/eng/parity/tests/test_workflows.py @@ -2,8 +2,10 @@ from __future__ import annotations +import json import pathlib import runpy +import shutil import typing as t import pytest @@ -16,7 +18,11 @@ def load_checker() -> dict[str, t.Any]: ) -SUPPORTED_TMUX_VERSIONS: tuple[str, ...] = load_checker()["SUPPORTED_TMUX_VERSIONS"] +REPOSITORY_ROOT = pathlib.Path(__file__).parents[3] +MANIFEST = REPOSITORY_ROOT / "eng" / "tmux" / "versions.json" +SUPPORTED_TMUX_VERSIONS: tuple[str, ...] = tuple( + json.loads(MANIFEST.read_text(encoding="utf-8"))["supported"] +) def verify(root: pathlib.Path) -> list[str]: @@ -110,6 +116,12 @@ def write( (workflows / "dotnet.yml").write_text(build, encoding="utf-8") (workflows / "dotnet-tmux.yml").write_text(matrix, encoding="utf-8") (workflows / "release.yml").write_text(release, encoding="utf-8") + + # The check measures a root against that root's own version manifest, so a + # laid-out repository needs one. + manifest = root / "eng" / "tmux" / "versions.json" + manifest.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(MANIFEST, manifest) return root diff --git a/eng/parity/verify_tmux_versions.py b/eng/parity/verify_tmux_versions.py new file mode 100644 index 0000000..34f9c9e --- /dev/null +++ b/eng/parity/verify_tmux_versions.py @@ -0,0 +1,125 @@ +"""Check every list of supported tmux versions against the one manifest. + +The same eight versions were written out in the workflow matrix, two shell +scripts, two validators, the runtime constants and six documents. Adding one +meant finding all of them, and the version that got missed was invisible until +a lane that never ran let a regression through. + +`eng/tmux/versions.json` is now the only place the list is decided. Nothing +here rewrites a consumer: a build script that had to parse JSON before it could +build tmux would be worse than one that repeats a list a gate checks. +""" + +from __future__ import annotations + +import argparse +import json +import pathlib +import sys +import typing as t + +MANIFEST = pathlib.Path("eng/tmux/versions.json") + + +def load(root: pathlib.Path) -> dict[str, t.Any]: + """Read the manifest every other list is measured against.""" + with (root / MANIFEST).open(encoding="utf-8") as handle: + return t.cast(dict[str, t.Any], json.load(handle)) + + +def buildable(supported: list[str], transition: str) -> list[str]: + """Order what build-version.sh accepts: the transition before its suffixes. + + tmux 3.7 is buildable but never tested on its own — the matrix runs its + lettered successors. It sits with them rather than at the end. + """ + if transition in supported: + return list(supported) + successor = next( + (each for each in supported if each.startswith(transition)), + None, + ) + if successor is None: + return [*supported, transition] + at = supported.index(successor) + return [*supported[:at], transition, *supported[at:]] + + +def verify(root: pathlib.Path) -> list[str]: + """Report every list that disagrees with the manifest.""" + manifest = load(root) + supported: list[str] = manifest["supported"] + minimum: str = manifest["minimum"] + newest: str = manifest["maximumTested"] + violations: list[str] = [] + + if supported[0] != minimum: + violations.append(f"{MANIFEST}: minimum {minimum} is not the first supported") + if supported[-1] != newest: + violations.append(f"{MANIFEST}: maximumTested {newest} is not the last supported") + + def read(relative: str) -> str | None: + path = root / relative + if not path.is_file(): + violations.append(f"{relative}: missing, so its versions cannot be checked") + return None + return path.read_text(encoding="utf-8") + + # Lists written out in full. Each is a literal a person edits, so the check + # is that the exact rendering the file uses is present and complete. + renderings: tuple[tuple[str, str], ...] = ( + (".github/workflows/dotnet-tmux.yml", "tmux: [{}]".format( + ", ".join(f"'{version}'" for version in supported))), + ("eng/tmux/run-matrix.sh", "REQUIRED_VERSIONS=({})".format( + " ".join(supported))), + ("eng/tmux/run-matrix.sh", 'tmuxVersions:[{}]'.format( + ",".join(f'"{version}"' for version in supported))), + ("eng/tmux/build-version.sh", "<{}|master>".format( + "|".join(buildable(supported, manifest["transition"])))), + ) + for relative, rendering in renderings: + text = read(relative) + if text is not None and rendering not in text: + violations.append(f"{relative}: does not carry {rendering}") + + # The floor and the newest tested version are what the prose and the + # runtime promise. A document naming a different one is a false claim. + claims: tuple[tuple[str, tuple[str, ...]], ...] = ( + ("src/LibTmux/Constants/TmuxConstants.cs", (minimum, newest)), + ("README.md", (minimum, newest)), + ("src/LibTmux/README.md", (minimum, newest)), + ("src/LibTmux.Mcp/README.md", (minimum, newest)), + ("docs/README.md", (minimum, newest)), + (".github/CONTRIBUTING.md", (minimum,)), + (".github/ISSUE_TEMPLATE/bug_report.yml", (newest,)), + ) + for relative, expected in claims: + text = read(relative) + if text is None: + continue + for version in expected: + if version not in text: + violations.append(f"{relative}: does not name tmux {version}") + + return violations + + +def main(argv: list[str] | None = None) -> int: + """Report whether every version list agrees with the manifest.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--root", + type=pathlib.Path, + default=pathlib.Path(__file__).resolve().parents[2], + help="the repository root holding eng/tmux/versions.json", + ) + arguments = parser.parse_args(argv) + violations = verify(arguments.root) + for violation in violations: + print(violation) + + return 1 if violations else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/eng/parity/verify_workflows.py b/eng/parity/verify_workflows.py index 03d4a9c..f770173 100644 --- a/eng/parity/verify_workflows.py +++ b/eng/parity/verify_workflows.py @@ -9,19 +9,21 @@ from __future__ import annotations import argparse +import json import pathlib import sys -SUPPORTED_TMUX_VERSIONS = ( - "3.2a", - "3.3a", - "3.4", - "3.5", - "3.6", - "3.7a", - "3.7b", - "3.7c", -) +#: Read rather than repeated. eng/parity/verify_tmux_versions.py checks the +#: lists that cannot read it, such as the workflow matrix this one inspects. +TMUX_VERSION_MANIFEST = pathlib.Path("eng/tmux/versions.json") + + +def supported_tmux_versions(root: pathlib.Path) -> tuple[str, ...]: + """Answer the tmux versions the repository claims to support.""" + with (root / TMUX_VERSION_MANIFEST).open(encoding="utf-8") as handle: + return tuple(json.load(handle)["supported"]) + + TARGET_FRAMEWORKS = ("net8.0", "net10.0") #: Checks a change has to pass locally. A workflow missing one of these would @@ -91,7 +93,7 @@ def verify(root: pathlib.Path) -> list[str]: ) violations.extend( f"dotnet-tmux.yml omits tmux {version}" - for version in SUPPORTED_TMUX_VERSIONS + for version in supported_tmux_versions(root) if f"'{version}'" not in matrix_text ) violations.extend( diff --git a/eng/tmux/versions.json b/eng/tmux/versions.json new file mode 100644 index 0000000..d4b068b --- /dev/null +++ b/eng/tmux/versions.json @@ -0,0 +1,17 @@ +{ + "$comment": "The tmux versions this repository supports. Every list of them elsewhere is checked against this by eng/parity/verify_tmux_versions.py; add a version here first.", + "schemaVersion": 1, + "minimum": "3.2a", + "maximumTested": "3.7c", + "transition": "3.7", + "supported": [ + "3.2a", + "3.3a", + "3.4", + "3.5", + "3.6", + "3.7a", + "3.7b", + "3.7c" + ] +} From 412e48b7e1ead961302b05180b8457a0e71a5e79 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 16:05:48 -0500 Subject: [PATCH 078/129] Engineering(refactor[swap]): Separate JSONC from the swap why: mcp_swap carried a format-preserving JSONC editor inside a tool about MCP configuration. The editor knows nothing about MCP or any CLI, and burying a format in a policy is what made one file 2,615 lines long. what: - move the scanner, renderer and merge into eng/mcp/jsonc.py - name the three entry points for what they do rather than what they parse - load the sibling the way the script itself does, in the tests too --- eng/mcp/jsonc.py | 337 ++++++++++++++++++++++++++++++++ eng/mcp/mcp_swap.py | 341 +-------------------------------- eng/mcp/tests/test_mcp_swap.py | 50 ++--- 3 files changed, 374 insertions(+), 354 deletions(-) create mode 100644 eng/mcp/jsonc.py diff --git a/eng/mcp/jsonc.py b/eng/mcp/jsonc.py new file mode 100644 index 0000000..e4f599f --- /dev/null +++ b/eng/mcp/jsonc.py @@ -0,0 +1,337 @@ +"""Edit JSONC without reserializing it. + +A config with comments and trailing commas belongs to the person who wrote +it, and no PyPI library round-trips that format without losing the parts +JSON has no room for. So edits are applied as text splices located by an +offset-preserving scanner: everything the edit did not name comes out byte +for byte as it went in. + +This knows nothing about MCP or about any particular CLI. It is here rather +than inside the swap because it is a format, not a policy. +""" + +from __future__ import annotations + +import json +import typing as t + +_JSON_WS = " \t\n\r" + +#: Longest inline rendering of a scalar list before it is broken across +#: lines. A swapped ``command`` array is the common case and reads +#: better on one line, which is how these configs are written by hand. +_INLINE_WIDTH = 88 + + +def blank_comments(text: str) -> str: + """Replace comment bytes with spaces, preserving every offset. + + Scanning rather than matching a regex is the whole point: ``//`` + inside a URL and ``/*`` inside a Windows path are string content, not + comments, and only a scanner that tracks string state can tell them + apart. Offsets are preserved so a span found in the blanked text + addresses the same bytes in the original. + """ + out = list(text) + i, n = 0, len(text) + in_string = False + while i < n: + char = text[i] + if in_string: + if char == "\\": + i += 2 + continue + if char == '"': + in_string = False + i += 1 + elif char == '"': + in_string = True + i += 1 + elif char == "/" and i + 1 < n and text[i + 1] == "/": + while i < n and text[i] != "\n": + out[i] = " " + i += 1 + elif char == "/" and i + 1 < n and text[i + 1] == "*": + end = text.find("*/", i + 2) + end = n if end == -1 else end + 2 + for j in range(i, end): + if out[j] != "\n": + out[j] = " " + i = end + else: + i += 1 + return "".join(out) + + +def blank_trailing_commas(blanked: str) -> str: + """Blank trailing commas so stdlib :func:`json.loads` accepts the text.""" + out = list(blanked) + i, n = 0, len(blanked) + in_string = False + last_comma = -1 + while i < n: + char = blanked[i] + if in_string: + if char == "\\": + i += 2 + continue + if char == '"': + in_string = False + i += 1 + continue + if char == '"': + in_string = True + last_comma = -1 + elif char == ",": + last_comma = i + elif char in "}]": + if last_comma != -1: + out[last_comma] = " " + last_comma = -1 + elif char not in _JSON_WS: + last_comma = -1 + i += 1 + return "".join(out) + + +def loads(text: str) -> t.Any: + """Parse JSONC text into plain Python objects.""" + if not text.strip(): + return {} + return json.loads(blank_trailing_commas(blank_comments(text))) + + +class _JsoncScanner: + """Locate value spans inside comment-blanked JSON text.""" + + def __init__(self, text: str) -> None: + self.text = text + self.pos = 0 + + def skip_ws(self) -> None: + """Advance past insignificant whitespace.""" + while self.pos < len(self.text) and self.text[self.pos] in _JSON_WS: + self.pos += 1 + + def read_string(self) -> str: + """Consume one string token and return its raw text, quotes included.""" + start = self.pos + self.pos += 1 + while self.pos < len(self.text): + char = self.text[self.pos] + if char == "\\": + self.pos += 2 + continue + self.pos += 1 + if char == '"': + break + return self.text[start : self.pos] + + def read_value(self) -> tuple[int, int]: + """Consume one value and return its ``(start, end)`` span.""" + self.skip_ws() + start = self.pos + char = self.text[self.pos] + if char == '"': + self.read_string() + elif char in "{[": + self._read_container() + else: + while ( + self.pos < len(self.text) + and self.text[self.pos] not in ",}]" + and self.text[self.pos] not in _JSON_WS + ): + self.pos += 1 + return start, self.pos + + def _read_container(self) -> None: + self.pos += 1 + depth = 1 + while self.pos < len(self.text) and depth: + char = self.text[self.pos] + if char == '"': + self.read_string() + continue + if char in "{[": + depth += 1 + elif char in "}]": + depth -= 1 + self.pos += 1 + + def read_members(self, obj_start: int) -> list[_JsoncMember]: + """Enumerate an object's members. ``obj_start`` indexes its ``{``.""" + self.pos = obj_start + 1 + found: list[_JsoncMember] = [] + while True: + self.skip_ws() + if self.pos >= len(self.text) or self.text[self.pos] == "}": + return found + if self.text[self.pos] == ",": + self.pos += 1 + continue + member_start = self.pos + raw_key = self.read_string() + self.skip_ws() + self.pos += 1 # the ':' + value_start, value_end = self.read_value() + found.append( + _JsoncMember( + key=json.loads(raw_key), + start=member_start, + end=value_end, + value_start=value_start, + value_end=value_end, + ) + ) + + +class _JsoncMember(t.NamedTuple): + """One ``"key": value`` pair located inside a JSONC document. + + Attributes + ---------- + key : str + The decoded member name. + start : int + Offset of the opening quote of the key. + end : int + Offset just past the value — the end of the whole member. + value_start : int + Offset of the first byte of the value. + value_end : int + Offset just past the last byte of the value. + """ + + key: str + start: int + end: int + value_start: int + value_end: int + + +def _render(value: t.Any, depth: int, *, ensure_ascii: bool) -> str: + """Render ``value`` as JSON text indented for nesting ``depth``.""" + pad = " " * depth + if isinstance(value, list) and all( + isinstance(item, (str, int, float, bool)) or item is None for item in value + ): + inline = json.dumps(value, ensure_ascii=ensure_ascii) + if len(inline) + len(pad) <= _INLINE_WIDTH: + return inline + return json.dumps(value, indent=2, ensure_ascii=ensure_ascii).replace( + "\n", "\n" + pad + ) + + +def _object_span(blanked: str, path: tuple[str, ...]) -> tuple[int, int] | None: + """Return the span of the object reached by ``path``, or ``None``.""" + scanner = _JsoncScanner(blanked) + scanner.skip_ws() + if scanner.pos >= len(blanked) or blanked[scanner.pos] != "{": + return None + cursor = scanner.pos + for key in path: + match = next( + (m for m in _JsoncScanner(blanked).read_members(cursor) if m.key == key), + None, + ) + if match is None or blanked[match.value_start] != "{": + return None + cursor = match.value_start + tail = _JsoncScanner(blanked) + tail.pos = cursor + return tail.read_value() + + +def _next_edit( + text: str, + data: t.Mapping[str, t.Any], + path: tuple[str, ...], + *, + ensure_ascii: bool, +) -> tuple[int, int, str] | None: + """Find the one next splice that brings ``path`` closer to ``data``.""" + blanked = blank_comments(text) + span = _object_span(blanked, path) + if span is None: + return None + obj_start, obj_end = span + members = _JsoncScanner(blanked).read_members(obj_start) + by_key = {member.key: member for member in members} + depth = len(path) + 1 + pad = " " * depth + + for key, value in data.items(): + member = by_key.get(key) + if member is None: + body = _render(value, depth, ensure_ascii=ensure_ascii) + # Escape the key like any other value: written raw, a backslash + # or quote in a server name emits text that cannot be parsed + # back, so the member is never found and the merge re-inserts + # it until the pass ceiling, holding the swap lock throughout. + name = json.dumps(key, ensure_ascii=ensure_ascii) + if members: + tail = members[-1].end + return tail, tail, f",\n{pad}{name}: {body}" + if blanked[obj_start + 1 : obj_end - 1].strip(): + return None + # Blanking hid any comment the object holds, so measure the + # interior in the original text and splice after it, not over it. + interior = text[obj_start + 1 : obj_end - 1] + anchor = obj_start + 1 + len(interior.rstrip()) + closing = " " * (depth - 1) + return anchor, obj_end - 1, f"\n{pad}{name}: {body}\n{closing}" + current = json.loads( + blank_trailing_commas(blanked[member.value_start : member.value_end]) + ) + if isinstance(value, dict) and isinstance(current, dict): + nested = _next_edit( + text, value, (*path, key), ensure_ascii=ensure_ascii + ) + if nested is not None: + return nested + elif current != value: + return ( + member.value_start, + member.value_end, + _render(value, depth, ensure_ascii=ensure_ascii), + ) + + for index, member in enumerate(members): + if member.key in data: + continue + # Exactly one delimiter leaves with the member: the comma before + # it, or, for the first member which has none, the comma after. + if index: + return members[index - 1].end, member.end, "" + # Read that comma out of the blanked text -- one inside a comment + # is not a delimiter, and a real one behind a comment still is. + trailing = blanked[member.end : obj_end] + drop_to = member.end + if trailing.lstrip(_JSON_WS).startswith(","): + drop_to += trailing.index(",") + 1 + return obj_start + 1, drop_to, "" + return None + + +def merge(text: str, data: t.Mapping[str, t.Any], *, ensure_ascii: bool) -> str: + """Reconcile ``data`` into ``text``, rewriting only members that differ. + + Applies one splice at a time and rescans, so offsets are always + computed against current text rather than patched up after the fact. + Config files are small enough that the extra passes do not matter and + the invariant is worth far more than the cycles. + """ + if not text.strip(): + return json.dumps(dict(data), indent=2, ensure_ascii=ensure_ascii) + "\n" + # One splice per member, plus slack; a config that needs more than + # this has a pathology worth surfacing rather than looping on. + for _ in range(10_000): + edit = _next_edit(text, data, (), ensure_ascii=ensure_ascii) + if edit is None: + return text + start, end, replacement = edit + text = text[:start] + replacement + text[end:] + msg = "JSONC merge did not converge" + raise RuntimeError(msg) diff --git a/eng/mcp/mcp_swap.py b/eng/mcp/mcp_swap.py index 9d8efc2..523f6b4 100755 --- a/eng/mcp/mcp_swap.py +++ b/eng/mcp/mcp_swap.py @@ -133,6 +133,12 @@ import tomlkit import tomlkit.items +# A sibling module, not a package: this file runs as a script, so its own +# directory is what Python imports from. +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) + +import jsonc # noqa: E402 + CLIName = t.Literal[ "claude", "codex", "cursor", "gemini", "grok", "agy", "opencode", "pi" ] @@ -484,335 +490,6 @@ class SwapEntry: class SwapStateError(RuntimeError): """Swap state is unsafe to use for a mutating operation.""" - -# --------------------------------------------------------------------------- -# JSONC — comments and trailing commas, edited without reserializing -# --------------------------------------------------------------------------- -# No PyPI library gives JSONC a format-preserving round trip; edits are -# applied as text splices via an offset-preserving scanner instead. - -_JSON_WS = " \t\n\r" - -#: Longest inline rendering of a scalar list before it is broken across -#: lines. A swapped ``command`` array is the common case and reads -#: better on one line, which is how these configs are written by hand. -_INLINE_WIDTH = 88 - - -def _jsonc_blank_comments(text: str) -> str: - """Replace comment bytes with spaces, preserving every offset. - - Scanning rather than matching a regex is the whole point: ``//`` - inside a URL and ``/*`` inside a Windows path are string content, not - comments, and only a scanner that tracks string state can tell them - apart. Offsets are preserved so a span found in the blanked text - addresses the same bytes in the original. - """ - out = list(text) - i, n = 0, len(text) - in_string = False - while i < n: - char = text[i] - if in_string: - if char == "\\": - i += 2 - continue - if char == '"': - in_string = False - i += 1 - elif char == '"': - in_string = True - i += 1 - elif char == "/" and i + 1 < n and text[i + 1] == "/": - while i < n and text[i] != "\n": - out[i] = " " - i += 1 - elif char == "/" and i + 1 < n and text[i + 1] == "*": - end = text.find("*/", i + 2) - end = n if end == -1 else end + 2 - for j in range(i, end): - if out[j] != "\n": - out[j] = " " - i = end - else: - i += 1 - return "".join(out) - - -def _jsonc_blank_trailing_commas(blanked: str) -> str: - """Blank trailing commas so stdlib :func:`json.loads` accepts the text.""" - out = list(blanked) - i, n = 0, len(blanked) - in_string = False - last_comma = -1 - while i < n: - char = blanked[i] - if in_string: - if char == "\\": - i += 2 - continue - if char == '"': - in_string = False - i += 1 - continue - if char == '"': - in_string = True - last_comma = -1 - elif char == ",": - last_comma = i - elif char in "}]": - if last_comma != -1: - out[last_comma] = " " - last_comma = -1 - elif char not in _JSON_WS: - last_comma = -1 - i += 1 - return "".join(out) - - -def _jsonc_loads(text: str) -> t.Any: - """Parse JSONC text into plain Python objects.""" - if not text.strip(): - return {} - return json.loads(_jsonc_blank_trailing_commas(_jsonc_blank_comments(text))) - - -class _JsoncScanner: - """Locate value spans inside comment-blanked JSON text.""" - - def __init__(self, text: str) -> None: - self.text = text - self.pos = 0 - - def skip_ws(self) -> None: - """Advance past insignificant whitespace.""" - while self.pos < len(self.text) and self.text[self.pos] in _JSON_WS: - self.pos += 1 - - def read_string(self) -> str: - """Consume one string token and return its raw text, quotes included.""" - start = self.pos - self.pos += 1 - while self.pos < len(self.text): - char = self.text[self.pos] - if char == "\\": - self.pos += 2 - continue - self.pos += 1 - if char == '"': - break - return self.text[start : self.pos] - - def read_value(self) -> tuple[int, int]: - """Consume one value and return its ``(start, end)`` span.""" - self.skip_ws() - start = self.pos - char = self.text[self.pos] - if char == '"': - self.read_string() - elif char in "{[": - self._read_container() - else: - while ( - self.pos < len(self.text) - and self.text[self.pos] not in ",}]" - and self.text[self.pos] not in _JSON_WS - ): - self.pos += 1 - return start, self.pos - - def _read_container(self) -> None: - self.pos += 1 - depth = 1 - while self.pos < len(self.text) and depth: - char = self.text[self.pos] - if char == '"': - self.read_string() - continue - if char in "{[": - depth += 1 - elif char in "}]": - depth -= 1 - self.pos += 1 - - def read_members(self, obj_start: int) -> list[_JsoncMember]: - """Enumerate an object's members. ``obj_start`` indexes its ``{``.""" - self.pos = obj_start + 1 - found: list[_JsoncMember] = [] - while True: - self.skip_ws() - if self.pos >= len(self.text) or self.text[self.pos] == "}": - return found - if self.text[self.pos] == ",": - self.pos += 1 - continue - member_start = self.pos - raw_key = self.read_string() - self.skip_ws() - self.pos += 1 # the ':' - value_start, value_end = self.read_value() - found.append( - _JsoncMember( - key=json.loads(raw_key), - start=member_start, - end=value_end, - value_start=value_start, - value_end=value_end, - ) - ) - - -class _JsoncMember(t.NamedTuple): - """One ``"key": value`` pair located inside a JSONC document. - - Attributes - ---------- - key : str - The decoded member name. - start : int - Offset of the opening quote of the key. - end : int - Offset just past the value — the end of the whole member. - value_start : int - Offset of the first byte of the value. - value_end : int - Offset just past the last byte of the value. - """ - - key: str - start: int - end: int - value_start: int - value_end: int - - -def _jsonc_render(value: t.Any, depth: int, *, ensure_ascii: bool) -> str: - """Render ``value`` as JSON text indented for nesting ``depth``.""" - pad = " " * depth - if isinstance(value, list) and all( - isinstance(item, (str, int, float, bool)) or item is None for item in value - ): - inline = json.dumps(value, ensure_ascii=ensure_ascii) - if len(inline) + len(pad) <= _INLINE_WIDTH: - return inline - return json.dumps(value, indent=2, ensure_ascii=ensure_ascii).replace( - "\n", "\n" + pad - ) - - -def _jsonc_object_span(blanked: str, path: tuple[str, ...]) -> tuple[int, int] | None: - """Return the span of the object reached by ``path``, or ``None``.""" - scanner = _JsoncScanner(blanked) - scanner.skip_ws() - if scanner.pos >= len(blanked) or blanked[scanner.pos] != "{": - return None - cursor = scanner.pos - for key in path: - match = next( - (m for m in _JsoncScanner(blanked).read_members(cursor) if m.key == key), - None, - ) - if match is None or blanked[match.value_start] != "{": - return None - cursor = match.value_start - tail = _JsoncScanner(blanked) - tail.pos = cursor - return tail.read_value() - - -def _jsonc_next_edit( - text: str, - data: t.Mapping[str, t.Any], - path: tuple[str, ...], - *, - ensure_ascii: bool, -) -> tuple[int, int, str] | None: - """Find the one next splice that brings ``path`` closer to ``data``.""" - blanked = _jsonc_blank_comments(text) - span = _jsonc_object_span(blanked, path) - if span is None: - return None - obj_start, obj_end = span - members = _JsoncScanner(blanked).read_members(obj_start) - by_key = {member.key: member for member in members} - depth = len(path) + 1 - pad = " " * depth - - for key, value in data.items(): - member = by_key.get(key) - if member is None: - body = _jsonc_render(value, depth, ensure_ascii=ensure_ascii) - # Escape the key like any other value: written raw, a backslash - # or quote in a server name emits text that cannot be parsed - # back, so the member is never found and the merge re-inserts - # it until the pass ceiling, holding the swap lock throughout. - name = json.dumps(key, ensure_ascii=ensure_ascii) - if members: - tail = members[-1].end - return tail, tail, f",\n{pad}{name}: {body}" - if blanked[obj_start + 1 : obj_end - 1].strip(): - return None - # Blanking hid any comment the object holds, so measure the - # interior in the original text and splice after it, not over it. - interior = text[obj_start + 1 : obj_end - 1] - anchor = obj_start + 1 + len(interior.rstrip()) - closing = " " * (depth - 1) - return anchor, obj_end - 1, f"\n{pad}{name}: {body}\n{closing}" - current = json.loads( - _jsonc_blank_trailing_commas(blanked[member.value_start : member.value_end]) - ) - if isinstance(value, dict) and isinstance(current, dict): - nested = _jsonc_next_edit( - text, value, (*path, key), ensure_ascii=ensure_ascii - ) - if nested is not None: - return nested - elif current != value: - return ( - member.value_start, - member.value_end, - _jsonc_render(value, depth, ensure_ascii=ensure_ascii), - ) - - for index, member in enumerate(members): - if member.key in data: - continue - # Exactly one delimiter leaves with the member: the comma before - # it, or, for the first member which has none, the comma after. - if index: - return members[index - 1].end, member.end, "" - # Read that comma out of the blanked text -- one inside a comment - # is not a delimiter, and a real one behind a comment still is. - trailing = blanked[member.end : obj_end] - drop_to = member.end - if trailing.lstrip(_JSON_WS).startswith(","): - drop_to += trailing.index(",") + 1 - return obj_start + 1, drop_to, "" - return None - - -def _jsonc_merge(text: str, data: t.Mapping[str, t.Any], *, ensure_ascii: bool) -> str: - """Reconcile ``data`` into ``text``, rewriting only members that differ. - - Applies one splice at a time and rescans, so offsets are always - computed against current text rather than patched up after the fact. - Config files are small enough that the extra passes do not matter and - the invariant is worth far more than the cycles. - """ - if not text.strip(): - return json.dumps(dict(data), indent=2, ensure_ascii=ensure_ascii) + "\n" - # One splice per member, plus slack; a config that needs more than - # this has a pathology worth surfacing rather than looping on. - for _ in range(10_000): - edit = _jsonc_next_edit(text, data, (), ensure_ascii=ensure_ascii) - if edit is None: - return text - start, end, replacement = edit - text = text[:start] + replacement + text[end:] - msg = "JSONC merge did not converge" - raise RuntimeError(msg) - - # --------------------------------------------------------------------------- # Config IO — per format # --------------------------------------------------------------------------- @@ -826,7 +503,7 @@ def load_config(info: CLIInfo) -> t.Any: """ raw = info.config_path.read_bytes() if info.fmt == "jsonc": - return _jsonc_loads(raw.decode()) + return jsonc.loads(raw.decode()) if info.fmt == "json": text = raw.decode().strip() return json.loads(text) if text else {} @@ -868,9 +545,9 @@ def dump_config_bytes(info: CLIInfo, config: t.Any, *, original: bytes) -> bytes # and needs no _json_trailer fixup. source = original.decode() try: - return _jsonc_merge(source, config, ensure_ascii=False).encode() + return jsonc.merge(source, config, ensure_ascii=False).encode() except UnicodeEncodeError: - return _jsonc_merge(source, config, ensure_ascii=True).encode() + return jsonc.merge(source, config, ensure_ascii=True).encode() trailer = _json_trailer(original) # ensure_ascii would re-escape every non-ASCII character in the file, # including config text the swap never read. diff --git a/eng/mcp/tests/test_mcp_swap.py b/eng/mcp/tests/test_mcp_swap.py index 1d8829a..c4cd240 100644 --- a/eng/mcp/tests/test_mcp_swap.py +++ b/eng/mcp/tests/test_mcp_swap.py @@ -29,6 +29,12 @@ _SCRIPT = pathlib.Path(__file__).resolve().parents[1] / "mcp_swap.py" +# The script imports its siblings from its own directory, so a test loading it +# by path has to put that directory where Python will look. +sys.path.insert(0, str(_SCRIPT.parent)) + +import jsonc # noqa: E402 + _spec = importlib.util.spec_from_file_location("mcp_swap", _SCRIPT) assert _spec and _spec.loader mcp_swap = importlib.util.module_from_spec(_spec) @@ -3120,7 +3126,7 @@ def test_opencode_swap_preserves_jsonc_comments( assert "// header comment" in text assert "/* a block comment" in text assert "spanning lines */" in text - doc = mcp_swap._jsonc_loads(text) + doc = jsonc.loads(text) assert doc["model"] == "openrouter/x" assert doc["mcp"]["other"]["command"] == ["echo", "keep"] assert doc["mcp"]["tmux"]["command"][0].endswith("LibTmux.Mcp") @@ -3151,7 +3157,7 @@ def test_opencode_comment_inside_the_replaced_entry_survives( assert _swap_opencode(fake_repo) == 0 text = info.config_path.read_text() assert "// Pinned deliberately; this rationale must outlive the swap." in text - entry = mcp_swap._jsonc_loads(text)["mcp"]["tmux"] + entry = jsonc.loads(text)["mcp"]["tmux"] assert entry["command"][0].endswith("LibTmux.Mcp") assert entry["environment"] == _runtime_env(fake_repo) | {"KEEP": "me"} @@ -3202,7 +3208,7 @@ def test_opencode_seeds_schema_into_an_empty_config( """Seeding an empty file writes ``$schema`` alongside the server entry.""" info = _opencode_config(fake_home, "") assert _swap_opencode(fake_repo) == 0 - doc = mcp_swap._jsonc_loads(info.config_path.read_text()) + doc = jsonc.loads(info.config_path.read_text()) assert doc["$schema"] == mcp_swap.OPENCODE_SCHEMA_URL assert doc["mcp"]["tmux"]["type"] == "local" @@ -3223,7 +3229,7 @@ def test_opencode_symlinked_config_swap_updates_target_not_link( assert info.config_path.readlink() == target text = target.read_text() assert "// linked" in text - assert mcp_swap._jsonc_loads(text)["mcp"]["tmux"]["command"][0].endswith("LibTmux.Mcp") + assert jsonc.loads(text)["mcp"]["tmux"]["command"][0].endswith("LibTmux.Mcp") def test_pi_config_with_comments_is_readable( @@ -3247,7 +3253,7 @@ def test_pi_config_with_comments_is_readable( assert mcp_swap.cmd_use_local(args) == 0 text = info.config_path.read_text() assert "// the adapter allows comments" in text - servers = mcp_swap._jsonc_loads(text)["mcpServers"] + servers = jsonc.loads(text)["mcpServers"] assert servers["keep"]["command"] == "echo" assert servers["tmux"]["command"] == str( mcp_swap.profile_binary(fake_repo, "LibTmux.Mcp", "Debug") @@ -3360,7 +3366,7 @@ def test_jsonc_values_match_stdlib_json( expected = json.loads(body) except json.JSONDecodeError: pytest.skip("comment or trailing comma — stdlib cannot parse it") - assert mcp_swap._jsonc_loads(body) == expected + assert jsonc.loads(body) == expected def test_jsonc_config_is_not_written_through_the_toml_writer( @@ -3378,7 +3384,7 @@ def test_jsonc_config_is_not_written_through_the_toml_writer( ) text = out.decode() assert text.lstrip().startswith("{") - assert mcp_swap._jsonc_loads(text)["mcp"]["x"]["type"] == "local" + assert jsonc.loads(text)["mcp"]["x"]["type"] == "local" class JsoncDeletionCase(t.NamedTuple): @@ -3447,7 +3453,7 @@ def test_jsonc_merge_removing_a_member_takes_exactly_one_comma( inside a comment passes for the separator. """ assert test_id - assert mcp_swap._jsonc_merge(body, data, ensure_ascii=False) == expected + assert jsonc.merge(body, data, ensure_ascii=False) == expected @pytest.mark.parametrize( @@ -3462,10 +3468,10 @@ def test_jsonc_merge_escapes_an_inserted_key(name: str) -> None: spinning while holding the swap lock and then failing. """ src = '{\n "mcp": {}\n}\n' - data = mcp_swap._jsonc_loads(src) + data = jsonc.loads(src) data["mcp"][name] = {"type": "local"} - out = mcp_swap._jsonc_merge(src, data, ensure_ascii=False) - assert mcp_swap._jsonc_loads(out)["mcp"][name] == {"type": "local"} + out = jsonc.merge(src, data, ensure_ascii=False) + assert jsonc.loads(out)["mcp"][name] == {"type": "local"} def test_jsonc_merge_removing_a_middle_member_stays_parseable() -> None: @@ -3475,10 +3481,10 @@ def test_jsonc_merge_removing_a_middle_member_stays_parseable() -> None: ' "enabled": true,\n "timeout": 5000,\n' ' "command": ["uvx", "old"]\n }\n }\n}\n' ) - data = mcp_swap._jsonc_loads(src) + data = jsonc.loads(src) data["mcp"]["tmux"] = {"type": "local", "command": ["uv", "run", "x"]} - out = mcp_swap._jsonc_merge(src, data, ensure_ascii=False) - assert mcp_swap._jsonc_loads(out) == data + out = jsonc.merge(src, data, ensure_ascii=False) + assert jsonc.loads(out) == data def test_jsonc_merge_inserting_into_a_comment_only_object_keeps_the_comment() -> None: @@ -3489,9 +3495,9 @@ def test_jsonc_merge_inserting_into_a_comment_only_object_keeps_the_comment() -> whole interior and take the comment with it. """ src = '{\n "mcp": {\n // why there are no servers yet\n }\n}\n' - data = mcp_swap._jsonc_loads(src) + data = jsonc.loads(src) data["mcp"]["tmux"] = {"type": "local", "command": ["uv"]} - out = mcp_swap._jsonc_merge(src, data, ensure_ascii=False) + out = jsonc.merge(src, data, ensure_ascii=False) assert out == ( '{\n "mcp": {\n // why there are no servers yet\n' ' "tmux": {\n "type": "local",\n "command": [\n' @@ -3502,9 +3508,9 @@ def test_jsonc_merge_inserting_into_a_comment_only_object_keeps_the_comment() -> def test_jsonc_merge_inserting_into_a_comment_only_document_keeps_the_comment() -> None: """The same splice at the root, where there is no enclosing member.""" src = "{\n // root rationale\n}\n" - data = mcp_swap._jsonc_loads(src) + data = jsonc.loads(src) data["mcp"] = {} - out = mcp_swap._jsonc_merge(src, data, ensure_ascii=False) + out = jsonc.merge(src, data, ensure_ascii=False) assert out == '{\n // root rationale\n "mcp": {}\n}\n' @@ -3514,10 +3520,10 @@ def test_jsonc_merge_inserting_into_a_comment_only_document_keeps_the_comment() ) def test_jsonc_merge_inserting_into_an_empty_object_is_unchanged(body: str) -> None: """A genuinely empty interior still collapses to the old splice point.""" - data = mcp_swap._jsonc_loads(body) + data = jsonc.loads(body) data.setdefault("mcp", {})["tmux"] = {"type": "local"} - out = mcp_swap._jsonc_merge(body, data, ensure_ascii=False) - assert mcp_swap._jsonc_loads(out)["mcp"]["tmux"] == {"type": "local"} + out = jsonc.merge(body, data, ensure_ascii=False) + assert jsonc.loads(out)["mcp"]["tmux"] == {"type": "local"} assert out.rstrip().endswith("}") @@ -3529,7 +3535,7 @@ def test_jsonc_comment_blanking_preserves_offsets() -> None: would land in the wrong place. """ src = '{\n // note\n "a": 1, /* x */\n "b": "//not a comment"\n}\n' - blanked = mcp_swap._jsonc_blank_comments(src) + blanked = jsonc.blank_comments(src) assert len(blanked) == len(src) assert "//not a comment" in blanked assert "note" not in blanked From 1e4634f50c6bc9d514ddece118fdc8df4f8fedd2 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 16:31:38 -0500 Subject: [PATCH 079/129] Engineering(refactor[swap]): Split building from config editing why: One file held the portable server shape, the dotnet toolchain, every CLI's config dialect, the state file, the commands and the diagnostics. Pointing a CLI at a build and rewriting that CLI's config are separate jobs, and only one of them shells out. what: - move the shared entry shape to spec.py - move repo resolution, dotnet, profile builds, install and preflight to build.py, and name the releases root once so status and the installer cannot disagree - move the XDG lookups both of them need to xdg.py - drop an import that had been dead since before the split --- eng/mcp/build.py | 428 ++++++++++++++++++++++++ eng/mcp/mcp_swap.py | 572 ++------------------------------- eng/mcp/spec.py | 108 +++++++ eng/mcp/tests/test_mcp_swap.py | 50 +-- eng/mcp/xdg.py | 40 +++ 5 files changed, 626 insertions(+), 572 deletions(-) create mode 100644 eng/mcp/build.py create mode 100644 eng/mcp/spec.py create mode 100644 eng/mcp/xdg.py diff --git a/eng/mcp/build.py b/eng/mcp/build.py new file mode 100644 index 0000000..2de655d --- /dev/null +++ b/eng/mcp/build.py @@ -0,0 +1,428 @@ +"""Turn a repository, a run or a release into a server spec. + +Pointing a CLI at a build is a separate job from editing that CLI's config, +and it is the half that shells out: finding a dotnet the way mise hides it, +building a profile, installing a published tool, and asking the result to +complete an MCP handshake before anything is written down. +""" + +from __future__ import annotations + +import json +import os +import pathlib +import shutil +import subprocess +import sys +import time +import typing as t + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) + +import xdg +from spec import McpServerSpec + +#: Where an installed release lands, named once so the status line and +#: the installer cannot disagree about it. +RELEASES_ROOT = xdg.state_home() / "tmux-mcp-dev" / "releases" + + + +ALL_SOURCES = ("debug", "release", "run", "path", "published") +Source = t.Literal["debug", "release", "run", "path", "published"] + +DEFAULT_PROJECT = "LibTmux.Mcp" + +#: The config-file key every libtmux port registers under. +#: +#: Named rather than derived from the package. Deriving it gives +#: ``libtmux`` here and ``tmux`` in the Rust port, so a swap would add a +#: second server beside the one it meant to replace and the agent would +#: keep using whichever it found first -- measured, and the reason this is +#: a constant. Override with ``--server`` to register alongside on purpose. +DEFAULT_SERVER = "tmux" + +#: Frameworks the tool multi-targets, newest first. A profile build writes +#: one output directory per framework; the newest present is the one an +#: agent should launch, and the older one stays available for a bisect. +FRAMEWORKS = ("net10.0", "net8.0") + + +def project_file(repo: pathlib.Path, project: str = DEFAULT_PROJECT) -> pathlib.Path: + """Return the project file to build and to point ``dotnet run`` at.""" + path = repo / "src" / project / f"{project}.csproj" + if not path.is_file(): + msg = f"no {project}.csproj under {repo / 'src' / project}" + raise RuntimeError(msg) + return path + + +def project_property(text: str, name: str) -> str | None: + """Read one MSBuild property out of a project file. + + Deliberately a substring read rather than an XML parse: the properties + wanted here are plain literals, and a dependency on an XML library + would be the only one this script has. + """ + opening = f"<{name}>" + closing = f"" + start = text.find(opening) + if start < 0: + return None + stop = text.find(closing, start) + if stop < 0: + return None + return text[start + len(opening) : stop].strip() or None + + +def resolve_repo_meta( + repo: pathlib.Path, project: str = DEFAULT_PROJECT +) -> tuple[str, str]: + """Derive (server_name, binary_name) from the project file. + + The server name is :data:`DEFAULT_SERVER`, the config-file key every + libtmux port registers under (``mcpServers.`` in JSON, + ``[mcp_servers.]`` in TOML). + + The binary name is what the build writes into ``bin//``, + which is ``AssemblyName`` when the project sets one and the project + name otherwise. + """ + text = project_file(repo, project).read_text() + binary = project_property(text, "AssemblyName") or project + return DEFAULT_SERVER, binary + + +def find_dotnet() -> str: + """Locate the SDK an agent must launch, as an absolute path. + + ``dotnet`` is pinned by ``global.json`` and resolves through mise, so + it is usually absent from a bare ``PATH``. An agent does not inherit + this shell, and a config naming a bare ``dotnet`` would leave every + agent failing to start the server with an error that surfaces inside + the agent rather than here. + """ + found = shutil.which("dotnet") + if found: + return str(pathlib.Path(found).resolve()) + try: + resolved = subprocess.run( + ["mise", "which", "dotnet"], + capture_output=True, + text=True, + check=True, + ).stdout.strip() + except (OSError, subprocess.CalledProcessError): + resolved = "" + if resolved: + return str(pathlib.Path(resolved).resolve()) + msg = ( + "no dotnet on PATH and mise could not name one; " + "install the SDK or run inside `mise exec`" + ) + raise RuntimeError(msg) + + +def dotnet_environment() -> dict[str, str]: + """Environment an agent needs so the apphost can find its runtime. + + A framework-dependent apphost is a native launcher that locates the + runtime through ``DOTNET_ROOT`` or ``PATH``. The SDK here is pinned by + ``global.json`` and installed by mise, so neither names it in the + environment an agent CLI spawns -- measured: the same binary that runs + from a developer shell exits with "You must install .NET to run this + application" under an agent, before the handshake, which the agent + reports as the server having no tools. + + Carrying the location in the config is what makes the entry work for + whoever launches it rather than only for the shell that wrote it. + + ``DOTNET_ROOT`` alone, verified by launching under a stripped + environment. Adding ``PATH`` would work too and would copy the whole + developer environment into every agent's config file, which is a + thousand characters of somebody else's machine per entry. + """ + return {"DOTNET_ROOT": str(pathlib.Path(find_dotnet()).parent)} + + +def profile_binary( + repo: pathlib.Path, + binary: str, + configuration: str, + project: str = DEFAULT_PROJECT, +) -> pathlib.Path: + """Return the built apphost for a configuration, newest framework first.""" + root = repo.resolve() / "src" / project / "bin" / configuration + for framework in FRAMEWORKS: + candidate = root / framework / binary + if candidate.is_file(): + return candidate + return root / FRAMEWORKS[0] / binary + + +def build_profile_spec( + repo: pathlib.Path, + binary: str, + configuration: str, + project: str = DEFAULT_PROJECT, +) -> McpServerSpec: + """Point an agent straight at a compiled binary. + + The agent launches the apphost itself, so nothing runs in front of it: + startup is a process spawn rather than a build that may decide to + recompile while a client is waiting for the handshake. + """ + return McpServerSpec( + command=str(profile_binary(repo, binary, configuration, project)), + env=dotnet_environment(), + ) + + +def build_run_spec( + repo: pathlib.Path, project: str = DEFAULT_PROJECT +) -> McpServerSpec: + """Launch through ``dotnet run``, rebuilding on every start. + + This is the shape to use while editing: the next agent session picks + up the current source with no build step to remember. It costs a build + check on each launch, and the first launch after a change can be slow + enough that a client with a short handshake timeout gives up, which is + why it is not the default. + + The build writes its progress to stderr, leaving stdout to carry the + protocol -- ``preflight`` proves that rather than assuming it. + """ + return McpServerSpec( + command=find_dotnet(), + env=dotnet_environment(), + args=[ + "run", + "--project", + str(project_file(repo, project).resolve()), + "--framework", + FRAMEWORKS[0], + "--configuration", + "Debug", + "--", + ], + ) + + +def build_path_spec(binary_path: pathlib.Path) -> McpServerSpec: + """Point at a binary the caller names, wherever it came from.""" + return McpServerSpec(command=str(binary_path.resolve()), env=dotnet_environment()) + + +def published_root(version: str, binary: str) -> pathlib.Path: + """Where a published release is installed so it cannot shadow others. + + Each version gets its own tool path, so swapping between releases does + not reinstall over the previous one and reverting leaves it available. + """ + return RELEASES_ROOT / f"{binary}-{version}" + + +def published_command(version: str, binary: str, command: str) -> pathlib.Path: + """Return the launcher a published install writes.""" + return published_root(version, binary) / command + + +def build_published_spec( + version: str, binary: str, command: str +) -> McpServerSpec: + """Point at a NuGet release installed under its own tool path.""" + return McpServerSpec( + command=str(published_command(version, binary, command)), + env=dotnet_environment(), + ) + + +def install_published( + package: str, version: str, binary: str, command: str +) -> pathlib.Path: + """Install a NuGet release, returning the launcher path. + + Skips the install when that exact version is already present, so + repeated swaps between releases cost one download each rather than one + per swap. + """ + target = published_command(version, binary, command) + if target.is_file(): + return target + subprocess.run( + [ + find_dotnet(), + "tool", + "install", + package, + "--version", + version, + "--tool-path", + str(published_root(version, binary)), + ], + check=True, + ) + return target + + +def build_source_spec( + source: Source, + *, + repo: pathlib.Path, + binary: str, + project: str = DEFAULT_PROJECT, + command: str = "libtmux-mcp", + version: str | None = None, + binary_path: pathlib.Path | None = None, +) -> McpServerSpec: + """Build the spec for one source kind.""" + if source == "debug": + return build_profile_spec(repo, binary, "Debug", project) + if source == "release": + return build_profile_spec(repo, binary, "Release", project) + if source == "run": + return build_run_spec(repo, project) + if source == "path": + if binary_path is None: + msg = "--source path needs --bin" + raise RuntimeError(msg) + return build_path_spec(binary_path) + if source == "published": + if version is None: + msg = "--source published needs --version" + raise RuntimeError(msg) + return build_published_spec(version, binary, command) + msg = f"unknown source {source!r}" + raise RuntimeError(msg) + + +def dotnet_build( + repo: pathlib.Path, configuration: str, project: str = DEFAULT_PROJECT +) -> None: + """Build the binary a profile spec points at. + + Writing a config that names a binary which does not exist yet leaves + every agent failing to start a server, and the error surfaces inside + the agent rather than here. + """ + subprocess.run( + [ + find_dotnet(), + "build", + str(project_file(repo, project).resolve()), + "--configuration", + configuration, + ], + check=True, + ) + + +def _run_text(argv: list[str], cwd: pathlib.Path | None = None) -> str: + """Run ``argv`` and return stdout, raising on a non-zero exit.""" + return subprocess.run( + argv, + cwd=None if cwd is None else str(cwd), + capture_output=True, + text=True, + check=True, + ).stdout + + +_INITIALIZE_FRAME = ( + json.dumps( + { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-06-18", + "capabilities": {}, + "clientInfo": {"name": "mcp_swap-preflight", "version": "1"}, + }, + } + ) + + "\n" +) + + +def preflight_spec(spec: McpServerSpec, *, timeout: float = 300.0) -> str | None: + """Launch ``spec`` and complete one MCP ``initialize`` round trip. + + Returns ``None`` when the server answered, otherwise a reason to + show the operator. A pull-request spec resolves its dependencies at + launch time, inside whichever agent starts it, so an unresolvable + ref would otherwise land in every config and surface later as an + opaque startup failure in each one. + + stdin is held open until the answer arrives, the way a real client + holds it open for the session. Writing the frame and closing at once + is a different test: an SDK that treats end of input as a disconnect + tears the session down while the reply is still being written, and + the server answers nothing. Measured against this one -- immediate + close returned no bytes, the same frame followed by a pause returned + the handshake. + """ + try: + proc = subprocess.Popen( + [spec.command, *spec.args], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env={**os.environ, **spec.env}, + text=True, + ) + except OSError as exc: + return f"could not launch {spec.command}: {exc}" + + assert proc.stdin is not None + assert proc.stdout is not None + try: + proc.stdin.write(_INITIALIZE_FRAME) + proc.stdin.flush() + except OSError as exc: + proc.kill() + proc.communicate() + return f"{spec.command} closed stdin before answering: {exc}" + + answer: str | None = None + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + line = proc.stdout.readline() + if not line: + break + try: + message = json.loads(line) + except json.JSONDecodeError: + continue + if isinstance(message, dict) and message.get("id") == 1 and "result" in message: + answer = line + break + + # Closed by hand, so ``communicate`` must not be asked to close it + # again: on CPython 3.12 that raises "I/O operation on closed file" + # and turns a server that answered correctly into a swap that refuses + # to write. The remaining output is drained directly instead. + try: + proc.stdin.close() + except OSError: + pass + proc.stdin = None + + try: + out, err = proc.communicate(timeout=timeout) + except subprocess.TimeoutExpired: + proc.kill() + out, err = proc.communicate() + if answer is not None: + return None + + for line in out.splitlines(): + try: + message = json.loads(line) + except json.JSONDecodeError: + continue + if isinstance(message, dict) and message.get("id") == 1 and "result" in message: + return None + + tail = "\n".join(err.strip().splitlines()[-3:]) + return tail or "server exited without answering initialize" diff --git a/eng/mcp/mcp_swap.py b/eng/mcp/mcp_swap.py index 523f6b4..703bcea 100755 --- a/eng/mcp/mcp_swap.py +++ b/eng/mcp/mcp_swap.py @@ -121,7 +121,6 @@ import json import os import pathlib -import re import shutil import stat import subprocess @@ -137,7 +136,10 @@ # directory is what Python imports from. sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) -import jsonc # noqa: E402 +import build +import jsonc +import xdg +from spec import Dialect, McpServerSpec CLIName = t.Literal[ "claude", "codex", "cursor", "gemini", "grok", "agy", "opencode", "pi" @@ -226,33 +228,14 @@ def _parse_state_entry(v: dict[str, t.Any]) -> SwapEntry | None: return None -def _xdg_state_home() -> pathlib.Path: - """Resolve ``$XDG_STATE_HOME`` per the XDG Base Directory spec. - - Defaults to ``~/.local/state`` when the env var is unset or empty. - State is the right XDG bucket here (vs. cache / config / data): the - file is machine-written, must persist across runs so ``revert`` can - locate the right backup, but is not safely deletable like cache nor - user-edited like config. - """ - env = os.environ.get("XDG_STATE_HOME") - if env: - return pathlib.Path(env) - return pathlib.Path.home() / ".local" / "state" - - # ``-dev`` suffix in the namespace makes it loud that this is dev-only # tooling state, distinct from the ``LibTmux.Mcp`` tool it swaps. -STATE_DIR = _xdg_state_home() / "tmux-mcp-dev" / "swap" +STATE_DIR = xdg.state_home() / "tmux-mcp-dev" / "swap" STATE_FILE = STATE_DIR / "state.json" BACKUP_SUFFIX_PREFIX = ".bak.mcp-swap-" -#: Per-entry shape a CLI's server map expects; the same file format -#: (e.g. JSON) does not imply the same entry shape. See -#: :meth:`McpServerSpec.to_entry_dict` for what each dialect writes. -Dialect = t.Literal["standard", "claude", "opencode"] @dataclasses.dataclass(frozen=True) @@ -272,20 +255,6 @@ class CLIInfo: dialect: Dialect -def _xdg_config_home() -> pathlib.Path: - """``$XDG_CONFIG_HOME`` when absolute, else ``~/.config``. - - The spec requires these variables to be absolute and says to ignore - them otherwise. A relative value would resolve against the working - directory, so the swap would record a backup path that revert could - no longer find from anywhere else. - """ - raw = os.environ.get("XDG_CONFIG_HOME") - if raw and pathlib.Path(raw).is_absolute(): - return pathlib.Path(raw) - return pathlib.Path.home() / ".config" - - CLIS: dict[CLIName, CLIInfo] = { "claude": CLIInfo( name="claude", @@ -338,7 +307,7 @@ def _xdg_config_home() -> pathlib.Path: "opencode": CLIInfo( name="opencode", binary="opencode", - config_path=_xdg_config_home() / "opencode" / "opencode.jsonc", + config_path=xdg.config_home() / "opencode" / "opencode.jsonc", fmt="jsonc", container=("mcp",), dialect="opencode", @@ -372,97 +341,6 @@ def _xdg_config_home() -> pathlib.Path: #: GitHub publishes ``refs/pull//head`` on the *base* repository, so #: one URL serves same-repo and fork pull requests alike. -@dataclasses.dataclass -class McpServerSpec: - """The portable shape shared across CLI configs.""" - - command: str - args: list[str] = dataclasses.field(default_factory=list) - env: dict[str, str] = dataclasses.field(default_factory=dict) - - def to_entry_dict(self, dialect: Dialect = "standard") -> dict[str, t.Any]: - """Serialize to the entry shape ``dialect`` expects.""" - # Claude's format always includes ``type`` and ``env`` (even when - # empty); the standard shape omits both when there is nothing to say. - if dialect == "claude": - return { - "type": "stdio", - "command": self.command, - "args": list(self.args), - "env": dict(self.env), - } - if dialect == "opencode": - # One array for argv, and the table is "environment" -- an - # "env" key here is dropped in silence, and a scalar command - # is a decode error that takes the whole config down with it. - local: dict[str, t.Any] = { - "type": "local", - "command": [self.command, *self.args], - } - if self.env: - local["environment"] = dict(self.env) - return local - out: dict[str, t.Any] = {"command": self.command, "args": list(self.args)} - if self.env: - out["env"] = dict(self.env) - return out - - def project_path(self) -> pathlib.Path | None: - """Extract ``--project`` from a ``dotnet run`` spec, if any.""" - if pathlib.Path(self.command).name not in {"dotnet", "dotnet.exe"}: - return None - try: - i = self.args.index("--project") - except ValueError: - return None - if i + 1 >= len(self.args): - return None - return pathlib.Path(self.args[i + 1]) - - def built_binary_path(self) -> pathlib.Path | None: - """Return the binary this spec launches directly, if it launches one. - - A configuration build is invoked by absolute path rather than - through ``dotnet``, so the agent starts the server without a build - step in front of it. That is the shape this recognises. - """ - if self.project_path() is not None or "/" not in self.command: - return None - return pathlib.Path(self.command) - - def _bin_parts(self) -> tuple[pathlib.Path, str] | None: - """Split a built path into its project directory and configuration. - - The layout is ``/bin///``, - so the configuration is three levels up from the binary. - """ - binary = self.built_binary_path() - if binary is None: - return None - framework_dir = binary.parent - configuration_dir = framework_dir.parent - if configuration_dir.parent.name != "bin": - return None - return configuration_dir.parent.parent, configuration_dir.name - - def local_repo_path(self) -> pathlib.Path | None: - """Return the repo a spec points into, whichever shape it uses.""" - project = self.project_path() - if project is not None: - # src//.csproj -> repo root - return project.parent.parent.parent - parts = self._bin_parts() - if parts is not None: - # src/ -> repo root - return parts[0].parent.parent - return None - - def dotnet_configuration(self) -> str | None: - """Return ``Debug`` or ``Release`` for a configuration build.""" - parts = self._bin_parts() - return None if parts is None else parts[1] - - @dataclasses.dataclass class SwapEntry: """One CLI's bookkeeping for a swap, written to the state file.""" @@ -941,408 +819,6 @@ def _spec_from_entry(entry: t.Any, *, info: CLIInfo) -> McpServerSpec: # --------------------------------------------------------------------------- # Repo metadata # --------------------------------------------------------------------------- - - -ALL_SOURCES = ("debug", "release", "run", "path", "published") -Source = t.Literal["debug", "release", "run", "path", "published"] - -DEFAULT_PROJECT = "LibTmux.Mcp" - -#: The config-file key every libtmux port registers under. -#: -#: Named rather than derived from the package. Deriving it gives -#: ``libtmux`` here and ``tmux`` in the Rust port, so a swap would add a -#: second server beside the one it meant to replace and the agent would -#: keep using whichever it found first -- measured, and the reason this is -#: a constant. Override with ``--server`` to register alongside on purpose. -DEFAULT_SERVER = "tmux" - -#: Frameworks the tool multi-targets, newest first. A profile build writes -#: one output directory per framework; the newest present is the one an -#: agent should launch, and the older one stays available for a bisect. -FRAMEWORKS = ("net10.0", "net8.0") - - -def project_file(repo: pathlib.Path, project: str = DEFAULT_PROJECT) -> pathlib.Path: - """Return the project file to build and to point ``dotnet run`` at.""" - path = repo / "src" / project / f"{project}.csproj" - if not path.is_file(): - msg = f"no {project}.csproj under {repo / 'src' / project}" - raise RuntimeError(msg) - return path - - -def _project_property(text: str, name: str) -> str | None: - """Read one MSBuild property out of a project file. - - Deliberately a substring read rather than an XML parse: the properties - wanted here are plain literals, and a dependency on an XML library - would be the only one this script has. - """ - opening = f"<{name}>" - closing = f"" - start = text.find(opening) - if start < 0: - return None - stop = text.find(closing, start) - if stop < 0: - return None - return text[start + len(opening) : stop].strip() or None - - -def resolve_repo_meta( - repo: pathlib.Path, project: str = DEFAULT_PROJECT -) -> tuple[str, str]: - """Derive (server_name, binary_name) from the project file. - - The server name is :data:`DEFAULT_SERVER`, the config-file key every - libtmux port registers under (``mcpServers.`` in JSON, - ``[mcp_servers.]`` in TOML). - - The binary name is what the build writes into ``bin//``, - which is ``AssemblyName`` when the project sets one and the project - name otherwise. - """ - text = project_file(repo, project).read_text() - binary = _project_property(text, "AssemblyName") or project - return DEFAULT_SERVER, binary - - -def find_dotnet() -> str: - """Locate the SDK an agent must launch, as an absolute path. - - ``dotnet`` is pinned by ``global.json`` and resolves through mise, so - it is usually absent from a bare ``PATH``. An agent does not inherit - this shell, and a config naming a bare ``dotnet`` would leave every - agent failing to start the server with an error that surfaces inside - the agent rather than here. - """ - found = shutil.which("dotnet") - if found: - return str(pathlib.Path(found).resolve()) - try: - resolved = subprocess.run( - ["mise", "which", "dotnet"], - capture_output=True, - text=True, - check=True, - ).stdout.strip() - except (OSError, subprocess.CalledProcessError): - resolved = "" - if resolved: - return str(pathlib.Path(resolved).resolve()) - msg = ( - "no dotnet on PATH and mise could not name one; " - "install the SDK or run inside `mise exec`" - ) - raise RuntimeError(msg) - - -def dotnet_environment() -> dict[str, str]: - """Environment an agent needs so the apphost can find its runtime. - - A framework-dependent apphost is a native launcher that locates the - runtime through ``DOTNET_ROOT`` or ``PATH``. The SDK here is pinned by - ``global.json`` and installed by mise, so neither names it in the - environment an agent CLI spawns -- measured: the same binary that runs - from a developer shell exits with "You must install .NET to run this - application" under an agent, before the handshake, which the agent - reports as the server having no tools. - - Carrying the location in the config is what makes the entry work for - whoever launches it rather than only for the shell that wrote it. - - ``DOTNET_ROOT`` alone, verified by launching under a stripped - environment. Adding ``PATH`` would work too and would copy the whole - developer environment into every agent's config file, which is a - thousand characters of somebody else's machine per entry. - """ - return {"DOTNET_ROOT": str(pathlib.Path(find_dotnet()).parent)} - - -def profile_binary( - repo: pathlib.Path, - binary: str, - configuration: str, - project: str = DEFAULT_PROJECT, -) -> pathlib.Path: - """Return the built apphost for a configuration, newest framework first.""" - root = repo.resolve() / "src" / project / "bin" / configuration - for framework in FRAMEWORKS: - candidate = root / framework / binary - if candidate.is_file(): - return candidate - return root / FRAMEWORKS[0] / binary - - -def build_profile_spec( - repo: pathlib.Path, - binary: str, - configuration: str, - project: str = DEFAULT_PROJECT, -) -> McpServerSpec: - """Point an agent straight at a compiled binary. - - The agent launches the apphost itself, so nothing runs in front of it: - startup is a process spawn rather than a build that may decide to - recompile while a client is waiting for the handshake. - """ - return McpServerSpec( - command=str(profile_binary(repo, binary, configuration, project)), - env=dotnet_environment(), - ) - - -def build_run_spec( - repo: pathlib.Path, project: str = DEFAULT_PROJECT -) -> McpServerSpec: - """Launch through ``dotnet run``, rebuilding on every start. - - This is the shape to use while editing: the next agent session picks - up the current source with no build step to remember. It costs a build - check on each launch, and the first launch after a change can be slow - enough that a client with a short handshake timeout gives up, which is - why it is not the default. - - The build writes its progress to stderr, leaving stdout to carry the - protocol -- ``preflight`` proves that rather than assuming it. - """ - return McpServerSpec( - command=find_dotnet(), - env=dotnet_environment(), - args=[ - "run", - "--project", - str(project_file(repo, project).resolve()), - "--framework", - FRAMEWORKS[0], - "--configuration", - "Debug", - "--", - ], - ) - - -def build_path_spec(binary_path: pathlib.Path) -> McpServerSpec: - """Point at a binary the caller names, wherever it came from.""" - return McpServerSpec(command=str(binary_path.resolve()), env=dotnet_environment()) - - -def published_root(version: str, binary: str) -> pathlib.Path: - """Where a published release is installed so it cannot shadow others. - - Each version gets its own tool path, so swapping between releases does - not reinstall over the previous one and reverting leaves it available. - """ - return _xdg_state_home() / "tmux-mcp-dev" / "releases" / f"{binary}-{version}" - - -def published_command(version: str, binary: str, command: str) -> pathlib.Path: - """Return the launcher a published install writes.""" - return published_root(version, binary) / command - - -def build_published_spec( - version: str, binary: str, command: str -) -> McpServerSpec: - """Point at a NuGet release installed under its own tool path.""" - return McpServerSpec( - command=str(published_command(version, binary, command)), - env=dotnet_environment(), - ) - - -def install_published( - package: str, version: str, binary: str, command: str -) -> pathlib.Path: - """Install a NuGet release, returning the launcher path. - - Skips the install when that exact version is already present, so - repeated swaps between releases cost one download each rather than one - per swap. - """ - target = published_command(version, binary, command) - if target.is_file(): - return target - subprocess.run( - [ - find_dotnet(), - "tool", - "install", - package, - "--version", - version, - "--tool-path", - str(published_root(version, binary)), - ], - check=True, - ) - return target - - -def build_source_spec( - source: Source, - *, - repo: pathlib.Path, - binary: str, - project: str = DEFAULT_PROJECT, - command: str = "libtmux-mcp", - version: str | None = None, - binary_path: pathlib.Path | None = None, -) -> McpServerSpec: - """Build the spec for one source kind.""" - if source == "debug": - return build_profile_spec(repo, binary, "Debug", project) - if source == "release": - return build_profile_spec(repo, binary, "Release", project) - if source == "run": - return build_run_spec(repo, project) - if source == "path": - if binary_path is None: - msg = "--source path needs --bin" - raise RuntimeError(msg) - return build_path_spec(binary_path) - if source == "published": - if version is None: - msg = "--source published needs --version" - raise RuntimeError(msg) - return build_published_spec(version, binary, command) - msg = f"unknown source {source!r}" - raise RuntimeError(msg) - - -def dotnet_build( - repo: pathlib.Path, configuration: str, project: str = DEFAULT_PROJECT -) -> None: - """Build the binary a profile spec points at. - - Writing a config that names a binary which does not exist yet leaves - every agent failing to start a server, and the error surfaces inside - the agent rather than here. - """ - subprocess.run( - [ - find_dotnet(), - "build", - str(project_file(repo, project).resolve()), - "--configuration", - configuration, - ], - check=True, - ) - - -def _run_text(argv: list[str], cwd: pathlib.Path | None = None) -> str: - """Run ``argv`` and return stdout, raising on a non-zero exit.""" - return subprocess.run( - argv, - cwd=None if cwd is None else str(cwd), - capture_output=True, - text=True, - check=True, - ).stdout - - -_INITIALIZE_FRAME = ( - json.dumps( - { - "jsonrpc": "2.0", - "id": 1, - "method": "initialize", - "params": { - "protocolVersion": "2025-06-18", - "capabilities": {}, - "clientInfo": {"name": "mcp_swap-preflight", "version": "1"}, - }, - } - ) - + "\n" -) - - -def preflight_spec(spec: McpServerSpec, *, timeout: float = 300.0) -> str | None: - """Launch ``spec`` and complete one MCP ``initialize`` round trip. - - Returns ``None`` when the server answered, otherwise a reason to - show the operator. A pull-request spec resolves its dependencies at - launch time, inside whichever agent starts it, so an unresolvable - ref would otherwise land in every config and surface later as an - opaque startup failure in each one. - - stdin is held open until the answer arrives, the way a real client - holds it open for the session. Writing the frame and closing at once - is a different test: an SDK that treats end of input as a disconnect - tears the session down while the reply is still being written, and - the server answers nothing. Measured against this one -- immediate - close returned no bytes, the same frame followed by a pause returned - the handshake. - """ - try: - proc = subprocess.Popen( - [spec.command, *spec.args], - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - env={**os.environ, **spec.env}, - text=True, - ) - except OSError as exc: - return f"could not launch {spec.command}: {exc}" - - assert proc.stdin is not None - assert proc.stdout is not None - try: - proc.stdin.write(_INITIALIZE_FRAME) - proc.stdin.flush() - except OSError as exc: - proc.kill() - proc.communicate() - return f"{spec.command} closed stdin before answering: {exc}" - - answer: str | None = None - deadline = time.monotonic() + timeout - while time.monotonic() < deadline: - line = proc.stdout.readline() - if not line: - break - try: - message = json.loads(line) - except json.JSONDecodeError: - continue - if isinstance(message, dict) and message.get("id") == 1 and "result" in message: - answer = line - break - - # Closed by hand, so ``communicate`` must not be asked to close it - # again: on CPython 3.12 that raises "I/O operation on closed file" - # and turns a server that answered correctly into a swap that refuses - # to write. The remaining output is drained directly instead. - try: - proc.stdin.close() - except OSError: - pass - proc.stdin = None - - try: - out, err = proc.communicate(timeout=timeout) - except subprocess.TimeoutExpired: - proc.kill() - out, err = proc.communicate() - if answer is not None: - return None - - for line in out.splitlines(): - try: - message = json.loads(line) - except json.JSONDecodeError: - continue - if isinstance(message, dict) and message.get("id") == 1 and "result" in message: - return None - - tail = "\n".join(err.strip().splitlines()[-3:]) - return tail or "server exited without answering initialize" - - # --------------------------------------------------------------------------- # State file # --------------------------------------------------------------------------- @@ -1507,7 +983,7 @@ def cmd_status(args: argparse.Namespace) -> int: ``args.scope``. """ repo = pathlib.Path(args.repo).resolve() - server = args.server or resolve_repo_meta(repo)[0] + server = args.server or build.resolve_repo_meta(repo)[0] scope_filter: Scope | None = args.scope for cli in args.cli or present_clis(): info = CLIS[cli] @@ -1580,7 +1056,7 @@ def _describe_spec(spec: McpServerSpec, repo: pathlib.Path) -> str: local = spec.local_repo_path() if configuration and local and local.resolve() == repo.resolve(): return f"{configuration.lower()} build: this repo" - releases = _xdg_state_home() / "tmux-mcp-dev" / "releases" + releases = build.RELEASES_ROOT try: relative = binary.relative_to(releases) except ValueError: @@ -1622,25 +1098,25 @@ def _cmd_use_local(args: argparse.Namespace) -> int: coerced to ``"user"`` for non-Claude CLIs by :func:`_normalize_scope`. """ repo = pathlib.Path(args.repo).resolve() - project = getattr(args, "project", None) or DEFAULT_PROJECT - server, default_binary = resolve_repo_meta(repo, project) + project = getattr(args, "project", None) or build.DEFAULT_PROJECT + server, default_binary = build.resolve_repo_meta(repo, project) server = args.server or server binary = args.entry or default_binary - command = _project_property( - project_file(repo, project).read_text(), "ToolCommandName" + command = build.project_property( + build.project_file(repo, project).read_text(), "ToolCommandName" ) or "libtmux-mcp" extra_env = dict(args.env or []) - source: Source = getattr(args, "source", "debug") + source: build.Source = getattr(args, "source", "debug") # A config naming a binary that was never built leaves every agent # failing to start a server, and the failure surfaces inside the agent # rather than here. try: if source in ("debug", "release") and not getattr(args, "no_build", False): - dotnet_build(repo, source.capitalize(), project) + build.dotnet_build(repo, source.capitalize(), project) if source == "published": - install_published("LibTmux.Mcp", args.version, binary, command) - spec = build_source_spec( + build.install_published("LibTmux.Mcp", args.version, binary, command) + spec = build.build_source_spec( source, repo=repo, binary=binary, @@ -1675,7 +1151,7 @@ def _cmd_use_local(args: argparse.Namespace) -> int: # speak the protocol now, and finding out from inside each agent. if not args.no_preflight: print(f"preflight: {spec.command} {' '.join(spec.args)}", file=sys.stderr) - failure = preflight_spec(spec) + failure = build.preflight_spec(spec) if failure is not None: print(f"preflight failed, nothing written:\n{failure}", file=sys.stderr) return 1 @@ -2070,7 +1546,7 @@ def cmd_doctor(args: argparse.Namespace) -> int: and lives in documentation, not here. """ repo = pathlib.Path(args.repo).resolve() - server = args.server or resolve_repo_meta(repo)[0] + server = args.server or build.resolve_repo_meta(repo)[0] print("mcp-swap doctor") print(f" repo: {repo}") print(f" server: {server} (derived default; override with --server)") @@ -2158,7 +1634,7 @@ def build_parser() -> argparse.ArgumentParser: ps = sub.add_parser("status", help="show the current MCP server entry per CLI") ps.add_argument("--repo", default=".", help="repo root (default: .)") ps.add_argument( - "--server", help=f"MCP server name (default: {DEFAULT_SERVER})" + "--server", help=f"MCP server name (default: {build.DEFAULT_SERVER})" ) ps.add_argument( "--cli", action="append", choices=ALL_CLIS, help="limit to one or more CLIs" @@ -2184,7 +1660,7 @@ def build_parser() -> argparse.ArgumentParser: pu.add_argument("--repo", default=".", help="repo root (default: .)") pu.add_argument( "--source", - choices=ALL_SOURCES, + choices=build.ALL_SOURCES, default="debug", help=( "Which build to point the agents at. 'debug' and 'release' build " @@ -2203,8 +1679,8 @@ def build_parser() -> argparse.ArgumentParser: pu.add_argument("--bin", help="binary to run for --source path") pu.add_argument( "--project", - default=DEFAULT_PROJECT, - help=f"project providing the server (default: {DEFAULT_PROJECT})", + default=build.DEFAULT_PROJECT, + help=f"project providing the server (default: {build.DEFAULT_PROJECT})", ) pu.add_argument( "--no-build", @@ -2225,7 +1701,7 @@ def build_parser() -> argparse.ArgumentParser: ), ) pu.add_argument( - "--server", help=f"MCP server name (default: {DEFAULT_SERVER})" + "--server", help=f"MCP server name (default: {build.DEFAULT_SERVER})" ) pu.add_argument( "--entry", help="binary name (default: the project's AssemblyName)" @@ -2275,7 +1751,7 @@ def build_parser() -> argparse.ArgumentParser: ) pd.add_argument("--repo", default=".", help="repo root (default: .)") pd.add_argument( - "--server", help=f"MCP server name (default: {DEFAULT_SERVER})" + "--server", help=f"MCP server name (default: {build.DEFAULT_SERVER})" ) pd.set_defaults(func=cmd_doctor) diff --git a/eng/mcp/spec.py b/eng/mcp/spec.py new file mode 100644 index 0000000..ffcfd0b --- /dev/null +++ b/eng/mcp/spec.py @@ -0,0 +1,108 @@ +"""The portable shape of an MCP server entry. + +Every CLI stores the same three things — a command, its arguments and its +environment — and then disagrees about how to write them down. This holds the +shared shape and each dialect's rendering of it, so the config editors and the +builders can pass one value between them without either owning it. +""" + +from __future__ import annotations + +import dataclasses +import pathlib +import typing as t + +#: How one CLI wants an entry written. The same file format does not imply +#: the same entry shape. +Dialect = t.Literal["standard", "claude", "opencode"] + + +@dataclasses.dataclass +class McpServerSpec: + """The portable shape shared across CLI configs.""" + + command: str + args: list[str] = dataclasses.field(default_factory=list) + env: dict[str, str] = dataclasses.field(default_factory=dict) + + def to_entry_dict(self, dialect: Dialect = "standard") -> dict[str, t.Any]: + """Serialize to the entry shape ``dialect`` expects.""" + # Claude's format always includes ``type`` and ``env`` (even when + # empty); the standard shape omits both when there is nothing to say. + if dialect == "claude": + return { + "type": "stdio", + "command": self.command, + "args": list(self.args), + "env": dict(self.env), + } + if dialect == "opencode": + # One array for argv, and the table is "environment" -- an + # "env" key here is dropped in silence, and a scalar command + # is a decode error that takes the whole config down with it. + local: dict[str, t.Any] = { + "type": "local", + "command": [self.command, *self.args], + } + if self.env: + local["environment"] = dict(self.env) + return local + out: dict[str, t.Any] = {"command": self.command, "args": list(self.args)} + if self.env: + out["env"] = dict(self.env) + return out + + def project_path(self) -> pathlib.Path | None: + """Extract ``--project`` from a ``dotnet run`` spec, if any.""" + if pathlib.Path(self.command).name not in {"dotnet", "dotnet.exe"}: + return None + try: + i = self.args.index("--project") + except ValueError: + return None + if i + 1 >= len(self.args): + return None + return pathlib.Path(self.args[i + 1]) + + def built_binary_path(self) -> pathlib.Path | None: + """Return the binary this spec launches directly, if it launches one. + + A configuration build is invoked by absolute path rather than + through ``dotnet``, so the agent starts the server without a build + step in front of it. That is the shape this recognises. + """ + if self.project_path() is not None or "/" not in self.command: + return None + return pathlib.Path(self.command) + + def _bin_parts(self) -> tuple[pathlib.Path, str] | None: + """Split a built path into its project directory and configuration. + + The layout is ``/bin///``, + so the configuration is three levels up from the binary. + """ + binary = self.built_binary_path() + if binary is None: + return None + framework_dir = binary.parent + configuration_dir = framework_dir.parent + if configuration_dir.parent.name != "bin": + return None + return configuration_dir.parent.parent, configuration_dir.name + + def local_repo_path(self) -> pathlib.Path | None: + """Return the repo a spec points into, whichever shape it uses.""" + project = self.project_path() + if project is not None: + # src//.csproj -> repo root + return project.parent.parent.parent + parts = self._bin_parts() + if parts is not None: + # src/ -> repo root + return parts[0].parent.parent + return None + + def dotnet_configuration(self) -> str | None: + """Return ``Debug`` or ``Release`` for a configuration build.""" + parts = self._bin_parts() + return None if parts is None else parts[1] diff --git a/eng/mcp/tests/test_mcp_swap.py b/eng/mcp/tests/test_mcp_swap.py index c4cd240..99a742e 100644 --- a/eng/mcp/tests/test_mcp_swap.py +++ b/eng/mcp/tests/test_mcp_swap.py @@ -33,7 +33,9 @@ # by path has to put that directory where Python will look. sys.path.insert(0, str(_SCRIPT.parent)) +import build # noqa: E402 import jsonc # noqa: E402 +import xdg # noqa: E402 _spec = importlib.util.spec_from_file_location("mcp_swap", _SCRIPT) assert _spec and _spec.loader @@ -148,7 +150,7 @@ def fake_repo( " \n" "\n" ) - binary = project / "bin" / "Debug" / mcp_swap.FRAMEWORKS[0] / "LibTmux.Mcp" + binary = project / "bin" / "Debug" / build.FRAMEWORKS[0] / "LibTmux.Mcp" binary.parent.mkdir(parents=True) binary.write_text("#!/bin/sh\nexit 0\n") binary.chmod(0o755) @@ -159,7 +161,7 @@ def fake_repo( dotnet.parent.mkdir(parents=True, exist_ok=True) dotnet.write_text("#!/bin/sh\nexit 0\n") dotnet.chmod(0o755) - monkeypatch.setattr(mcp_swap, "find_dotnet", lambda: str(dotnet)) + monkeypatch.setattr(build, "find_dotnet", lambda: str(dotnet)) return repo @@ -176,7 +178,7 @@ def _runtime_env(fake_repo: pathlib.Path) -> dict[str, str]: the config, so every entry this script writes carries it. """ del fake_repo - return dict(mcp_swap.dotnet_environment()) + return dict(build.dotnet_environment()) def _pinned_json_entry() -> dict[str, t.Any]: @@ -204,7 +206,7 @@ def test_resolve_repo_meta_strips_mcp_suffix(fake_repo: pathlib.Path) -> None: ``--server tmux`` is the override to target the README/serverInfo slug for fresh installs. """ - server, entry = mcp_swap.resolve_repo_meta(fake_repo) + server, entry = build.resolve_repo_meta(fake_repo) assert server == "tmux" assert entry == "LibTmux.Mcp" @@ -221,8 +223,8 @@ def test_resolve_repo_meta_falls_back_to_the_project_name( # The slug is the shared one whatever the project is called: every # libtmux port swaps into the same slot, which is what makes a swap # replace rather than accumulate. - assert mcp_swap.resolve_repo_meta(repo, "Weather.Mcp") == ( - mcp_swap.DEFAULT_SERVER, + assert build.resolve_repo_meta(repo, "Weather.Mcp") == ( + build.DEFAULT_SERVER, "Weather.Mcp", ) @@ -249,7 +251,7 @@ def test_json_swap_and_revert_round_trip( after = json.loads(info.config_path.read_text()) entry = after["mcpServers"]["tmux"] assert entry["command"] == str( - mcp_swap.profile_binary(fake_repo, "LibTmux.Mcp", "Debug") + build.profile_binary(fake_repo, "LibTmux.Mcp", "Debug") ) assert entry["args"] == [] @@ -275,7 +277,7 @@ def test_grok_set_get_delete_roundtrip(fake_repo: pathlib.Path) -> None: """The Grok CLI reads/writes the TOML ``[mcp_servers]`` table like Codex.""" config = tomlkit.parse("") spec = mcp_swap.McpServerSpec( - command=str(mcp_swap.profile_binary(fake_repo, "LibTmux.Mcp", "Debug")) + command=str(build.profile_binary(fake_repo, "LibTmux.Mcp", "Debug")) ) assert mcp_swap.set_server("grok", config, "tmux", spec, fake_repo) == "added" assert "mcp_servers" in config @@ -337,7 +339,7 @@ def test_use_local_preserves_existing_env_when_replacing( entry = json.loads(info.config_path.read_text())["mcpServers"]["tmux"] assert entry["command"] == str( - mcp_swap.profile_binary(fake_repo, "LibTmux.Mcp", "Debug") + build.profile_binary(fake_repo, "LibTmux.Mcp", "Debug") ) assert entry["args"] == [] assert entry["env"] == _runtime_env(fake_repo) | { @@ -433,7 +435,7 @@ def test_claude_swap_writes_under_repo_abspath_only( new_entry = after["projects"][repo_key]["mcpServers"]["tmux"] assert new_entry["type"] == "stdio" assert new_entry["command"] == str( - mcp_swap.profile_binary(fake_repo, "LibTmux.Mcp", "Debug") + build.profile_binary(fake_repo, "LibTmux.Mcp", "Debug") ) assert new_entry["args"] == [] @@ -470,7 +472,7 @@ def test_claude_user_scope_writes_top_level_mcpServers( after = json.loads(info.config_path.read_text()) new_entry = after["mcpServers"]["tmux"] assert new_entry["command"] == str( - mcp_swap.profile_binary(fake_repo, "LibTmux.Mcp", "Debug") + build.profile_binary(fake_repo, "LibTmux.Mcp", "Debug") ) assert new_entry["args"] == [] # No projects. node should have been created — user scope must @@ -588,7 +590,7 @@ def test_claude_user_and_project_swaps_coexist_independently( # Project-level still local. proj_entry = after["projects"][str(fake_repo.resolve())]["mcpServers"]["tmux"] assert proj_entry["command"] == str( - mcp_swap.profile_binary(fake_repo, "LibTmux.Mcp", "Debug") + build.profile_binary(fake_repo, "LibTmux.Mcp", "Debug") ) @@ -794,7 +796,7 @@ def test_non_claude_scope_user_passes_through_to_global_config( after = json.loads(info.config_path.read_text()) assert after["mcpServers"]["tmux"]["command"] == str( - mcp_swap.profile_binary(fake_repo, "LibTmux.Mcp", "Debug") + build.profile_binary(fake_repo, "LibTmux.Mcp", "Debug") ) # State key reflects the normalised scope, not the raw flag value. @@ -833,7 +835,7 @@ def test_codex_swap_preserves_toml_comments( assert "# Top-level comment preserved across swap" in text doc = tomlkit.loads(text).unwrap() assert doc["mcp_servers"]["tmux"]["command"] == str( - mcp_swap.profile_binary(fake_repo, "LibTmux.Mcp", "Debug") + build.profile_binary(fake_repo, "LibTmux.Mcp", "Debug") ) assert doc["other"]["keep"] is True @@ -1645,7 +1647,7 @@ def test_status_scope_user_with_only_project_entry_shows_no_entry( def _local_entry(repo: pathlib.Path) -> dict[str, t.Any]: """Return the JSON entry a default (debug-profile) swap writes.""" return { - "command": str(mcp_swap.profile_binary(repo, "LibTmux.Mcp", "Debug")), + "command": str(build.profile_binary(repo, "LibTmux.Mcp", "Debug")), "args": [], } @@ -2220,7 +2222,7 @@ def test_preflight_accepts_a_server_that_answers_initialize( ) spec = mcp_swap.McpServerSpec(command=sys.executable, args=[str(server)]) - assert mcp_swap.preflight_spec(spec, timeout=60) is None + assert build.preflight_spec(spec, timeout=60) is None def test_preflight_reports_stderr_when_the_server_never_answers( @@ -2234,14 +2236,14 @@ def test_preflight_reports_stderr_when_the_server_never_answers( ) spec = mcp_swap.McpServerSpec(command=sys.executable, args=[str(server)]) - assert mcp_swap.preflight_spec(spec, timeout=60) == "could not resolve ref" + assert build.preflight_spec(spec, timeout=60) == "could not resolve ref" def test_preflight_reports_a_command_that_cannot_launch() -> None: """A missing binary is named rather than raising.""" spec = mcp_swap.McpServerSpec(command="mcp-swap-no-such-binary", args=[]) - failure = mcp_swap.preflight_spec(spec, timeout=60) + failure = build.preflight_spec(spec, timeout=60) assert failure is not None assert "mcp-swap-no-such-binary" in failure @@ -2266,7 +2268,7 @@ def test_preflight_passes_spec_env_to_the_process(tmp_path: pathlib.Path) -> Non command=sys.executable, args=[str(server)], env={"MCP_SWAP_PROBE": "1"} ) - assert mcp_swap.preflight_spec(spec, timeout=60) is None + assert build.preflight_spec(spec, timeout=60) is None # --------------------------------------------------------------------------- @@ -2904,7 +2906,7 @@ def test_symlinked_config_swap_and_revert_round_trip( assert info.config_path.is_symlink() assert backup.parent == info.config_path.parent assert json.loads(target.read_text())["mcpServers"]["tmux"]["command"] == str( - mcp_swap.profile_binary(fake_repo, "LibTmux.Mcp", "Debug") + build.profile_binary(fake_repo, "LibTmux.Mcp", "Debug") ) assert mcp_swap.cmd_revert(parser.parse_args(["revert", "--cli", "cursor"])) == 0 @@ -2984,7 +2986,7 @@ def test_relative_xdg_config_home_is_ignored( from any other directory reported the backup missing for good. """ monkeypatch.setenv("XDG_CONFIG_HOME", raw) - assert mcp_swap._xdg_config_home() == pathlib.Path.home() / ".config" + assert xdg.config_home() == pathlib.Path.home() / ".config" def test_absolute_xdg_config_home_is_honoured( @@ -2992,7 +2994,7 @@ def test_absolute_xdg_config_home_is_honoured( ) -> None: """Opencode resolves XDG the way its own loader does.""" monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) - assert mcp_swap._xdg_config_home() == tmp_path + assert xdg.config_home() == tmp_path def test_opencode_and_pi_registered() -> None: @@ -3024,7 +3026,7 @@ def test_new_cli_set_get_delete_roundtrip(cli: str, fake_repo: pathlib.Path) -> """ config: dict[str, t.Any] = {} spec = mcp_swap.McpServerSpec( - command=str(mcp_swap.profile_binary(fake_repo, "LibTmux.Mcp", "Debug")) + command=str(build.profile_binary(fake_repo, "LibTmux.Mcp", "Debug")) ) assert mcp_swap.set_server(cli, config, "tmux", spec, fake_repo) == "added" assert mcp_swap.CLIS[cli].container[0] in config @@ -3256,7 +3258,7 @@ def test_pi_config_with_comments_is_readable( servers = jsonc.loads(text)["mcpServers"] assert servers["keep"]["command"] == "echo" assert servers["tmux"]["command"] == str( - mcp_swap.profile_binary(fake_repo, "LibTmux.Mcp", "Debug") + build.profile_binary(fake_repo, "LibTmux.Mcp", "Debug") ) diff --git a/eng/mcp/xdg.py b/eng/mcp/xdg.py new file mode 100644 index 0000000..5708edc --- /dev/null +++ b/eng/mcp/xdg.py @@ -0,0 +1,40 @@ +"""Resolve the XDG base directories this tool writes into. + +Both the config files a swap edits and the releases it installs are located +this way, from two modules, so the rules for reading those variables live in +one place rather than being remembered twice. +""" + +from __future__ import annotations + +import os +import pathlib + + +def state_home() -> pathlib.Path: + """Resolve ``$XDG_STATE_HOME`` per the XDG Base Directory spec. + + Defaults to ``~/.local/state`` when the env var is unset or empty. + State is the right XDG bucket here (vs. cache / config / data): the + file is machine-written, must persist across runs so ``revert`` can + locate the right backup, but is not safely deletable like cache nor + user-edited like config. + """ + env = os.environ.get("XDG_STATE_HOME") + if env: + return pathlib.Path(env) + return pathlib.Path.home() / ".local" / "state" + + +def config_home() -> pathlib.Path: + """``$XDG_CONFIG_HOME`` when absolute, else ``~/.config``. + + The spec requires these variables to be absolute and says to ignore + them otherwise. A relative value would resolve against the working + directory, so the swap would record a backup path that revert could + no longer find from anywhere else. + """ + raw = os.environ.get("XDG_CONFIG_HOME") + if raw and pathlib.Path(raw).is_absolute(): + return pathlib.Path(raw) + return pathlib.Path.home() / ".config" From 7cc36720f7315bd3842677b3b92acd11d07aad2a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 16:33:22 -0500 Subject: [PATCH 080/129] Docs(fix[api]): Regenerate the reference for the wait channel why: The API reference is rendered from the built XML documentation and checked in CI. Adding TmuxWaitChannel left it describing a surface that no longer matched the assemblies. what: - re-render docs/api/README.md --- docs/api/README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/api/README.md b/docs/api/README.md index ad2b931..cecaf0c 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -154,6 +154,7 @@ modes differ. | `LibTmux.TmuxTransportException` | Reports a process-transport failure. | | `LibTmux.TmuxVersion` | Represents one lossless parsed tmux version. | | `LibTmux.TmuxVersionTooLowException` | Reports an unsupported tmux version. | +| `LibTmux.TmuxWaitChannel` | An open wait on a tmux wait-for channel. | | `LibTmux.TmuxWaitMode` | What to do with a wait-for channel. | | `LibTmux.TmuxWaitTimeoutException` | Reports an expired bounded wait. | | `LibTmux.TmuxWindowException` | Thrown when a window operation is refused before tmux sees it. | @@ -337,6 +338,7 @@ modes differ. | `LibTmux.Server.LockAsync(System.Threading.CancellationToken)` | Locks every client attached to this server. | | `LibTmux.Server.LockClientAsync(System.String,System.Threading.CancellationToken)` | Locks one client. | | `LibTmux.Server.Open(LibTmux.ServerConnectionOptions)` | Opens an unmaterialized server connection handle. | +| `LibTmux.Server.OpenWaitChannel(System.String)` | Opens a wait on a channel that survives a timed attempt. | | `LibTmux.Server.RaiseIfDeadAsync(System.Threading.CancellationToken)` | Throws unless a tmux server is answering. | | `LibTmux.Server.RefreshClientAsync(System.String,System.Boolean,System.Threading.CancellationToken)` | Redraws one client. | | `LibTmux.Server.RunShellAsync(LibTmux.RunShellRequest,System.Threading.CancellationToken)` | Runs a shell command and reports what it printed. | @@ -554,6 +556,8 @@ modes differ. | `LibTmux.TmuxVersion.op_LessThan(LibTmux.TmuxVersion,LibTmux.TmuxVersion)` | Reports whether the left version is older. | | `LibTmux.TmuxVersion.op_LessThanOrEqual(LibTmux.TmuxVersion,LibTmux.TmuxVersion)` | Reports whether the left version is at most the right version. | | `LibTmux.TmuxVersionTooLowException.#ctor(System.String,LibTmux.TmuxVersion,LibTmux.TmuxVersion,System.Exception)` | Initializes an unsupported-version exception. | +| `LibTmux.TmuxWaitChannel.DisposeAsync` | Withdraws the waiter from tmux. | +| `LibTmux.TmuxWaitChannel.WaitAsync(System.TimeSpan,System.Threading.CancellationToken)` | Waits for the signal, giving this attempt a budget. | | `LibTmux.TmuxWaitTimeoutException.#ctor(System.String,System.TimeSpan,System.Exception)` | Initializes a wait-timeout exception. | | `LibTmux.TmuxWindowException.#ctor(System.String,LibTmux.WindowId,System.Exception)` | Initializes the exception for one window. | | `LibTmux.UnbindKeyRequest.#ctor(System.String,System.String,System.Boolean,System.Boolean)` | Initializes a request to remove a binding. | @@ -1095,6 +1099,8 @@ modes differ. | `LibTmux.TmuxVersion.Suffix` | Gets the exact preserved suffix projection. | | `LibTmux.TmuxVersionTooLowException.ActualVersion` | Gets the actual tmux version. | | `LibTmux.TmuxVersionTooLowException.RequiredVersion` | Gets the required tmux version. | +| `LibTmux.TmuxWaitChannel.Channel` | Gets the channel being waited on. | +| `LibTmux.TmuxWaitChannel.Signalled` | Gets whether something really signalled the channel. | | `LibTmux.TmuxWaitTimeoutException.Timeout` | Gets the expired timeout. | | `LibTmux.TmuxWindowException.WindowId` | Gets the window the request named. | | `LibTmux.UnbindKeyRequest.All` | Gets whether every binding in the table goes. | From 91748d250ed29114d1ff7be30b614bba7d1717a6 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 16:33:44 -0500 Subject: [PATCH 081/129] Docs(fix[contributing]): Say which tmux the tests should use why: The tests spawn the tmux their own PATH resolves, and a command they send into a pane resolves it again through that pane's interactive shell. Another port's version-matrix install earlier on that PATH makes the two different binaries, and the mismatch surfaces as tmux_run timing out with no exit status -- which reads as a library bug. what: - say to set LIBTMUX_TMUX on a machine carrying more than one tmux - name the symptom, so the next person recognises it --- .github/CONTRIBUTING.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 6b6df55..08f9327 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -49,6 +49,19 @@ You also need a real `tmux`, version 3.2a or newer. The suite drives one rather than mocking it, because this library's job is being right about tmux and only tmux can say whether it is. +Name that tmux explicitly when the machine has more than one: + +```console +$ export LIBTMUX_TMUX=/usr/local/bin/tmux +``` + +Without it the tests spawn whatever `tmux` their own `PATH` resolves, while a +command they send into a pane resolves it again through that pane's +interactive shell. A version-matrix install earlier on the interactive `PATH` +makes those two different binaries, and a client cannot talk to a server of +another version. What you see is `tmux_run` timing out with no exit status, +which reads as a library bug rather than as two tmuxes. + ## Own your tmux socket root Give this repository a socket root of its own before running anything: From 91d7c6975b577c9f0a347be41f379d27001d3f8f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 16:48:32 -0500 Subject: [PATCH 082/129] Materialization(perf[reads]): Read one entity with one command why: Every single-entity read listed the whole server and filtered the rows in memory, so refreshing one pane on a fifty-pane server read fifty panes. Every mutation that returns a replacement handle pays that read, which made the cost of a resize grow with what the server holds. tmux resolves a $, @ or % identifier against the whole server and never against the caller's current session, so a targeted read inherits nothing a listing was avoiding. display-message renders the same projection: all 123 fields on 3.2a and all 136 on 3.7c come back byte-identical to the listing row, except #{line}, which is a listing row index. display-message declares its target CMD_FIND_CANFAIL and exits zero on a target it cannot resolve. An unresolvable target answers with every entity field empty; one that resolves only in part -- a live session naming a window that is gone -- answers with that session's current window or pane. Requiring the identifier back is what separates either from the entity being there. what: - Rebuild MaterializationQuery.FetchOneAsync on display-message, trying a session-scoped target before the bare identifier and requiring the identifier back from both - Move RelationReader out of Session.Relations.cs and give it FindAsync and CapturedSession - Route Pane, Window and Session refresh, Pane.FromEnvironmentAsync, and the created-pane and created-session reads through it - Rebuild TmuxEntityLookup on the same targeted read - Name a window or pane inside its session with TmuxTarget.In, so a refreshed handle stays in the session it was read in - Cover a killed pane refusing to answer with the surviving one --- src/LibTmux/Internal/TmuxEntityLookup.cs | 111 ++++++------- src/LibTmux/Materialization/RelationReader.cs | 110 +++++++++++++ .../TmuxMaterializationQuery.cs | 153 ++++++++++++------ src/LibTmux/Pane.Environment.cs | 28 ++-- src/LibTmux/Pane.Snapshot.cs | 21 ++- src/LibTmux/Pane.Topology.cs | 16 +- src/LibTmux/Query/NativeFilterSearch.cs | 2 + src/LibTmux/Server.Collections.cs | 2 + src/LibTmux/Server.Lifecycle.cs | 19 ++- src/LibTmux/Session.Lifecycle.cs | 17 +- src/LibTmux/Session.Relations.cs | 75 --------- src/LibTmux/Targets/TmuxTarget.cs | 15 ++ src/LibTmux/Window.State.cs | 26 +-- .../Hierarchy/PaneOperationsTests.cs | 17 ++ .../Materialization/MaterializationTests.cs | 4 +- .../Parity/Component04ParityTests.cs | 6 +- .../CompositeMutationDispatchTests.cs | 24 ++- 17 files changed, 399 insertions(+), 247 deletions(-) create mode 100644 src/LibTmux/Materialization/RelationReader.cs diff --git a/src/LibTmux/Internal/TmuxEntityLookup.cs b/src/LibTmux/Internal/TmuxEntityLookup.cs index 3dea7ab..ad699ba 100644 --- a/src/LibTmux/Internal/TmuxEntityLookup.cs +++ b/src/LibTmux/Internal/TmuxEntityLookup.cs @@ -1,6 +1,10 @@ namespace LibTmux.Internal; /// Resolves stable entity identifiers without materializing collections. +/// +/// Each lookup asks tmux to resolve one identifier rather than listing the +/// server, so the cost does not grow with what the server holds. +/// internal sealed class TmuxEntityLookup( Func, CancellationToken, Task> execute) { @@ -10,92 +14,91 @@ internal sealed class TmuxEntityLookup( SessionId id, CancellationToken cancellationToken) { - TmuxCommandResult result = await execute( - ["list-sessions", "-F", $"{GenerationFormat}\t#{{session_id}}"], + (ServerGeneration Generation, string Text)? found = await FindAsync( + id.ToString(), + "session_id", + "session", cancellationToken).ConfigureAwait(false); - EnsureSuccessful(result, "session lookup"); - foreach (string line in result.StandardOutputLines) + if (found is not (ServerGeneration generation, string text)) { - (ServerGeneration Generation, string Text) fields = ParseIdentityRow(line, "session"); - if (!SessionId.TryParse(fields.Text, out SessionId candidate)) - { - throw new InvalidDataException("tmux reported a malformed session identifier."); - } - - if (candidate == id) - { - return (fields.Generation, candidate); - } + return null; } - return null; + return SessionId.TryParse(text, out SessionId candidate) && candidate == id + ? (generation, candidate) + : throw new InvalidDataException("tmux reported a malformed session identifier."); } internal async Task<(ServerGeneration Generation, WindowId Id)?> FindWindowAsync( WindowId id, CancellationToken cancellationToken) { - TmuxCommandResult result = await execute( - ["list-windows", "-a", "-F", $"{GenerationFormat}\t#{{window_id}}"], + (ServerGeneration Generation, string Text)? found = await FindAsync( + id.ToString(), + "window_id", + "window", cancellationToken).ConfigureAwait(false); - EnsureSuccessful(result, "window lookup"); - var seen = new HashSet<(ServerGeneration Generation, WindowId Id)>(); - foreach (string line in result.StandardOutputLines) + if (found is not (ServerGeneration generation, string text)) { - (ServerGeneration Generation, string Text) fields = ParseIdentityRow(line, "window"); - if (!WindowId.TryParse(fields.Text, out WindowId candidate)) - { - throw new InvalidDataException("tmux reported a malformed window identifier."); - } - - var identity = (fields.Generation, candidate); - if (seen.Add(identity) && candidate == id) - { - return identity; - } + return null; } - return null; + return WindowId.TryParse(text, out WindowId candidate) && candidate == id + ? (generation, candidate) + : throw new InvalidDataException("tmux reported a malformed window identifier."); } internal async Task<(ServerGeneration Generation, PaneId Id)?> FindPaneAsync( PaneId id, CancellationToken cancellationToken) { - TmuxCommandResult result = await execute( - ["list-panes", "-a", "-F", $"{GenerationFormat}\t#{{pane_id}}"], + (ServerGeneration Generation, string Text)? found = await FindAsync( + id.ToString(), + "pane_id", + "pane", cancellationToken).ConfigureAwait(false); - EnsureSuccessful(result, "pane lookup"); - var seen = new HashSet<(ServerGeneration Generation, PaneId Id)>(); - foreach (string line in result.StandardOutputLines) + if (found is not (ServerGeneration generation, string text)) { - (ServerGeneration Generation, string Text) fields = ParseIdentityRow(line, "pane"); - if (!PaneId.TryParse(fields.Text, out PaneId candidate)) - { - throw new InvalidDataException("tmux reported a malformed pane identifier."); - } - - var identity = (fields.Generation, candidate); - if (seen.Add(identity) && candidate == id) - { - return identity; - } + return null; } - return null; + return PaneId.TryParse(text, out PaneId candidate) && candidate == id + ? (generation, candidate) + : throw new InvalidDataException("tmux reported a malformed pane identifier."); } - private static (ServerGeneration Generation, string Text) ParseIdentityRow( - string line, - string kind) + private async Task<(ServerGeneration Generation, string Text)?> FindAsync( + string target, + string idWireName, + string kind, + CancellationToken cancellationToken) { - string[] fields = line.Split('\t'); + TmuxCommandResult result = await execute( + [ + "display-message", + "-p", + "-t", + target, + $"{GenerationFormat}\t#{{{idWireName}}}", + ], + cancellationToken).ConfigureAwait(false); + EnsureSuccessful(result, $"{kind} lookup"); + if (result.StandardOutputLines.Count != 1) + { + throw new InvalidDataException($"tmux reported a malformed {kind} identity row."); + } + + string[] fields = result.StandardOutputLines[0].Split('\t'); if (fields.Length != 2) { throw new InvalidDataException($"tmux reported a malformed {kind} identity row."); } - return (TmuxConnection.ParseGeneration(fields[0]), fields[1]); + // display-message resolves its target with CMD_FIND_CANFAIL, so a + // target tmux cannot find leaves the identifier empty and still exits + // zero. The server's own fields resolve either way. + ServerGeneration generation = TmuxConnection.ParseGeneration(fields[0]); + return fields[1].Length == 0 ? null : (generation, fields[1]); } private static void EnsureSuccessful(TmuxCommandResult result, string operation) diff --git a/src/LibTmux/Materialization/RelationReader.cs b/src/LibTmux/Materialization/RelationReader.cs new file mode 100644 index 0000000..d8d2621 --- /dev/null +++ b/src/LibTmux/Materialization/RelationReader.cs @@ -0,0 +1,110 @@ +using System.Runtime.Versioning; + +namespace LibTmux.Internal; + +/// Reads one live relation and rebuilds owned entity handles. +/// +/// Relation reads go through the same projection and materializer as a +/// snapshot, so a live child carries the same fields a captured one does. +/// +internal static class RelationReader +{ + [UnsupportedOSPlatform("windows")] + internal static Task>> ListAsync( + Server owner, + string listCommand, + IReadOnlyList extraArguments, + CancellationToken cancellationToken) + { + var context = new MaterializationContext(owner, ParseVersion(owner)); + return new MaterializationQuery(context) + .FetchAsync(listCommand, extraArguments, cancellationToken); + } + + /// Reads the session a handle was materialized in. + /// The fields captured with the handle, or null. + /// The captured session, or null when none was captured. + internal static SessionId? CapturedSession(IReadOnlyDictionary? snapshot) => + snapshot is not null + && snapshot.TryGetValue("session_id", out string? text) + && SessionId.TryParse(text, out SessionId id) + ? id + : null; + + /// Reads the one entity a tmux identifier resolves to. + /// The server that owns the entity. + /// The list-* subcommand naming the projection. + /// The format token identifying the entity. + /// The entity's tmux identifier. + /// The entity scoped to one session, tried first. + /// Cancels the tmux commands. + /// The row, or null when tmux no longer has the entity. + [UnsupportedOSPlatform("windows")] + internal static Task?> FindAsync( + Server owner, + string listCommand, + string idWireName, + string identifier, + TmuxTarget? inSession, + CancellationToken cancellationToken) + { + var context = new MaterializationContext(owner, ParseVersion(owner)); + return new MaterializationQuery(context) + .FetchOneAsync(listCommand, idWireName, identifier, inSession, cancellationToken); + } + + [UnsupportedOSPlatform("windows")] + internal static Window ToWindow(Server owner, IReadOnlyDictionary row) + { + EntityMaterializationState state = Capture(owner, row); + return new Window( + owner, + Connection(owner), + state.Generation, + state.WindowId ?? throw new InvalidDataException("tmux row carries no window."), + state.RawFields); + } + + [UnsupportedOSPlatform("windows")] + internal static Pane ToPane(Server owner, IReadOnlyDictionary row) + { + EntityMaterializationState state = Capture(owner, row); + if (!PaneId.TryParse( + state.RawFields.TryGetValue("pane_id", out string? text) ? text : null, + out PaneId id)) + { + throw new InvalidDataException("tmux row carries no pane."); + } + + return new Pane(owner, Connection(owner), state.Generation, id, state.RawFields); + } + + [UnsupportedOSPlatform("windows")] + internal static Session ToSession(Server owner, IReadOnlyDictionary row) + { + EntityMaterializationState state = Capture(owner, row); + return new Session( + owner, + Connection(owner), + state.Generation, + state.SessionId ?? throw new InvalidDataException("tmux row carries no session."), + state.RawFields); + } + + private static EntityMaterializationState Capture( + Server owner, + IReadOnlyDictionary row) => + Materializer.CreateState(new MaterializationContext(owner, ParseVersion(owner)), row); + + private static TmuxConnection Connection(Server owner) => + owner.Connection + ?? throw new InvalidOperationException("The server has no connection."); + + private static TmuxVersion ParseVersion(Server owner) + { + string raw = owner.RawVersion + ?? throw new InvalidOperationException("The server reported no tmux version."); + return TmuxVersion.Parse( + raw.StartsWith("tmux ", StringComparison.Ordinal) ? raw[5..] : raw); + } +} diff --git a/src/LibTmux/Materialization/TmuxMaterializationQuery.cs b/src/LibTmux/Materialization/TmuxMaterializationQuery.cs index 1b9019d..6a7ab38 100644 --- a/src/LibTmux/Materialization/TmuxMaterializationQuery.cs +++ b/src/LibTmux/Materialization/TmuxMaterializationQuery.cs @@ -3,13 +3,12 @@ namespace LibTmux.Internal; /// -/// Runs one tmux list command and materializes its framed rows. +/// Runs one tmux read and materializes its framed rows. /// /// -/// A single-target lookup asks tmux for the whole listing and selects the row -/// itself. tmux resolves an ambiguous -t against the caller's current -/// session, which a library has no business inheriting, so selection happens -/// here against explicit identifiers instead. +/// A listing enumerates children; a single-target read asks tmux to resolve +/// one identifier. Both render the same projection, so a row means the same +/// thing whichever produced it. /// internal sealed class MaterializationQuery { @@ -33,12 +32,7 @@ internal MaterializationQuery(MaterializationContext context) CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(listCommand); - // Reading Generation first rejects an unmaterialized server before a - // command is ever dispatched. - ServerGeneration generation = _context.Generation; - FormatProjection projection = FormatProjection.Create( - listCommand, - _context.TmuxVersion); + FormatProjection projection = CreateProjection(listCommand); string[] arguments = [ listCommand, @@ -47,6 +41,104 @@ internal MaterializationQuery(MaterializationContext context) projection.Template, ]; + return await ExecuteAsync(listCommand, arguments, cancellationToken) + .ConfigureAwait(false); + } + + /// Fetches the row for exactly one tmux entity. + /// The list-* subcommand naming the projection. + /// The format token identifying the entity. + /// The entity's tmux identifier. + /// The entity scoped to one session, tried first. + /// Cancels the tmux commands. + /// The row, or null when tmux no longer has the entity. + /// + /// This costs one command whatever the server holds, where a listing costs + /// one row per entity on it. Reading the scoped target first keeps a + /// refreshed handle in the session its predecessor was read in; the bare + /// identifier still answers when the entity has left that session. + /// + [UnsupportedOSPlatform("windows")] + internal async Task?> FetchOneAsync( + string listCommand, + string idWireName, + string identifier, + TmuxTarget? inSession = null, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(listCommand); + ArgumentException.ThrowIfNullOrWhiteSpace(idWireName); + ArgumentException.ThrowIfNullOrWhiteSpace(identifier); + if (inSession is TmuxTarget scoped) + { + IReadOnlyDictionary? row = await ReadAsync( + listCommand, + idWireName, + identifier, + scoped, + cancellationToken) + .ConfigureAwait(false); + if (row is not null) + { + return row; + } + } + + return await ReadAsync( + listCommand, + idWireName, + identifier, + new TmuxTarget(identifier), + cancellationToken) + .ConfigureAwait(false); + } + + [UnsupportedOSPlatform("windows")] + private async Task?> ReadAsync( + string listCommand, + string idWireName, + string identifier, + TmuxTarget target, + CancellationToken cancellationToken) + { + FormatProjection projection = CreateProjection(listCommand); + string[] arguments = ["display-message", "-p", "-t", target.Value, projection.Template]; + + IReadOnlyList> rows = + await ExecuteAsync(listCommand, arguments, cancellationToken).ConfigureAwait(false); + if (rows.Count != 1) + { + throw new TmuxTransportException( + $"tmux answered a single-target read with {rows.Count} rows.", + arguments); + } + + // display-message declares its target CMD_FIND_CANFAIL and exits zero + // on one it cannot resolve. A target that resolves to nothing leaves + // every entity field empty; one that resolves only in part -- a session + // that still exists naming a window that does not -- answers with that + // session's current window or pane. Requiring the identifier back + // separates either from the entity being there. The server's own fields + // resolve throughout, so a stale generation is still rejected before + // absence is reported. + return rows[0].TryGetValue(idWireName, out string? id) + && string.Equals(id, identifier, StringComparison.Ordinal) + ? rows[0] + : null; + } + + private FormatProjection CreateProjection(string listCommand) => + FormatProjection.Create(listCommand, _context.TmuxVersion); + + [UnsupportedOSPlatform("windows")] + private async Task>> ExecuteAsync( + string listCommand, + string[] arguments, + CancellationToken cancellationToken) + { + // Reading Generation first rejects an unmaterialized server before a + // command is ever dispatched. + ServerGeneration generation = _context.Generation; TmuxConnection connection = _context.Server.Connection ?? throw new InvalidOperationException( "The server has no connection; connect before querying."); @@ -56,7 +148,7 @@ internal MaterializationQuery(MaterializationContext context) .ConfigureAwait(false); if (result.ExitCode != 0) { - throw new TmuxCommandException($"{listCommand} failed.", result); + throw new TmuxCommandException($"{arguments[0]} failed.", result); } try @@ -69,44 +161,9 @@ internal MaterializationQuery(MaterializationContext context) catch (InvalidDataException error) { throw new TmuxTransportException( - $"tmux returned an undecodable {listCommand} listing.", + $"tmux returned an undecodable {listCommand} projection.", arguments, error); } } - - /// Fetches exactly the row whose identifier matches. - /// A tmux list-* subcommand. - /// The identifying format token. - /// The identifier the row must carry. - /// Cancels the tmux command. - /// The matching row, or null when tmux has no such target. - [UnsupportedOSPlatform("windows")] - internal async Task?> FetchOneAsync( - string listCommand, - string idWireName, - string id, - CancellationToken cancellationToken = default) - { - ArgumentException.ThrowIfNullOrWhiteSpace(idWireName); - ArgumentException.ThrowIfNullOrWhiteSpace(id); - // "-a" makes window and pane listings span every session, so lookup - // never depends on which session tmux considers current. - string[] extra = listCommand is "list-windows" or "list-panes" ? ["-a"] : []; - IReadOnlyList> rows = - await FetchAsync(listCommand, extra, cancellationToken).ConfigureAwait(false); - foreach (IReadOnlyDictionary row in rows) - { - if (row.TryGetValue(idWireName, out string? candidate) - && string.Equals(candidate, id, StringComparison.Ordinal)) - { - return row; - } - } - - // A reachable server that lists no matching row is a missing target, - // which the caller must distinguish from an unreachable server; an - // unreachable server has already thrown from FetchAsync. - return null; - } } diff --git a/src/LibTmux/Pane.Environment.cs b/src/LibTmux/Pane.Environment.cs index 213f265..e448dc7 100644 --- a/src/LibTmux/Pane.Environment.cs +++ b/src/LibTmux/Pane.Environment.cs @@ -32,20 +32,18 @@ public static async Task FromEnvironmentAsync( // Materialize rather than resolve by identifier: callers reach for // Session and Window straight off this pane, and those relations are // served from the captured snapshot. - IReadOnlyList> rows = - await RelationReader.ListAsync(server, "list-panes", ["-a"], cancellationToken) - .ConfigureAwait(false); - string wanted = id.ToString(); - foreach (IReadOnlyDictionary row in rows) - { - if (row.TryGetValue("pane_id", out string? candidate) && candidate == wanted) - { - return RelationReader.ToPane(server, row); - } - } - - throw new TmuxObjectNotFoundException( - $"tmux no longer has pane '{wanted}'.", - wanted); + IReadOnlyDictionary row = await RelationReader + .FindAsync( + server, + "list-panes", + "pane_id", + id.ToString(), + inSession: null, + cancellationToken) + .ConfigureAwait(false) + ?? throw new TmuxObjectNotFoundException( + $"tmux no longer has pane '{id}'.", + id.ToString()); + return RelationReader.ToPane(server, row); } } diff --git a/src/LibTmux/Pane.Snapshot.cs b/src/LibTmux/Pane.Snapshot.cs index af801d0..d2ab396 100644 --- a/src/LibTmux/Pane.Snapshot.cs +++ b/src/LibTmux/Pane.Snapshot.cs @@ -1,5 +1,7 @@ using System.Runtime.Versioning; +using LibTmux.Internal; + namespace LibTmux; public sealed partial class Pane @@ -43,16 +45,21 @@ public sealed partial class Pane [UnsupportedOSPlatform("windows")] public async Task RefreshAsync(CancellationToken cancellationToken = default) { - // Listing by -t fails loudly on a pane that is already gone, which - // would report a command failure where the pane is simply missing. Server owner = Server; - IReadOnlyList> rows = await RelationReader - .ListAsync(owner, "list-panes", ["-a"], cancellationToken) - .ConfigureAwait(false); - return rows.Select(row => RelationReader.ToPane(owner, row)) - .FirstOrDefault(pane => pane.Id == _id) + IReadOnlyDictionary row = await RelationReader + .FindAsync( + owner, + "list-panes", + "pane_id", + _id.ToString(), + RelationReader.CapturedSession(_snapshot) is SessionId session + ? TmuxTarget.In(session, _id) + : null, + cancellationToken) + .ConfigureAwait(false) ?? throw new TmuxObjectNotFoundException( $"tmux no longer has pane '{_id}'.", _id.ToString()); + return RelationReader.ToPane(owner, row); } } diff --git a/src/LibTmux/Pane.Topology.cs b/src/LibTmux/Pane.Topology.cs index 53b0626..f803709 100644 --- a/src/LibTmux/Pane.Topology.cs +++ b/src/LibTmux/Pane.Topology.cs @@ -577,18 +577,20 @@ private async Task CreatePaneFromAsync( : throw new InvalidDataException("tmux reported no new pane identifier.")); Server owner = sequence.Observe(() => Server); - IReadOnlyList> rows = await sequence - .ObserveAsync(() => RelationReader.ListAsync( + IReadOnlyDictionary? row = await sequence + .ObserveAsync(() => RelationReader.FindAsync( owner, "list-panes", - ["-a"], + "pane_id", + created.ToString(), + inSession: null, cancellationToken)) .ConfigureAwait(false); return sequence.Observe(() => - rows.Select(row => RelationReader.ToPane(owner, row)) - .FirstOrDefault(pane => pane.Id == created) - ?? throw new TmuxObjectNotFoundException( + row is null + ? throw new TmuxObjectNotFoundException( $"tmux did not report the created pane '{created}'.", - created.ToString())); + created.ToString()) + : RelationReader.ToPane(owner, row)); } } diff --git a/src/LibTmux/Query/NativeFilterSearch.cs b/src/LibTmux/Query/NativeFilterSearch.cs index 0c1fba0..dee3fb1 100644 --- a/src/LibTmux/Query/NativeFilterSearch.cs +++ b/src/LibTmux/Query/NativeFilterSearch.cs @@ -1,5 +1,7 @@ using System.Runtime.Versioning; +using LibTmux.Internal; + namespace LibTmux; // Raw tmux filters bypass the closed field catalog; malformed filters yield no diff --git a/src/LibTmux/Server.Collections.cs b/src/LibTmux/Server.Collections.cs index 0054fb0..6ab8e2d 100644 --- a/src/LibTmux/Server.Collections.cs +++ b/src/LibTmux/Server.Collections.cs @@ -1,5 +1,7 @@ using System.Runtime.Versioning; +using LibTmux.Internal; + namespace LibTmux; // Session listings preserve historical any-failure leniency; window and pane diff --git a/src/LibTmux/Server.Lifecycle.cs b/src/LibTmux/Server.Lifecycle.cs index a0983b8..b241c4d 100644 --- a/src/LibTmux/Server.Lifecycle.cs +++ b/src/LibTmux/Server.Lifecycle.cs @@ -170,22 +170,21 @@ await sequence Server materialized = await sequence .ObserveAsync(() => RediscoverCurrentGenerationAsync(cancellationToken)) .ConfigureAwait(false); - IReadOnlyList> rows = await sequence - .ObserveAsync(() => RelationReader.ListAsync( + IReadOnlyDictionary? row = await sequence + .ObserveAsync(() => RelationReader.FindAsync( materialized, "list-sessions", - [], + "session_id", + sessionId.ToString(), + inSession: null, cancellationToken)) .ConfigureAwait(false); return sequence.Observe(() => - { - IEnumerable sessions = - rows.Select(row => RelationReader.ToSession(materialized, row)); - return sessions.FirstOrDefault(session => session.Id == sessionId) - ?? throw new TmuxObjectNotFoundException( + row is null + ? throw new TmuxObjectNotFoundException( $"tmux did not report the created session '{sessionId}'.", - sessionId.ToString()); - }); + sessionId.ToString()) + : RelationReader.ToSession(materialized, row)); } /// Starts a server and takes ownership of it. diff --git a/src/LibTmux/Session.Lifecycle.cs b/src/LibTmux/Session.Lifecycle.cs index 4e559a2..b9765a1 100644 --- a/src/LibTmux/Session.Lifecycle.cs +++ b/src/LibTmux/Session.Lifecycle.cs @@ -45,17 +45,20 @@ is string attached [UnsupportedOSPlatform("windows")] public async Task RefreshAsync(CancellationToken cancellationToken = default) { - // A refresh that cannot reach the server must say so, not report the - // session as gone, so this uses the throwing listing path. Server owner = RequireOwner("refresh"); - IReadOnlyList> rows = await RelationReader - .ListAsync(owner, "list-sessions", [], cancellationToken) - .ConfigureAwait(false); - IEnumerable sessions = rows.Select(row => RelationReader.ToSession(owner, row)); - return sessions.FirstOrDefault(session => session.Id == _id) + IReadOnlyDictionary row = await RelationReader + .FindAsync( + owner, + "list-sessions", + "session_id", + _id.ToString(), + inSession: null, + cancellationToken) + .ConfigureAwait(false) ?? throw new TmuxObjectNotFoundException( $"tmux no longer has session '{_id}'.", _id.ToString()); + return RelationReader.ToSession(owner, row); } /// Renames this session. diff --git a/src/LibTmux/Session.Relations.cs b/src/LibTmux/Session.Relations.cs index 057b5ae..7015d65 100644 --- a/src/LibTmux/Session.Relations.cs +++ b/src/LibTmux/Session.Relations.cs @@ -157,78 +157,3 @@ private TmuxConnection RequireConnection() => ? value : null; } - -/// Reads one live relation and rebuilds owned entity handles. -/// -/// Relation reads go through the same projection and materializer as a -/// snapshot, so a live child carries the same fields a captured one does. -/// -internal static class RelationReader -{ - [UnsupportedOSPlatform("windows")] - internal static Task>> ListAsync( - Server owner, - string listCommand, - IReadOnlyList extraArguments, - CancellationToken cancellationToken) - { - var context = new MaterializationContext(owner, ParseVersion(owner)); - return new MaterializationQuery(context) - .FetchAsync(listCommand, extraArguments, cancellationToken); - } - - [UnsupportedOSPlatform("windows")] - internal static Window ToWindow(Server owner, IReadOnlyDictionary row) - { - EntityMaterializationState state = Capture(owner, row); - return new Window( - owner, - Connection(owner), - state.Generation, - state.WindowId ?? throw new InvalidDataException("tmux row carries no window."), - state.RawFields); - } - - [UnsupportedOSPlatform("windows")] - internal static Pane ToPane(Server owner, IReadOnlyDictionary row) - { - EntityMaterializationState state = Capture(owner, row); - if (!PaneId.TryParse( - state.RawFields.TryGetValue("pane_id", out string? text) ? text : null, - out PaneId id)) - { - throw new InvalidDataException("tmux row carries no pane."); - } - - return new Pane(owner, Connection(owner), state.Generation, id, state.RawFields); - } - - [UnsupportedOSPlatform("windows")] - internal static Session ToSession(Server owner, IReadOnlyDictionary row) - { - EntityMaterializationState state = Capture(owner, row); - return new Session( - owner, - Connection(owner), - state.Generation, - state.SessionId ?? throw new InvalidDataException("tmux row carries no session."), - state.RawFields); - } - - private static EntityMaterializationState Capture( - Server owner, - IReadOnlyDictionary row) => - Materializer.CreateState(new MaterializationContext(owner, ParseVersion(owner)), row); - - private static TmuxConnection Connection(Server owner) => - owner.Connection - ?? throw new InvalidOperationException("The server has no connection."); - - private static TmuxVersion ParseVersion(Server owner) - { - string raw = owner.RawVersion - ?? throw new InvalidOperationException("The server reported no tmux version."); - return TmuxVersion.Parse( - raw.StartsWith("tmux ", StringComparison.Ordinal) ? raw[5..] : raw); - } -} diff --git a/src/LibTmux/Targets/TmuxTarget.cs b/src/LibTmux/Targets/TmuxTarget.cs index 0bd21ec..4e4f379 100644 --- a/src/LibTmux/Targets/TmuxTarget.cs +++ b/src/LibTmux/Targets/TmuxTarget.cs @@ -7,4 +7,19 @@ internal readonly record struct TmuxTarget(string Value) internal static TmuxTarget From(WindowId id) => new(id.ToString()); internal static TmuxTarget From(PaneId id) => new(id.ToString()); + + /// Names a window inside one session. + /// + /// A window linked into several sessions resolves from a bare identifier to + /// whichever session tmux ranks best, which need not be the one a handle + /// was read in. Naming the session keeps the answer where the caller is. + /// + internal static TmuxTarget In(SessionId session, WindowId id) => new($"{session}:{id}"); + + /// Names a pane inside one session. + /// + /// The empty window part asks tmux to resolve the pane identifier globally + /// and keep the session, which is what a linked window needs. + /// + internal static TmuxTarget In(SessionId session, PaneId id) => new($"{session}:.{id}"); } diff --git a/src/LibTmux/Window.State.cs b/src/LibTmux/Window.State.cs index 5e7cae1..b5eef43 100644 --- a/src/LibTmux/Window.State.cs +++ b/src/LibTmux/Window.State.cs @@ -1,6 +1,8 @@ using System.Globalization; using System.Runtime.Versioning; +using LibTmux.Internal; + namespace LibTmux; // Provides captured window state and refresh. @@ -58,22 +60,22 @@ public sealed partial class Window [UnsupportedOSPlatform("windows")] public async Task RefreshAsync(CancellationToken cancellationToken = default) { - // Listing by -t would return the whole session and would fail loudly on - // a window that is already gone, so the whole server is listed and the - // row is selected here. A linked window yields one row per session. Server owner = RequireOwner("refresh"); - IReadOnlyList> rows = await RelationReader - .ListAsync(owner, "list-windows", ["-a"], cancellationToken) - .ConfigureAwait(false); - IReadOnlyList windows = [.. rows - .Select(row => RelationReader.ToWindow(owner, row)) - .Where(window => window.Id == _id)]; - string? session = ReadSnapshot("session_id"); - return windows.FirstOrDefault(window => window.ReadSnapshot("session_id") == session) - ?? (windows.Count > 0 ? windows[0] : null) + IReadOnlyDictionary row = await RelationReader + .FindAsync( + owner, + "list-windows", + "window_id", + _id.ToString(), + RelationReader.CapturedSession(_snapshot) is SessionId session + ? TmuxTarget.In(session, _id) + : null, + cancellationToken) + .ConfigureAwait(false) ?? throw new TmuxObjectNotFoundException( $"tmux no longer has window '{_id}'.", _id.ToString()); + return RelationReader.ToWindow(owner, row); } private int ReadCapturedInt(string wireName, string relation) => diff --git a/tests/LibTmux.IntegrationTests/Hierarchy/PaneOperationsTests.cs b/tests/LibTmux.IntegrationTests/Hierarchy/PaneOperationsTests.cs index 86649d6..04e735a 100644 --- a/tests/LibTmux.IntegrationTests/Hierarchy/PaneOperationsTests.cs +++ b/tests/LibTmux.IntegrationTests/Hierarchy/PaneOperationsTests.cs @@ -524,6 +524,23 @@ private static Task ConnectAsync( logger: logger), token); + [UnixFact] + public async Task Killed_pane_is_a_raising_tombstone() + { + await using RawTmuxTestContext raw = await RawTmuxTestContext.StartAsync( + TestContext.Current.CancellationToken); + CancellationToken token = TestContext.Current.CancellationToken; + Pane survivor = await FirstPaneAsync(raw, token); + Pane doomed = await survivor.SplitAsync(new SplitPaneRequest(), token); + + await doomed.KillAsync(cancellationToken: token); + + // A session-scoped target answers with that session's current pane once + // the pane it names is gone, so refresh has to say the pane is missing + // rather than hand back the survivor. + await Assert.ThrowsAsync(() => doomed.RefreshAsync(token)); + } + private static async Task FirstPaneAsync( RawTmuxTestContext raw, CancellationToken token) => diff --git a/tests/LibTmux.IntegrationTests/Materialization/MaterializationTests.cs b/tests/LibTmux.IntegrationTests/Materialization/MaterializationTests.cs index 4a56572..82d7459 100644 --- a/tests/LibTmux.IntegrationTests/Materialization/MaterializationTests.cs +++ b/tests/LibTmux.IntegrationTests/Materialization/MaterializationTests.cs @@ -131,7 +131,7 @@ public async Task Window_and_pane_lookup_use_tmux_canonical_session() "list-panes", "pane_id", paneId, - TestContext.Current.CancellationToken); + cancellationToken: TestContext.Current.CancellationToken); // The pane resolves even though another session exists and tmux has no // attached client to make either session "current". @@ -151,7 +151,7 @@ public async Task Missing_target_is_distinct_from_unreachable_server() "list-panes", "pane_id", "%99999", - TestContext.Current.CancellationToken); + cancellationToken: TestContext.Current.CancellationToken); Assert.Null(missing); await fixture.RunAsync(["kill-server"], allowFailure: true); diff --git a/tests/LibTmux.IntegrationTests/Parity/Component04ParityTests.cs b/tests/LibTmux.IntegrationTests/Parity/Component04ParityTests.cs index 7e17100..8f03a4c 100644 --- a/tests/LibTmux.IntegrationTests/Parity/Component04ParityTests.cs +++ b/tests/LibTmux.IntegrationTests/Parity/Component04ParityTests.cs @@ -298,7 +298,7 @@ private static async Task ProvesSessionAsync( "list-sessions", "session_id", await FirstIdAsync(query, "list-sessions", "session_id"), - TestContext.Current.CancellationToken); + cancellationToken: TestContext.Current.CancellationToken); return row is not null && Materializer.MaterializeSession(context, row).Id.Value >= 0; } @@ -313,7 +313,7 @@ private static async Task ProvesWindowAsync( "list-windows", "window_id", id, - TestContext.Current.CancellationToken); + cancellationToken: TestContext.Current.CancellationToken); if (row is null) { return false; @@ -336,7 +336,7 @@ private static async Task ProvesPaneAsync( "list-panes", "pane_id", id, - TestContext.Current.CancellationToken); + cancellationToken: TestContext.Current.CancellationToken); if (row is null) { return false; diff --git a/tests/LibTmux.UnitTests/Entities/CompositeMutationDispatchTests.cs b/tests/LibTmux.UnitTests/Entities/CompositeMutationDispatchTests.cs index 779457d..5a655d2 100644 --- a/tests/LibTmux.UnitTests/Entities/CompositeMutationDispatchTests.cs +++ b/tests/LibTmux.UnitTests/Entities/CompositeMutationDispatchTests.cs @@ -16,7 +16,7 @@ public async Task Layout_refresh_failure_is_unknown_after_the_layout_changed() Window window = CreateWindow((request, _) => { string[] arguments = [.. request.LogicalArguments]; - if (arguments.Contains("list-windows", StringComparer.Ordinal)) + if (ActualCommand(arguments) == ProjectionRead) { throw NotDispatched(arguments, "refresh was not dispatched"); } @@ -192,9 +192,9 @@ public async Task Replaced_session_listing_failure_is_unknown_after_creation() request, $"{Generation.ProcessId}:{Generation.StartTime}\n")), "-V" => Task.FromResult(Success(request, "tmux 3.7\n")), - "list-sessions" => throw NotDispatched( + ProjectionRead => throw NotDispatched( arguments, - "session listing was not dispatched"), + "session read was not dispatched"), _ => Task.FromResult(Success(request)), }; }); @@ -212,7 +212,7 @@ public async Task Replaced_session_listing_failure_is_unknown_after_creation() "new-session", "display-message", "-V", - "list-sessions", + ProjectionRead, ], commands.ToArray()); } @@ -479,12 +479,22 @@ private static TmuxTransportException NotDispatched( string message) => new(message, arguments, TmuxDispatchState.NotDispatched); - private static string ActualCommand(string[] arguments) => - arguments.Contains("if-shell", StringComparer.Ordinal) + // display-message serves three purposes: probing the generation, expanding + // a format, and reading one entity. Only the read carries a framed template. + private const string ProjectionRead = "read-one"; + + private static string ActualCommand(string[] arguments) + { + string command = arguments.Contains("if-shell", StringComparer.Ordinal) ? arguments.Last(static argument => argument is "display-message" or "list-sessions" or "list-windows" or "list-panes" or "new-window") : arguments[0]; + return command == "display-message" + && arguments[^1].Contains(FormatProjection.RowSeparator, StringComparison.Ordinal) + ? ProjectionRead + : command; + } private static TmuxCommandResult Success( TmuxCommandRequest request, @@ -536,7 +546,7 @@ private static Func Task.FromResult(Success(request, "tmux 3.7\n")), - "list-sessions" => Task.FromResult(Success( + ProjectionRead => Task.FromResult(Success( request, SessionListing(discovered, "$2", "created"), discovered)), From 52f0742ea0edfaf256c41d372340508709614573 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 16:50:29 -0500 Subject: [PATCH 083/129] Api(fix[visibility]): Stop shipping a type the contract calls internal why: TmuxCommandContext was declared public in the LibTmux.Internal namespace, so it was a supported promise nobody could reach: its only constructor is internal, its Socket property is internal, and no public member returns or accepts one. The contract has always recorded it as internal, and nothing read both, which is the drift decision 0005 set out to end and then left unguarded. what: - Declare TmuxCommandContext internal and drop it from the analyzer baseline - Hold every member the contract calls internal to being absent from the analyzer baselines, which are generated from the built assembly --- eng/parity/verify_public_api.py | 58 +++++++++++++++++++++++++++++ src/LibTmux/Diagnostics/TmuxLog.cs | 2 +- src/LibTmux/PublicAPI.Unshipped.txt | 2 - 3 files changed, 59 insertions(+), 3 deletions(-) diff --git a/eng/parity/verify_public_api.py b/eng/parity/verify_public_api.py index e3f10b0..6772f6d 100644 --- a/eng/parity/verify_public_api.py +++ b/eng/parity/verify_public_api.py @@ -13,6 +13,7 @@ API_PATH = DOCUMENT_ROOT / "public-api.json" LEDGER_PATH = DOCUMENT_ROOT / "parity" / "parity-ledger.json" PACKAGES_PATH = pathlib.Path(__file__).parents[2] / "Directory.Packages.props" +SOURCE_ROOT = pathlib.Path(__file__).parents[2] / "src" PACKAGE_IDS = ["LibTmux", "LibTmux.Query.Json"] COMPONENT_IDS = set(range(1, 19)) ENTITY_IDS = { @@ -1331,6 +1332,63 @@ def validate(contract: dict[str, t.Any], ledger: dict[str, t.Any]) -> list[str]: validate_query(contract, violations) validate_examples_and_reachability(contract, violations) validate_ledger(contract, ledger, members, violations) + violations.extend(validate_visibility(members)) + return violations + + +def shipped_surface() -> set[str]: + """Read the declarations the Roslyn analyzer holds each assembly to. + + Returns + ------- + set[str] + One entry per approved declaration, without its return type. + + Examples + -------- + >>> "LibTmux.Pane" in shipped_surface() + True + """ + surface: set[str] = set() + for path in sorted(SOURCE_ROOT.glob("*/PublicAPI.*.txt")): + for line in path.read_text(encoding="utf-8").splitlines(): + entry = line.strip() + if entry and not entry.startswith("#"): + surface.add(entry.split(" -> ")[0].removeprefix("static ")) + return surface + + +def validate_visibility(members: dict[str, t.Any]) -> list[str]: + """Hold the contract's internal members to being absent from the assembly. + + The analyzer baselines are generated from the built assembly, so a member + the contract calls internal and the baseline lists is public in fact. That + disagreement is invisible to the analyzer, which never reads the contract, + and to the rest of this file, which never reads the assembly. + + Parameters + ---------- + members + Contract members keyed by member id. + + Returns + ------- + list[str] + One violation per member the contract and the assembly disagree on. + + Examples + -------- + >>> validate_visibility({}) + [] + """ + surface = shipped_surface() + violations = [] + for member_id, member in sorted(members.items()): + if member.get("visibility") != "internal": + continue + declaration = member_id[2:].split("(")[0].replace("`1", "") + if any(entry.startswith(declaration) for entry in surface): + violations.append(f"contract calls {member_id} internal; the assembly ships it") return violations diff --git a/src/LibTmux/Diagnostics/TmuxLog.cs b/src/LibTmux/Diagnostics/TmuxLog.cs index cd54f06..2ec42bb 100644 --- a/src/LibTmux/Diagnostics/TmuxLog.cs +++ b/src/LibTmux/Diagnostics/TmuxLog.cs @@ -10,7 +10,7 @@ namespace LibTmux.Internal; /// sites. Holding the logger alongside the socket it belongs to also keeps two /// servers in one process from writing each other's history. /// -public sealed class TmuxCommandContext +internal sealed class TmuxCommandContext { internal TmuxCommandContext(ILogger logger, string? socket) { diff --git a/src/LibTmux/PublicAPI.Unshipped.txt b/src/LibTmux/PublicAPI.Unshipped.txt index cda08f6..286b223 100644 --- a/src/LibTmux/PublicAPI.Unshipped.txt +++ b/src/LibTmux/PublicAPI.Unshipped.txt @@ -234,8 +234,6 @@ LibTmux.IncompleteSnapshotException LibTmux.IncompleteSnapshotException.CapturedDepth.get -> LibTmux.SnapshotDepth LibTmux.IncompleteSnapshotException.IncompleteSnapshotException(string! relation, LibTmux.SnapshotDepth capturedDepth) -> void LibTmux.IncompleteSnapshotException.Relation.get -> string! -LibTmux.Internal.TmuxCommandContext -LibTmux.Internal.TmuxCommandContext.Logger.get -> Microsoft.Extensions.Logging.ILogger! LibTmux.LibTmuxException LibTmux.LibTmuxException.Dispatch.get -> LibTmux.TmuxDispatchState LibTmux.LibTmuxException.LibTmuxException(string! message, LibTmux.TmuxDispatchState dispatch, System.Exception? innerException = null) -> void From c5b734096aeaf4dca39b5f30741e088a103571bf Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 16:53:43 -0500 Subject: [PATCH 084/129] Contract(fix[materialization]): Record what the query actually is why: The contract described MaterializationQuery as a static class whose FetchAsync and FetchOneAsync take a MaterializationContext argument. The class has been sealed and instance-scoped, with the context held as state, for as long as the code has existed, and FetchOneAsync returns null on a missing target rather than throwing. Nothing read both, so the record and the assembly drifted without saying so. A record that is knowingly wrong is worse than one that is merely stale. what: - Record MaterializationQuery as a sealed class holding a context - Record FetchAsync and FetchOneAsync with their shipped signatures and the behavior they have - Bring verify_public_api.py and its tests to the same shapes --- docs/public-api.json | 60 ++++++++++++----------------- docs/public-api.md | 6 +-- eng/parity/tests/test_public_api.py | 27 ++++--------- eng/parity/verify_public_api.py | 31 +++++++-------- 4 files changed, 48 insertions(+), 76 deletions(-) diff --git a/docs/public-api.json b/docs/public-api.json index fe0b9f4..4fef7ce 100644 --- a/docs/public-api.json +++ b/docs/public-api.json @@ -588,17 +588,19 @@ "id": "T:LibTmux.Internal.MaterializationQuery", "namespace": "LibTmux.Internal", "name": "MaterializationQuery", - "kind": "static class", + "kind": "sealed class", "package": "LibTmux", "modifiers": [ "internal", - "static" + "sealed" ], "baseType": "object", "interfaces": [], "ownership": "value", - "state": [], - "summary": "Acquires version-gated framed rows for materialization.", + "state": [ + "MaterializationContext" + ], + "summary": "Reads version-gated framed rows for materialization.", "behavior": { "requiredUniversalFields": [ "pid", @@ -5679,32 +5681,23 @@ "summary": "Creates materialization context for one owning server." }, { - "id": "M:LibTmux.Internal.MaterializationQuery.FetchAsync(MaterializationContext,string,IReadOnlyList?,string?,CancellationToken)", + "id": "M:LibTmux.Internal.MaterializationQuery.FetchAsync(string,IEnumerable?,CancellationToken)", "declaringType": "T:LibTmux.Internal.MaterializationQuery", "name": "FetchAsync", "kind": "method", "visibility": "internal", "package": "LibTmux", - "static": true, + "static": false, "genericParameters": [], "returnType": "Task>>", "parameters": [ - { - "name": "context", - "type": "MaterializationContext" - }, { "name": "listCommand", "type": "string" }, { - "name": "arguments", - "type": "IReadOnlyList?", - "default": "null" - }, - { - "name": "target", - "type": "string?", + "name": "extraArguments", + "type": "IEnumerable?", "default": "null" }, { @@ -5713,7 +5706,7 @@ "default": "default" } ], - "signature": "Task>> LibTmux.Internal.MaterializationQuery.FetchAsync(MaterializationContext context, string listCommand, IReadOnlyList? arguments = null, string? target = null, CancellationToken cancellationToken = default)", + "signature": "Task>> LibTmux.Internal.MaterializationQuery.FetchAsync(string listCommand, IEnumerable? extraArguments = null, CancellationToken cancellationToken = default)", "performsIO": true, "processBacked": true, "portable": false, @@ -5722,7 +5715,7 @@ ], "summary": "Acquires and decodes every version-gated row for one logical tmux list command.", "behavior": { - "projection": "FormatProjection.Create(listCommand, context.Server.Version)", + "projection": "FormatProjection.Create(listCommand, context.TmuxVersion)", "result": "all decoded rows as copied dictionaries", "rawValues": "Utf8BackslashDecoder after byte framing" }, @@ -5732,35 +5725,31 @@ } }, { - "id": "M:LibTmux.Internal.MaterializationQuery.FetchOneAsync(MaterializationContext,string,string,string,IReadOnlyList?,CancellationToken)", + "id": "M:LibTmux.Internal.MaterializationQuery.FetchOneAsync(string,string,string,TmuxTarget?,CancellationToken)", "declaringType": "T:LibTmux.Internal.MaterializationQuery", "name": "FetchOneAsync", "kind": "method", "visibility": "internal", "package": "LibTmux", - "static": true, + "static": false, "genericParameters": [], - "returnType": "Task>", + "returnType": "Task?>", "parameters": [ - { - "name": "context", - "type": "MaterializationContext" - }, { "name": "listCommand", "type": "string" }, { - "name": "targetId", + "name": "idWireName", "type": "string" }, { - "name": "idField", + "name": "identifier", "type": "string" }, { - "name": "arguments", - "type": "IReadOnlyList?", + "name": "inSession", + "type": "TmuxTarget?", "default": "null" }, { @@ -5769,17 +5758,18 @@ "default": "default" } ], - "signature": "Task> LibTmux.Internal.MaterializationQuery.FetchOneAsync(MaterializationContext context, string listCommand, string targetId, string idField, IReadOnlyList? arguments = null, CancellationToken cancellationToken = default)", + "signature": "Task?> LibTmux.Internal.MaterializationQuery.FetchOneAsync(string listCommand, string idWireName, string identifier, TmuxTarget? inSession = null, CancellationToken cancellationToken = default)", "performsIO": true, "processBacked": true, "portable": false, "platformAnnotations": [ "UnsupportedOSPlatform(\"windows\")" ], - "summary": "Acquires one canonical tmux target without conflating absence and server failure.", + "summary": "Reads one tmux entity without listing the server.", "behavior": { - "canonicalSession": "resolve session_name before list-windows or list-panes", - "missingTarget": "empty successful result throws TmuxObjectNotFoundException", + "read": "display-message -p -t target rendering the list command's projection", + "scoping": "a session-scoped target is read before the bare identifier", + "missingTarget": "a row that does not carry the identifier back returns null", "unreachableServer": "tmux or transport failure propagates distinctly" }, "failureMapping": { @@ -23524,7 +23514,7 @@ "kind": "type", "visibility": "internal", "package": "LibTmux", - "signature": "static class LibTmux.Internal.MaterializationQuery", + "signature": "sealed class LibTmux.Internal.MaterializationQuery", "portable": true }, { diff --git a/docs/public-api.md b/docs/public-api.md index 7968144..46dec6f 100644 --- a/docs/public-api.md +++ b/docs/public-api.md @@ -2321,7 +2321,7 @@ component ownership depend on their exact typed boundaries. | `T:LibTmux.Internal.FormatProjection` | record | Defines one version-gated length-prefixed tmux projection. Behavior: {"emittedFieldCounts":{"list-clients":{"3.2a":146,"3.3a-3.6":150,"3.7a+":161},"list-panes":{"3.2a":123,"3.3a-3.6":125,"3.7a+":136},"list-sessions":{"3.2a":123,"3.3a-3.6":125,"3.7a+":136},"list-windows":{"3.2a":123,"3.3a-3.6":125,"3.7a+":136}},"framedFieldCount":"Fields.Count * 2"}. | | `T:LibTmux.Internal.SeparatedRowFramer` | static class | Decodes separator-framed tmux fields without delimiter ambiguity. Validation: row := value{projection.Fields.Count}, each value terminated by FormatProjection.RowSeparator; wire names are not sent and values are read positionally from the same projection both ends build; every field is expanded exactly once, because a byte-count prefix would expand it a second time and a field that moved in between would desynchronise the payload; the separator is randomised per process so a caller-controlled name can neither contain nor predict it, and carries no '#' for tmux to expand; tmux LF separates rows and CRLF is accepted; a complete final row may end at EOF; embedded CR and LF remain value data; an empty value maps to null after Utf8BackslashDecoder, with its key present; maxFramedFieldBytes bounds one value; a row that ends before every field is read, a value that never closes, an oversized value, and a row not terminated by a newline each throw InvalidDataException; returned memories are copied. | | `T:LibTmux.Internal.MaterializationContext` | class | Carries the owning server while generated rows are materialized. | -| `T:LibTmux.Internal.MaterializationQuery` | static class | Acquires version-gated framed rows for materialization. Behavior: {"liveAcquisition":"reject unmaterialized MaterializationContext.Server.Generation","mismatch":"StaleServerGenerationException","requiredUniversalFields":["pid","start_time"],"rowValidation":"parse pid/start_time as ServerGeneration and require equality with MaterializationContext.Server.Generation"}. | +| `T:LibTmux.Internal.MaterializationQuery` | sealed class | Reads version-gated framed rows for materialization. Behavior: {"liveAcquisition":"reject unmaterialized MaterializationContext.Server.Generation","mismatch":"StaleServerGenerationException","requiredUniversalFields":["pid","start_time"],"rowValidation":"parse pid/start_time as ServerGeneration and require equality with MaterializationContext.Server.Generation"}. State: MaterializationContext. | | `T:LibTmux.Internal.Materializer` | static class | Materializes generated format projections. Behavior: {"mismatch":"StaleServerGenerationException","requiredUniversalFields":["pid","start_time"],"rowValidation":"parse pid/start_time as ServerGeneration and require equality with MaterializationContext.Server.Generation"}. | | `T:LibTmux.Internal.OptionFailure` | static class | Classifies option-command failures. | | `T:LibTmux.Internal.OptionParser` | static class | Parses lossless scalar, sparse, and complex option values. | @@ -2348,8 +2348,8 @@ component ownership depend on their exact typed boundaries. | `M:LibTmux.Internal.SeparatedRowFramer.Decode(ReadOnlySpan)` | `static IReadOnlyList> LibTmux.Internal.SeparatedRowFramer.Decode(ReadOnlySpan payload)` | Decodes one separator-framed row. | | `M:LibTmux.Internal.SeparatedRowFramer.DecodeRows(ReadOnlySpan,int,int)` | `static IReadOnlyList>> LibTmux.Internal.SeparatedRowFramer.DecodeRows(ReadOnlySpan payload, int expectedFieldCount, int maxFramedFieldBytes)` | Decodes copied raw field values from one or more complete separator-framed rows. Validation: row := value{projection.Fields.Count}, each value terminated by FormatProjection.RowSeparator; wire names are not sent and values are read positionally from the same projection both ends build; every field is expanded exactly once, because a byte-count prefix would expand it a second time and a field that moved in between would desynchronise the payload; the separator is randomised per process so a caller-controlled name can neither contain nor predict it, and carries no '#' for tmux to expand; tmux LF separates rows and CRLF is accepted; a complete final row may end at EOF; embedded CR and LF remain value data; an empty value maps to null after Utf8BackslashDecoder, with its key present; maxFramedFieldBytes bounds one value; a row that ends before every field is read, a value that never closes, an oversized value, and a row not terminated by a newline each throw InvalidDataException; returned memories are copied. | | `M:LibTmux.Internal.MaterializationContext.#ctor(Server)` | `MaterializationContext(Server server)` | Creates materialization context for one owning server. | -| `M:LibTmux.Internal.MaterializationQuery.FetchAsync(MaterializationContext,string,IReadOnlyList?,string?,CancellationToken)` | `static Task>> LibTmux.Internal.MaterializationQuery.FetchAsync(MaterializationContext context, string listCommand, IReadOnlyList? arguments = null, string? target = null, CancellationToken cancellationToken = default)` | Acquires and decodes every version-gated row for one logical tmux list command. Behavior: {"projection":"FormatProjection.Create(listCommand, context.Server.Version)","rawValues":"Utf8BackslashDecoder after byte framing","result":"all decoded rows as copied dictionaries"}. Failure mapping: {"framing":"TmuxTransportException carrying logical tmux arguments","lowLevel":"InvalidDataException"}. | -| `M:LibTmux.Internal.MaterializationQuery.FetchOneAsync(MaterializationContext,string,string,string,IReadOnlyList?,CancellationToken)` | `static Task> LibTmux.Internal.MaterializationQuery.FetchOneAsync(MaterializationContext context, string listCommand, string targetId, string idField, IReadOnlyList? arguments = null, CancellationToken cancellationToken = default)` | Acquires one canonical tmux target without conflating absence and server failure. Behavior: {"canonicalSession":"resolve session_name before list-windows or list-panes","missingTarget":"empty successful result throws TmuxObjectNotFoundException","unreachableServer":"tmux or transport failure propagates distinctly"}. Failure mapping: {"framing":"TmuxTransportException carrying logical tmux arguments","lowLevel":"InvalidDataException"}. | +| `M:LibTmux.Internal.MaterializationQuery.FetchAsync(string,IEnumerable?,CancellationToken)` | `Task>> LibTmux.Internal.MaterializationQuery.FetchAsync(string listCommand, IEnumerable? extraArguments = null, CancellationToken cancellationToken = default)` | Acquires and decodes every version-gated row for one logical tmux list command. Behavior: {"projection":"FormatProjection.Create(listCommand, context.TmuxVersion)","rawValues":"Utf8BackslashDecoder after byte framing","result":"all decoded rows as copied dictionaries"}. Failure mapping: {"framing":"TmuxTransportException carrying logical tmux arguments","lowLevel":"InvalidDataException"}. | +| `M:LibTmux.Internal.MaterializationQuery.FetchOneAsync(string,string,string,TmuxTarget?,CancellationToken)` | `Task?> LibTmux.Internal.MaterializationQuery.FetchOneAsync(string listCommand, string idWireName, string identifier, TmuxTarget? inSession = null, CancellationToken cancellationToken = default)` | Reads one tmux entity without listing the server. Behavior: {"missingTarget":"a row that does not carry the identifier back returns null","read":"display-message -p -t target rendering the list command's projection","scoping":"a session-scoped target is read before the bare identifier","unreachableServer":"tmux or transport failure propagates distinctly"}. Failure mapping: {"framing":"TmuxTransportException carrying logical tmux arguments","lowLevel":"InvalidDataException"}. | | `M:LibTmux.Internal.Materializer.MaterializeFormatFields(MaterializationContext,ReadOnlySpan)` | `static IReadOnlyDictionary LibTmux.Internal.Materializer.MaterializeFormatFields(MaterializationContext context, ReadOnlySpan payload)` | Materializes lossless format fields with explicit owner context. | | `M:LibTmux.Internal.Materializer.MaterializePane(MaterializationContext,IReadOnlyDictionary)` | `static Pane LibTmux.Internal.Materializer.MaterializePane(MaterializationContext context, IReadOnlyDictionary fields)` | Materializes one pane projection dictionary with explicit owner context. | | `M:LibTmux.Internal.Materializer.MaterializePane(MaterializationContext,ReadOnlySpan)` | `static Pane LibTmux.Internal.Materializer.MaterializePane(MaterializationContext context, ReadOnlySpan payload)` | Materializes one pane projection with explicit owner context. | diff --git a/eng/parity/tests/test_public_api.py b/eng/parity/tests/test_public_api.py index 822a000..f04eabe 100644 --- a/eng/parity/tests/test_public_api.py +++ b/eng/parity/tests/test_public_api.py @@ -1772,26 +1772,13 @@ def test_c4_projection_framing_and_materialization_contract_is_complete() -> Non "IReadOnlyList>>", ["ReadOnlySpan", "int", "int"], ), - "M:LibTmux.Internal.MaterializationQuery.FetchAsync(MaterializationContext,string,IReadOnlyList?,string?,CancellationToken)": ( + "M:LibTmux.Internal.MaterializationQuery.FetchAsync(string,IEnumerable?,CancellationToken)": ( "Task>>", - [ - "MaterializationContext", - "string", - "IReadOnlyList?", - "string?", - "CancellationToken", - ], + ["string", "IEnumerable?", "CancellationToken"], ), - "M:LibTmux.Internal.MaterializationQuery.FetchOneAsync(MaterializationContext,string,string,string,IReadOnlyList?,CancellationToken)": ( - "Task>", - [ - "MaterializationContext", - "string", - "string", - "string", - "IReadOnlyList?", - "CancellationToken", - ], + "M:LibTmux.Internal.MaterializationQuery.FetchOneAsync(string,string,string,TmuxTarget?,CancellationToken)": ( + "Task?>", + ["string", "string", "string", "TmuxTarget?", "CancellationToken"], ), "M:LibTmux.Internal.Materializer.MaterializeSession(MaterializationContext,IReadOnlyDictionary)": ( "Session", @@ -1836,7 +1823,7 @@ def test_c4_projection_framing_and_materialization_contract_is_complete() -> Non == C4_FRAMING_VALIDATION ) assert members[ - "M:LibTmux.Internal.MaterializationQuery.FetchAsync(MaterializationContext,string,IReadOnlyList?,string?,CancellationToken)" + "M:LibTmux.Internal.MaterializationQuery.FetchAsync(string,IEnumerable?,CancellationToken)" ]["failureMapping"] == { "framing": "TmuxTransportException carrying logical tmux arguments", "lowLevel": "InvalidDataException", @@ -1862,7 +1849,7 @@ def test_c4_contract_validator_rejects_projection_and_framing_drift() -> None: "M:LibTmux.Internal.SeparatedRowFramer.DecodeRows(ReadOnlySpan,int,int)" ]["validation"] = "delimiter-separated rows" members[ - "M:LibTmux.Internal.MaterializationQuery.FetchOneAsync(MaterializationContext,string,string,string,IReadOnlyList?,CancellationToken)" + "M:LibTmux.Internal.MaterializationQuery.FetchOneAsync(string,string,string,TmuxTarget?,CancellationToken)" ]["parameters"][2]["name"] = "target" types["T:LibTmux.Internal.MaterializationQuery"]["behavior"][ "requiredUniversalFields" diff --git a/eng/parity/verify_public_api.py b/eng/parity/verify_public_api.py index 6772f6d..53dc7e1 100644 --- a/eng/parity/verify_public_api.py +++ b/eng/parity/verify_public_api.py @@ -737,8 +737,8 @@ def validate_c4_materialization_contracts( ["internal", "sealed"], ), "T:LibTmux.Internal.MaterializationQuery": ( - "static class", - ["internal", "static"], + "sealed class", + ["internal", "sealed"], ), } expected_members = { @@ -773,34 +773,29 @@ def validate_c4_materialization_contracts( ), ( "M:LibTmux.Internal.MaterializationQuery.FetchAsync(" - "MaterializationContext,string,IReadOnlyList?,string?," - "CancellationToken)" + "string,IEnumerable?,CancellationToken)" ): ( "Task>>", ( - ("context", "MaterializationContext", None), ("listCommand", "string", None), - ("arguments", "IReadOnlyList?", "null"), - ("target", "string?", "null"), + ("extraArguments", "IEnumerable?", "null"), ("cancellationToken", "CancellationToken", "default"), ), - True, + False, ), ( "M:LibTmux.Internal.MaterializationQuery.FetchOneAsync(" - "MaterializationContext,string,string,string,IReadOnlyList?," - "CancellationToken)" + "string,string,string,TmuxTarget?,CancellationToken)" ): ( - "Task>", + "Task?>", ( - ("context", "MaterializationContext", None), ("listCommand", "string", None), - ("targetId", "string", None), - ("idField", "string", None), - ("arguments", "IReadOnlyList?", "null"), + ("idWireName", "string", None), + ("identifier", "string", None), + ("inSession", "TmuxTarget?", "null"), ("cancellationToken", "CancellationToken", "default"), ), - True, + False, ), ( "M:LibTmux.Internal.Materializer.MaterializeSession(" @@ -909,8 +904,8 @@ def validate_c4_materialization_contracts( violations.append("invalid C4 framing contract") fetch_ids = ( - "M:LibTmux.Internal.MaterializationQuery.FetchAsync(MaterializationContext,string,IReadOnlyList?,string?,CancellationToken)", - "M:LibTmux.Internal.MaterializationQuery.FetchOneAsync(MaterializationContext,string,string,string,IReadOnlyList?,CancellationToken)", + "M:LibTmux.Internal.MaterializationQuery.FetchAsync(string,IEnumerable?,CancellationToken)", + "M:LibTmux.Internal.MaterializationQuery.FetchOneAsync(string,string,string,TmuxTarget?,CancellationToken)", ) if any( members.get(member_id, {}).get("failureMapping") != C4_QUERY_FAILURE_MAPPING From 9c8f2f58ad487600cb8240cdd81e3aad206ed165 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 17:15:39 -0500 Subject: [PATCH 085/129] Connection(refactor[dialect]): Decide the multiplexer once why: TmuxConnection asked which multiplexer answered on every dispatch. ExecuteSingleAsync, ExecuteGroupAsync and ExecuteGuardedGroupAsync each awaited a detection state machine and then branched, and the psmux preview's policy -- banner provenance, socket-name isolation, colour mode, configuration file, executable trust, WSL data-directory forwarding -- sat in the class every real tmux command passes through. Forty-five of its lines named a preview that is query-only and Windows. Nothing detects its way into psmux: it is reachable only through PsmuxServer, which supplies PsmuxPreviewOptions. The implementation is declared at construction, and reading the version banner verifies that claim rather than discovering it. So the choice is made once, and every command is delegated rather than tested. what: - Add MultiplexerDialect, which reads the version banner once and holds what a connection may ask of a multiplexer - Add TmuxDialect, which owns the generation guard and the refusal to drive a Windows executable - Add PsmuxDialect, which owns the router, the endpoint rules, the banner provenance check, and the grouped-command refusal - Reduce TmuxConnection to endpoint resolution, transport construction and delegation, from 522 lines to 237 - Move the launch-time psmux policy to PsmuxProcessEnvironment and PsmuxBinaryTrust - Drop the implementation argument from the test-only constructor: every connection now reads the banner, as the process-backed one always did - Cover the two refusals the move made reachable from one place --- src/LibTmux/Connection/MultiplexerDialect.cs | 107 ++++ src/LibTmux/Connection/PsmuxDialect.cs | 160 ++++++ src/LibTmux/Connection/TmuxConnection.cs | 467 ++++-------------- src/LibTmux/Connection/TmuxDialect.cs | 100 ++++ src/LibTmux/Internal/PsmuxBinaryTrust.cs | 14 + .../Internal/PsmuxProcessEnvironment.cs | 16 + .../Connection/FakeMultiplexer.cs | 35 ++ .../Connection/PsmuxConnectionTests.cs | 51 +- .../Connection/TmuxConnectionTests.cs | 44 +- .../CompositeMutationDispatchTests.cs | 19 +- .../Entities/PaneSendKeysDispatchTests.cs | 5 +- tests/LibTmux.UnitTests/Mcp/JobStoreTests.cs | 4 +- .../LibTmux.UnitTests/Mcp/PaneReaderTests.cs | 7 +- .../Mcp/PasteTextCleanupTests.cs | 5 +- .../Mcp/ReadToolsHistoryTests.cs | 5 +- .../Mcp/SearchResultBudgetTests.cs | 6 +- .../Mcp/StructuredTextResultBudgetTests.cs | 6 +- .../LibTmux.UnitTests/Mcp/TailCursorTests.cs | 6 +- .../Mcp/WaitInputBudgetTests.cs | 6 +- .../Mcp/WriteToolsExecutionSafetyTests.cs | 4 +- .../Versioning/TmuxCapabilitiesTests.cs | 6 +- 21 files changed, 632 insertions(+), 441 deletions(-) create mode 100644 src/LibTmux/Connection/MultiplexerDialect.cs create mode 100644 src/LibTmux/Connection/PsmuxDialect.cs create mode 100644 src/LibTmux/Connection/TmuxDialect.cs create mode 100644 tests/LibTmux.UnitTests/Connection/FakeMultiplexer.cs diff --git a/src/LibTmux/Connection/MultiplexerDialect.cs b/src/LibTmux/Connection/MultiplexerDialect.cs new file mode 100644 index 0000000..6a852a4 --- /dev/null +++ b/src/LibTmux/Connection/MultiplexerDialect.cs @@ -0,0 +1,107 @@ +namespace LibTmux.Internal; + +/// The multiplexer a connection speaks to, and how it is addressed. +/// +/// tmux and the psmux preview accept different commands, guard a generation +/// differently, and report different version banners. Choosing between them is +/// a connection's first decision rather than a question asked of every command, +/// so it is answered once here and then delegated to. +/// +internal abstract class MultiplexerDialect +{ + private readonly Func> + _executeVersion; + private readonly object _publication = new(); + private string? _rawVersion; + + private protected MultiplexerDialect( + Func> execute, + Func> executeVersion) + { + Execute = execute; + _executeVersion = executeVersion; + } + + /// Gets whether this dialect speaks to the psmux preview. + internal abstract bool IsPsmux { get; } + + /// Gets the transport every command ultimately reaches. + private protected Func> + Execute + { get; } + + /// Reads the server generation and the version banner. + internal abstract Task<(ServerGeneration Generation, string RawVersion)> DiscoverAsync( + CancellationToken cancellationToken); + + /// Runs one command. + internal abstract Task ExecuteSingleAsync( + IReadOnlyList arguments, + CancellationToken cancellationToken); + + /// Runs several commands in one invocation. + internal abstract Task ExecuteGroupAsync( + IReadOnlyList> commands, + CancellationToken cancellationToken); + + /// Runs commands under a check that the server is still the same one. + internal abstract Task ExecuteGuardedAsync( + ServerGeneration expected, + IReadOnlyList> commands, + CancellationToken cancellationToken); + + /// Accepts or rejects the banner the executable reported. + /// The banner is not this dialect's. + private protected abstract void AcceptBanner(TmuxVersionBanner banner); + + /// Rejects an endpoint this dialect cannot serve, before the first command. + private protected virtual void AcceptEndpoint() + { + } + + /// Verifies the executable once, before the first command reaches it. + /// + /// The banner is read through a transport carrying no endpoint arguments: + /// -V answers from the client rather than from a server, and a socket + /// that names nothing running would fail the question. + /// + private protected async Task EnsureVerifiedAsync(CancellationToken cancellationToken) + { + AcceptEndpoint(); + if (Volatile.Read(ref _rawVersion) is string known) + { + return known; + } + + TmuxCommandResult result = await _executeVersion( + TmuxCommandRequest.Single(["-V"]), + cancellationToken) + .ConfigureAwait(false); + if (result.ExitCode != 0 || result.StandardErrorLines.Count > 0) + { + throw new TmuxCommandException("multiplexer version discovery failed.", result); + } + + if (!TmuxVersionBannerParser.TryParse( + result.StandardOutputLines, + out TmuxVersionBanner banner)) + { + throw new InvalidDataException( + "The multiplexer did not report a recognized version banner."); + } + + AcceptBanner(banner); + lock (_publication) + { + if (_rawVersion is not null + && !string.Equals(_rawVersion, banner.RawVersion, StringComparison.Ordinal)) + { + throw new InvalidDataException( + "The multiplexer changed version between two readings."); + } + + _rawVersion = banner.RawVersion; + return banner.RawVersion; + } + } +} diff --git a/src/LibTmux/Connection/PsmuxDialect.cs b/src/LibTmux/Connection/PsmuxDialect.cs new file mode 100644 index 0000000..ae9427e --- /dev/null +++ b/src/LibTmux/Connection/PsmuxDialect.cs @@ -0,0 +1,160 @@ +namespace LibTmux.Internal; + +/// Speaks to the psmux preview, which serves one isolated session. +/// +/// psmux answers a subset of tmux and does not preserve grouped-command +/// semantics, so a generation is guarded by best-effort preflight rather than +/// by one invocation. Everything the preview refuses is refused here. +/// +internal sealed class PsmuxDialect : MultiplexerDialect +{ + private readonly PsmuxSessionRouter _router; + private readonly ServerConnectionOptions _options; + private readonly string? _resolvedSocketName; + + internal PsmuxDialect( + Func> execute, + Func> executeVersion, + ServerConnectionOptions options, + string? resolvedSocketName) + : base(execute, executeVersion) + { + _options = options; + _resolvedSocketName = resolvedSocketName; + AcceptEndpoint(); + _router = new PsmuxSessionRouter(ExecuteRawSingleAsync); + } + + internal override bool IsPsmux => true; + + internal override async Task<(ServerGeneration Generation, string RawVersion)> DiscoverAsync( + CancellationToken cancellationToken) + { + string rawVersion = await EnsureVerifiedAsync(cancellationToken).ConfigureAwait(false); + PsmuxSessionState session = await _router.DiscoverSessionAsync(cancellationToken) + .ConfigureAwait(false); + return (session.Generation, rawVersion); + } + + internal override async Task ExecuteSingleAsync( + IReadOnlyList arguments, + CancellationToken cancellationToken) + { + await EnsureVerifiedAsync(cancellationToken).ConfigureAwait(false); + return await _router.ExecuteSingleAsync(arguments, cancellationToken) + .ConfigureAwait(false); + } + + internal override async Task ExecuteGroupAsync( + IReadOnlyList> commands, + CancellationToken cancellationToken) + { + if (commands.Count != 1) + { + throw new NotSupportedException( + "psmux does not preserve tmux grouped-command semantics."); + } + + return await ExecuteSingleAsync(commands[0], cancellationToken).ConfigureAwait(false); + } + + internal override async Task ExecuteGuardedAsync( + ServerGeneration expected, + IReadOnlyList> commands, + CancellationToken cancellationToken) + { + await EnsureVerifiedAsync(cancellationToken).ConfigureAwait(false); + return await _router.ExecuteGuardedAsync(expected, commands, cancellationToken) + .ConfigureAwait(false); + } + + private protected override void AcceptBanner(TmuxVersionBanner banner) + { + if (banner.Implementation is not TmuxImplementation.Psmux) + { + throw new NotSupportedException( + "The trusted psmux preview executable reported a tmux banner."); + } + + if (!string.Equals( + banner.Version, + PsmuxCompatibility.SupportedVersion, + StringComparison.Ordinal)) + { + throw new NotSupportedException( + $"The psmux preview supports exactly version {PsmuxCompatibility.SupportedVersion}."); + } + + if (!string.Equals( + banner.ImplementationLine, + PsmuxCompatibility.SupportedImplementationLine, + StringComparison.Ordinal)) + { + throw new NotSupportedException( + $"The psmux preview supports exactly {PsmuxCompatibility.SupportedImplementationLine}."); + } + } + + private protected override void AcceptEndpoint() + { + if (_options.SocketPath is not null) + { + throw new NotSupportedException( + "psmux connections require a socket name because -S does not select a namespace."); + } + + if (string.IsNullOrEmpty(_resolvedSocketName) + || string.Equals(_resolvedSocketName, "default", StringComparison.Ordinal)) + { + throw new NotSupportedException( + "psmux connections require a non-default socket name for endpoint isolation."); + } + + PsmuxTargetGrammar.ValidateName(_resolvedSocketName, "namespace"); + + if (_options.ColorMode is not TmuxColorMode.Default) + { + throw new NotSupportedException( + "psmux does not honor tmux's forced client color modes."); + } + + if (_options.ConfigurationFile is not null) + { + throw new NotSupportedException( + "psmux cannot apply a per-client configuration file to a pre-existing session."); + } + } + + /// Runs one command, reporting it as the command the caller asked for. + /// + /// The router rewrites a target before sending it, and a caller who never + /// saw that rewrite cannot read a failure that quotes it. + /// + private async Task ExecuteRawSingleAsync( + IReadOnlyList arguments, + CancellationToken cancellationToken, + IReadOnlyList? preserveArguments = null) + { + TmuxCommandRequest request = TmuxCommandRequest.Single(arguments); + TmuxCommandResult result; + try + { + result = await Execute(request, cancellationToken).ConfigureAwait(false); + } + catch (TmuxTransportException error) when (preserveArguments is not null) + { + throw new TmuxTransportException( + error.Message, + preserveArguments, + error.Dispatch, + error.InnerException); + } + + return preserveArguments is null + ? result + : TmuxCommandResultProjection.Remap( + result, + preserveArguments, + result.StandardOutput); + } +} diff --git a/src/LibTmux/Connection/TmuxConnection.cs b/src/LibTmux/Connection/TmuxConnection.cs index 00095c9..7054067 100644 --- a/src/LibTmux/Connection/TmuxConnection.cs +++ b/src/LibTmux/Connection/TmuxConnection.cs @@ -7,19 +7,11 @@ namespace LibTmux.Internal; internal sealed class TmuxConnection { internal const string GenerationFormat = "#{pid}:#{start_time}"; - private readonly Func> _execute; - private readonly Func> - _executeVersion; + private readonly MultiplexerDialect _dialect; private readonly TmuxEndpointIdentity _endpointIdentity; - private readonly bool _processBacked; - private readonly TmuxGenerationGuard _generationGuard; - private readonly object _implementationGate = new(); private readonly TmuxEntityLookup _entityLookup; - private readonly PsmuxSessionRouter _psmuxRouter; private readonly string? _resolvedSocketName; private readonly string? _resolvedSocketPath; - private int _implementation; - private string? _detectedVersionLine; internal TmuxConnection(ServerConnectionOptions options) : this(TmuxConnectionEndpoint.Resolve(options), execute: null, markerFactory: null) @@ -29,82 +21,41 @@ internal TmuxConnection(ServerConnectionOptions options) internal TmuxConnection( ServerConnectionOptions options, Func> execute, - Func? markerFactory = null, - TmuxImplementation implementation = TmuxImplementation.Tmux) - : this(TmuxConnectionEndpoint.Resolve(options), execute, markerFactory, implementation) + Func? markerFactory = null) + : this(TmuxConnectionEndpoint.Resolve(options), execute, markerFactory) { } private TmuxConnection( ResolvedTmuxConnection resolved, Func>? execute, - Func? markerFactory, - TmuxImplementation implementation = TmuxImplementation.Unknown) + Func? markerFactory) { Options = resolved.Options; _resolvedSocketName = resolved.SocketName; _resolvedSocketPath = resolved.SocketPath; PrefixArguments = resolved.PrefixArguments; _endpointIdentity = resolved.EndpointIdentity; - _processBacked = execute is null; - _implementation = (int)(execute is null ? TmuxImplementation.Unknown : implementation); - if (execute is null) - { - Process Launch(ProcessStartInfo startInfo) - { - bool forwardPsmuxDataDirectoryThroughWsl = - Options.PsmuxPreview is not null - && !OperatingSystem.IsWindows() - && string.Equals( - Path.GetExtension(Options.TmuxBinaryPath), - ".exe", - StringComparison.OrdinalIgnoreCase); - ApplyChildEnvironment( - startInfo, - resolved.ChildEnvironment, - forwardPsmuxDataDirectoryThroughWsl); - return Process.Start(startInfo) - ?? throw new InvalidOperationException("The tmux client process did not start."); - } - - async ValueTask VerifyBeforeStartAsync( - ProcessStartInfo _, - CancellationToken cancellationToken) - { - if (Options.PsmuxPreview is PsmuxPreviewOptions psmuxPreview) - { - await PsmuxBinaryTrust.VerifyAsync( - Options.TmuxBinaryPath, - psmuxPreview.ExpectedBinarySha256, - cancellationToken) - .ConfigureAwait(false); - } - } - - var transport = new TmuxProcessTransport( - Options.TmuxBinaryPath, - PrefixArguments, - launcher: Launch, - beforeStart: VerifyBeforeStartAsync); - var versionTransport = new TmuxProcessTransport( - Options.TmuxBinaryPath, - launcher: Launch, - beforeStart: VerifyBeforeStartAsync); - _execute = (request, cancellationToken) => - transport.ExecuteAsync(request, cancellationToken); - _executeVersion = (request, cancellationToken) => - versionTransport.ExecuteAsync(request, cancellationToken); - } - else - { - _execute = execute; - _executeVersion = execute; - } + ( + Func> send, + Func> sendVersion) = + execute is null + ? CreateProcessTransports(resolved) + : (execute, execute); + + // The psmux preview is reached only through its own facade, which + // supplies these options; nothing detects its way into it. + _dialect = Options.PsmuxPreview is null + ? new TmuxDialect( + send, + sendVersion, + markerFactory ?? (static () => $"libtmux_stale_{Guid.NewGuid():N}"), + processBacked: execute is null, + Options.TmuxBinaryPath) + : new PsmuxDialect(send, sendVersion, Options, _resolvedSocketName); - _psmuxRouter = new PsmuxSessionRouter(ExecuteRawSingleAsync); _entityLookup = new TmuxEntityLookup(ExecuteSingleAsync); - CommandContext = Options.Logger is ILogger logger ? new TmuxCommandContext(logger, Options.SocketName ?? Options.SocketPath) : null; @@ -112,9 +63,6 @@ await PsmuxBinaryTrust.VerifyAsync( ExecuteSingleAsync, CommandContext, ExecuteGroupAsync); - _generationGuard = new TmuxGenerationGuard( - _execute, - markerFactory ?? (static () => $"libtmux_stale_{Guid.NewGuid():N}")); } internal ServerConnectionOptions Options { get; } @@ -125,10 +73,7 @@ await PsmuxBinaryTrust.VerifyAsync( internal TmuxCommandContext? CommandContext { get; } - internal bool IsPsmux => CurrentImplementation is TmuxImplementation.Psmux; - - private TmuxImplementation CurrentImplementation => - (TmuxImplementation)Volatile.Read(ref _implementation); + internal bool IsPsmux => _dialect.IsPsmux; internal bool HasSameEndpoint(TmuxConnection other) { @@ -148,48 +93,9 @@ internal bool HasSameEndpoint(TmuxConnection other) internal (string? SocketName, string? SocketPath) ResolvedSocket => (_resolvedSocketName, _resolvedSocketPath); - internal async Task<(ServerGeneration Generation, string RawVersion)> DiscoverAsync( - CancellationToken cancellationToken) - { - TmuxImplementation implementation = await EnsureImplementationAsync(cancellationToken) - .ConfigureAwait(false); - - if (implementation is TmuxImplementation.Psmux) - { - PsmuxSessionState session = await _psmuxRouter.DiscoverSessionAsync( - cancellationToken) - .ConfigureAwait(false); - return (session.Generation, RequireDetectedVersionLine()); - } - - TmuxCommandResult generationResult = await ExecuteRawSingleAsync( - ["display-message", "-p", GenerationFormat], - cancellationToken) - .ConfigureAwait(false); - EnsureSuccessful(generationResult, "server generation discovery"); - if (generationResult.StandardOutputLines.Count != 1) - { - throw new InvalidDataException("tmux did not report exactly one server generation."); - } - - ServerGeneration generation = ParseGeneration(generationResult.StandardOutputLines[0]); - string? rawVersion = Volatile.Read(ref _detectedVersionLine); - if (rawVersion is null) - { - (TmuxImplementation detected, rawVersion) = await DetectImplementationAsync( - cancellationToken) - .ConfigureAwait(false); - if (detected != implementation) - { - throw new InvalidDataException( - "The selected multiplexer changed implementation during discovery."); - } - - PublishImplementation(detected, rawVersion); - } - - return (generation, rawVersion); - } + internal Task<(ServerGeneration Generation, string RawVersion)> DiscoverAsync( + CancellationToken cancellationToken) => + _dialect.DiscoverAsync(cancellationToken); internal Task<(ServerGeneration Generation, SessionId Id)?> FindSessionAsync( SessionId id, @@ -210,13 +116,34 @@ internal TmuxCommandDispatcher CreateEntityDispatcher(ServerGeneration generatio { ValidateLiveGeneration(generation); return new TmuxCommandDispatcher( - (arguments, cancellationToken) => ExecuteGuardedAsync( + (arguments, cancellationToken) => ExecuteGuardedGroupAsync( generation, - arguments, + [arguments], cancellationToken), CommandContext); } + /// Runs several commands under one generation guard. + internal Task ExecuteGuardedGroupAsync( + ServerGeneration expected, + IReadOnlyList> commands, + CancellationToken cancellationToken) + { + ValidateLiveGeneration(expected); + ArgumentNullException.ThrowIfNull(commands); + if (commands.Count == 0) + { + throw new InvalidOperationException("A guarded run needs at least one command."); + } + + foreach (IReadOnlyList command in commands) + { + TmuxCommandDispatcher.ValidateArguments(command); + } + + return _dialect.ExecuteGuardedAsync(expected, commands, cancellationToken); + } + internal static ServerGeneration ParseGeneration(string text) { string[] fields = text.Split(':'); @@ -246,48 +173,6 @@ internal static void ApplyChildEnvironment( childEnvironment, forwardPsmuxDataDirectoryThroughWsl); - /// Runs one command under a generation guard. - private Task ExecuteGuardedAsync( - ServerGeneration expected, - IReadOnlyList logicalArguments, - CancellationToken cancellationToken) => - ExecuteGuardedGroupAsync(expected, [logicalArguments], cancellationToken); - - /// Runs several commands under one generation guard. - /// The tmux path guards the batch in one invocation. The psmux - /// preview uses separate best-effort preflights and accepts one command. - internal async Task ExecuteGuardedGroupAsync( - ServerGeneration expected, - IReadOnlyList> commands, - CancellationToken cancellationToken) - { - ValidateLiveGeneration(expected); - ArgumentNullException.ThrowIfNull(commands); - if (commands.Count == 0) - { - throw new InvalidOperationException("A guarded run needs at least one command."); - } - - foreach (IReadOnlyList command in commands) - { - TmuxCommandDispatcher.ValidateArguments(command); - } - - TmuxImplementation implementation = await EnsureImplementationAsync(cancellationToken) - .ConfigureAwait(false); - if (implementation is TmuxImplementation.Psmux) - { - return await _psmuxRouter.ExecuteGuardedAsync( - expected, - commands, - cancellationToken) - .ConfigureAwait(false); - } - - return await _generationGuard.ExecuteAsync(expected, commands, cancellationToken) - .ConfigureAwait(false); - } - private static void ValidateLiveGeneration(ServerGeneration generation) { if (generation.ProcessId <= 0 || generation.StartTime <= 0) @@ -296,222 +181,52 @@ private static void ValidateLiveGeneration(ServerGeneration generation) } } - private async Task ExecuteSingleAsync( + /// Builds the two transports a process-backed connection needs. + /// + /// The version transport carries no endpoint arguments: -V answers + /// from the client, and a socket naming nothing running would fail it. + /// + private ( + Func> Send, + Func> SendVersion) + CreateProcessTransports(ResolvedTmuxConnection resolved) + { + Process Launch(ProcessStartInfo startInfo) + { + ApplyChildEnvironment( + startInfo, + resolved.ChildEnvironment, + PsmuxProcessEnvironment.ForwardsDataDirectoryThroughWsl(Options)); + return Process.Start(startInfo) + ?? throw new InvalidOperationException("The tmux client process did not start."); + } + + ValueTask VerifyBeforeStartAsync( + ProcessStartInfo _, + CancellationToken cancellationToken) => + PsmuxBinaryTrust.VerifyIfPreviewAsync(Options, cancellationToken); + + var transport = new TmuxProcessTransport( + Options.TmuxBinaryPath, + PrefixArguments, + launcher: Launch, + beforeStart: VerifyBeforeStartAsync); + var versionTransport = new TmuxProcessTransport( + Options.TmuxBinaryPath, + launcher: Launch, + beforeStart: VerifyBeforeStartAsync); + return (transport.ExecuteAsync, versionTransport.ExecuteAsync); + } + + private Task ExecuteSingleAsync( IReadOnlyList arguments, - CancellationToken cancellationToken) - { - TmuxImplementation implementation = await EnsureImplementationAsync(cancellationToken) - .ConfigureAwait(false); - if (implementation is not TmuxImplementation.Psmux) - { - return await ExecuteRawSingleAsync(arguments, cancellationToken).ConfigureAwait(false); - } - - return await _psmuxRouter.ExecuteSingleAsync(arguments, cancellationToken) - .ConfigureAwait(false); - } + CancellationToken cancellationToken) => + _dialect.ExecuteSingleAsync(arguments, cancellationToken); - private async Task ExecuteGroupAsync( + private Task ExecuteGroupAsync( IReadOnlyList> commands, - CancellationToken cancellationToken) - { - TmuxImplementation implementation = await EnsureImplementationAsync(cancellationToken) - .ConfigureAwait(false); - if (implementation is TmuxImplementation.Psmux) - { - if (commands.Count != 1) - { - throw new NotSupportedException( - "psmux does not preserve tmux grouped-command semantics."); - } - - return await ExecuteSingleAsync(commands[0], cancellationToken).ConfigureAwait(false); - } - - return await _execute(TmuxCommandRequest.Group([.. commands]), cancellationToken) - .ConfigureAwait(false); - } - - private async Task EnsureImplementationAsync( - CancellationToken cancellationToken) - { - if (Options.PsmuxPreview is not null) - { - ValidatePsmuxConnection(); - } - else if (_processBacked - && (OperatingSystem.IsWindows() - || string.Equals( - Path.GetExtension(Options.TmuxBinaryPath), - ".exe", - StringComparison.OrdinalIgnoreCase))) - { - throw new PlatformNotSupportedException( - "Windows executables require the explicit PsmuxServer query facade."); - } - - TmuxImplementation implementation = CurrentImplementation; - if (implementation is not TmuxImplementation.Unknown) - { - if (implementation is TmuxImplementation.Psmux) - { - ValidatePsmuxConnection(); - } - - return implementation; - } - - (implementation, string rawVersion) = await DetectImplementationAsync(cancellationToken) - .ConfigureAwait(false); - PublishImplementation(implementation, rawVersion); - return implementation; - } - - private async Task<(TmuxImplementation Implementation, string RawVersion)> - DetectImplementationAsync(CancellationToken cancellationToken) - { - TmuxCommandResult result = await _executeVersion( - TmuxCommandRequest.Single(["-V"]), - cancellationToken) - .ConfigureAwait(false); - EnsureSuccessful(result, "multiplexer version discovery"); - if (!TmuxVersionBannerParser.TryParse( - result.StandardOutputLines, - out TmuxVersionBanner banner)) - { - throw new InvalidDataException( - "The multiplexer did not report a recognized version banner."); - } - - if (banner.Implementation is TmuxImplementation.Psmux) - { - if (Options.PsmuxPreview is null) - { - throw new NotSupportedException( - "psmux requires the explicit PsmuxServer query facade."); - } - - if (!string.Equals( - banner.Version, - PsmuxCompatibility.SupportedVersion, - StringComparison.Ordinal)) - { - throw new NotSupportedException( - $"The psmux preview supports exactly version {PsmuxCompatibility.SupportedVersion}."); - } - - if (!string.Equals( - banner.ImplementationLine, - PsmuxCompatibility.SupportedImplementationLine, - StringComparison.Ordinal)) - { - throw new NotSupportedException( - $"The psmux preview supports exactly {PsmuxCompatibility.SupportedImplementationLine}."); - } - - ValidatePsmuxConnection(); - } - else if (Options.PsmuxPreview is not null) - { - throw new NotSupportedException( - "The trusted psmux preview executable reported a tmux banner."); - } - - return (banner.Implementation, banner.RawVersion); - } - - private void PublishImplementation(TmuxImplementation implementation, string rawVersion) - { - lock (_implementationGate) - { - TmuxImplementation observed = CurrentImplementation; - if (observed is not TmuxImplementation.Unknown && observed != implementation) - { - throw new InvalidDataException( - "The selected multiplexer changed implementation during discovery."); - } - - _detectedVersionLine ??= rawVersion; - Volatile.Write(ref _implementation, (int)implementation); - } - } - - private string RequireDetectedVersionLine() => - Volatile.Read(ref _detectedVersionLine) - ?? throw new InvalidOperationException("The multiplexer version was not detected."); - - private void ValidatePsmuxConnection() - { - if (Options.PsmuxPreview is null) - { - throw new NotSupportedException( - "psmux requires the explicit PsmuxServer query facade."); - } - - if (Options.SocketPath is not null) - { - throw new NotSupportedException( - "psmux connections require a socket name because -S does not select a namespace."); - } - - if (string.IsNullOrEmpty(_resolvedSocketName) - || string.Equals(_resolvedSocketName, "default", StringComparison.Ordinal)) - { - throw new NotSupportedException( - "psmux connections require a non-default socket name for endpoint isolation."); - } - - PsmuxTargetGrammar.ValidateName(_resolvedSocketName, "namespace"); - - if (Options.ColorMode is not TmuxColorMode.Default) - { - throw new NotSupportedException( - "psmux does not honor tmux's forced client color modes."); - } - - if (Options.ConfigurationFile is not null) - { - throw new NotSupportedException( - "psmux cannot apply a per-client configuration file to a pre-existing session."); - } - } - - private async Task ExecuteRawSingleAsync( - IReadOnlyList arguments, - CancellationToken cancellationToken, - IReadOnlyList? preserveArguments = null) - { - TmuxCommandRequest request = TmuxCommandRequest.Single(arguments); - TmuxCommandResult result; - try - { - result = await _execute(request, cancellationToken).ConfigureAwait(false); - } - catch (TmuxTransportException error) when (preserveArguments is not null) - { - throw new TmuxTransportException( - error.Message, - preserveArguments, - error.Dispatch, - error.InnerException); - } - - return preserveArguments is null - ? result - : TmuxCommandResultProjection.Remap( - result, - preserveArguments, - result.StandardOutput); - } - - private static void EnsureSuccessful(TmuxCommandResult result, string operation) - { - if (result.ExitCode != 0 || result.StandardErrorLines.Count > 0) - { - throw new TmuxCommandException($"{operation} failed.", result); - } - } - + CancellationToken cancellationToken) => + _dialect.ExecuteGroupAsync(commands, cancellationToken); } internal enum TmuxImplementation diff --git a/src/LibTmux/Connection/TmuxDialect.cs b/src/LibTmux/Connection/TmuxDialect.cs new file mode 100644 index 0000000..ef0b0e9 --- /dev/null +++ b/src/LibTmux/Connection/TmuxDialect.cs @@ -0,0 +1,100 @@ +namespace LibTmux.Internal; + +/// Speaks to a real tmux server. +internal sealed class TmuxDialect : MultiplexerDialect +{ + private readonly TmuxGenerationGuard _generationGuard; + private readonly bool _processBacked; + private readonly string _binaryPath; + + internal TmuxDialect( + Func> execute, + Func> executeVersion, + Func markerFactory, + bool processBacked, + string binaryPath) + : base(execute, executeVersion) + { + _generationGuard = new TmuxGenerationGuard(execute, markerFactory); + _processBacked = processBacked; + _binaryPath = binaryPath; + } + + internal override bool IsPsmux => false; + + internal override async Task<(ServerGeneration Generation, string RawVersion)> DiscoverAsync( + CancellationToken cancellationToken) + { + string rawVersion = await EnsureVerifiedAsync(cancellationToken).ConfigureAwait(false); + TmuxCommandResult result = await Execute( + TmuxCommandRequest.Single( + ["display-message", "-p", TmuxConnection.GenerationFormat]), + cancellationToken) + .ConfigureAwait(false); + if (result.ExitCode != 0 || result.StandardErrorLines.Count > 0) + { + throw new TmuxCommandException("server generation discovery failed.", result); + } + + if (result.StandardOutputLines.Count != 1) + { + throw new InvalidDataException("tmux did not report exactly one server generation."); + } + + return (TmuxConnection.ParseGeneration(result.StandardOutputLines[0]), rawVersion); + } + + internal override async Task ExecuteSingleAsync( + IReadOnlyList arguments, + CancellationToken cancellationToken) + { + await EnsureVerifiedAsync(cancellationToken).ConfigureAwait(false); + return await Execute(TmuxCommandRequest.Single(arguments), cancellationToken) + .ConfigureAwait(false); + } + + internal override async Task ExecuteGroupAsync( + IReadOnlyList> commands, + CancellationToken cancellationToken) + { + await EnsureVerifiedAsync(cancellationToken).ConfigureAwait(false); + return await Execute(TmuxCommandRequest.Group([.. commands]), cancellationToken) + .ConfigureAwait(false); + } + + internal override async Task ExecuteGuardedAsync( + ServerGeneration expected, + IReadOnlyList> commands, + CancellationToken cancellationToken) + { + await EnsureVerifiedAsync(cancellationToken).ConfigureAwait(false); + return await _generationGuard.ExecuteAsync(expected, commands, cancellationToken) + .ConfigureAwait(false); + } + + private protected override void AcceptBanner(TmuxVersionBanner banner) + { + if (banner.Implementation is TmuxImplementation.Psmux) + { + throw new NotSupportedException( + "psmux requires the explicit PsmuxServer query facade."); + } + } + + private protected override void AcceptEndpoint() + { + // A Windows executable answers the query facade and nothing else, so + // the refusal belongs before the command rather than after tmux + // rejects a flag it never had. + if (_processBacked + && (OperatingSystem.IsWindows() + || string.Equals( + Path.GetExtension(_binaryPath), + ".exe", + StringComparison.OrdinalIgnoreCase))) + { + throw new PlatformNotSupportedException( + "Windows executables require the explicit PsmuxServer query facade."); + } + } +} diff --git a/src/LibTmux/Internal/PsmuxBinaryTrust.cs b/src/LibTmux/Internal/PsmuxBinaryTrust.cs index c3d57ef..0fc116d 100644 --- a/src/LibTmux/Internal/PsmuxBinaryTrust.cs +++ b/src/LibTmux/Internal/PsmuxBinaryTrust.cs @@ -8,6 +8,20 @@ internal static class PsmuxBinaryTrust private const int BufferSize = 81920; private const long MaximumBinaryBytes = 128L * 1024 * 1024; + /// Verifies the executable when, and only when, the preview is in use. + internal static ValueTask VerifyIfPreviewAsync( + ServerConnectionOptions options, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(options); + return options.PsmuxPreview is PsmuxPreviewOptions preview + ? new ValueTask(VerifyAsync( + options.TmuxBinaryPath, + preview.ExpectedBinarySha256, + cancellationToken)) + : ValueTask.CompletedTask; + } + internal static async Task VerifyAsync( string path, string expectedSha256, diff --git a/src/LibTmux/Internal/PsmuxProcessEnvironment.cs b/src/LibTmux/Internal/PsmuxProcessEnvironment.cs index d2eb872..3b33565 100644 --- a/src/LibTmux/Internal/PsmuxProcessEnvironment.cs +++ b/src/LibTmux/Internal/PsmuxProcessEnvironment.cs @@ -4,6 +4,22 @@ namespace LibTmux.Internal; internal static class PsmuxProcessEnvironment { + /// Reports whether a launch has to carry the data directory into WSL. + /// + /// A Windows psmux executable started from Linux reads its data directory + /// through the interop layer, which does not forward the variable on its own. + /// + internal static bool ForwardsDataDirectoryThroughWsl(ServerConnectionOptions options) + { + ArgumentNullException.ThrowIfNull(options); + return options.PsmuxPreview is not null + && !OperatingSystem.IsWindows() + && string.Equals( + Path.GetExtension(options.TmuxBinaryPath), + ".exe", + StringComparison.OrdinalIgnoreCase); + } + internal static void Apply( ProcessStartInfo startInfo, IReadOnlyDictionary? childEnvironment, diff --git a/tests/LibTmux.UnitTests/Connection/FakeMultiplexer.cs b/tests/LibTmux.UnitTests/Connection/FakeMultiplexer.cs new file mode 100644 index 0000000..73a59e5 --- /dev/null +++ b/tests/LibTmux.UnitTests/Connection/FakeMultiplexer.cs @@ -0,0 +1,35 @@ +using LibTmux.Internal; + +namespace LibTmux.UnitTests.Connection; + +/// Answers the version banner a connection reads before its first command. +internal static class FakeMultiplexer +{ + internal const string TmuxBanner = "tmux 3.7\n"; + + /// Wraps a fake transport so it need only model the commands under test. + /// + /// Every connection reads -V once to learn which multiplexer answered. + /// Intercepting it here keeps that reading out of a fake's own bookkeeping. + /// + internal static Func> + AnsweringVersion( + Func> execute, + string banner = TmuxBanner) => + (request, cancellationToken) => + request.LogicalArguments is [string only] && only == "-V" + ? Task.FromResult(Banner(request.LogicalArguments, banner)) + : execute(request, cancellationToken); + + private static TmuxCommandResult Banner(IReadOnlyList arguments, string banner) + { + byte[] output = System.Text.Encoding.UTF8.GetBytes(banner); + return new TmuxCommandResult( + arguments, + 0, + output, + ReadOnlyMemory.Empty, + Utf8BackslashDecoder.ProjectOutputLines(output), + []); + } +} diff --git a/tests/LibTmux.UnitTests/Connection/PsmuxConnectionTests.cs b/tests/LibTmux.UnitTests/Connection/PsmuxConnectionTests.cs index 7bc6ca5..9c224b2 100644 --- a/tests/LibTmux.UnitTests/Connection/PsmuxConnectionTests.cs +++ b/tests/LibTmux.UnitTests/Connection/PsmuxConnectionTests.cs @@ -219,8 +219,7 @@ public async Task Unknown_backend_detects_once_before_rejecting_an_unsafe_argume calls++; Assert.Equal(["-V"], request.LogicalArguments); return Task.FromResult(Result(request.LogicalArguments, AuditedBanner)); - }, - implementation: TmuxImplementation.Unknown); + }); await Assert.ThrowsAsync( () => connection.ServerDispatcher.ExecuteAsync( @@ -249,8 +248,7 @@ public async Task Two_line_banner_selects_psmux_and_uses_the_sole_session_genera "display-message" => Result(arguments, "41:100\t$7\talpha\n"), _ => throw new Xunit.Sdk.XunitException("Unexpected command."), }); - }, - implementation: TmuxImplementation.Unknown); + }); (ServerGeneration generation, string rawVersion) = await connection.DiscoverAsync( TestContext.Current.CancellationToken); @@ -515,6 +513,39 @@ await Assert.ThrowsAsync( Assert.Equal(1, calls); } + [Fact] + public async Task Unguarded_grouped_commands_are_rejected_before_dispatch() + { + int calls = 0; + var connection = PsmuxConnection((request, _) => + { + calls++; + return Task.FromResult(Result(request.LogicalArguments, "41:100\t$7\talpha\n")); + }); + + await Assert.ThrowsAsync( + () => connection.ServerDispatcher.ExecuteGroupAsync( + [["display-message", "-p", "one"], ["display-message", "-p", "two"]], + TestContext.Current.CancellationToken)); + + Assert.Equal(0, calls); + } + + [Fact] + public async Task A_tmux_banner_from_the_trusted_executable_is_rejected() + { + var connection = new TmuxConnection( + PsmuxOptions(), + (request, _) => Task.FromResult(Result(request.LogicalArguments, "tmux 3.3.8\n"))); + + NotSupportedException error = await Assert.ThrowsAsync( + () => connection.DiscoverAsync(TestContext.Current.CancellationToken)); + + Assert.Equal( + "The trusted psmux preview executable reported a tmux banner.", + error.Message); + } + [Fact] public async Task Empty_namespace_normalizes_list_and_has_session_as_dead() { @@ -757,8 +788,7 @@ public async Task Psmux_facade_preserves_strict_transport_failures() return Task.FromResult(request.LogicalArguments[0] == "-V" ? Result(request.LogicalArguments, AuditedBanner) : OneSessionResult(request)); - }, - implementation: TmuxImplementation.Unknown); + }); (ServerGeneration generation, string rawVersion) = await connection.DiscoverAsync( TestContext.Current.CancellationToken); var facade = new PsmuxServer( @@ -816,8 +846,7 @@ public async Task Psmux_connection_rejects_versions_outside_the_audited_allowlis return Task.FromResult(Result( request.LogicalArguments, $"tmux {version}\npsmux {version}\n")); - }, - implementation: TmuxImplementation.Unknown); + }); await Assert.ThrowsAsync( () => connection.DiscoverAsync(TestContext.Current.CancellationToken)); @@ -840,8 +869,7 @@ public async Task Psmux_connection_requires_the_audited_build_provenance(string return Task.FromResult(Result( request.LogicalArguments, $"tmux 3.3.8\n{secondLine}\n")); - }, - implementation: TmuxImplementation.Unknown); + }); await Assert.ThrowsAsync( () => connection.DiscoverAsync(TestContext.Current.CancellationToken)); @@ -885,8 +913,7 @@ private static TmuxConnection PsmuxConnection( Func> execute) => new( PsmuxOptions(), - execute, - implementation: TmuxImplementation.Psmux); + FakeMultiplexer.AnsweringVersion(execute, AuditedBanner)); private static ServerConnectionOptions PsmuxOptions( string? binaryPath = null, diff --git a/tests/LibTmux.UnitTests/Connection/TmuxConnectionTests.cs b/tests/LibTmux.UnitTests/Connection/TmuxConnectionTests.cs index 51df9aa..508c2c6 100644 --- a/tests/LibTmux.UnitTests/Connection/TmuxConnectionTests.cs +++ b/tests/LibTmux.UnitTests/Connection/TmuxConnectionTests.cs @@ -278,8 +278,8 @@ public void Supported_color_modes_keep_their_values_and_value_one_is_reserved() factoryCalls++; return "unused"; }), - static (request, _) => Task.FromResult( - Result(request.LogicalArguments, 0, [], [])))); + FakeMultiplexer.AnsweringVersion(static (request, _) => Task.FromResult( + Result(request.LogicalArguments, 0, [], []))))); Assert.Equal("colorMode", error.ParamName); Assert.Equal(0, factoryCalls); @@ -527,11 +527,11 @@ public void Selected_socket_name_factory_rejects_invalid_results_before_executio Assert.Throws( () => new TmuxConnection( options, - (request, _) => + FakeMultiplexer.AnsweringVersion((request, _) => { executions++; return Task.FromResult(Result(request.LogicalArguments, 0, [], [])); - })); + }))); Assert.Equal(1, factoryCalls); Assert.Equal(0, executions); } @@ -679,11 +679,11 @@ public async Task Guard_uses_one_structural_group_and_hides_guard_output_and_arg byte[] groupedOutput = [.. "41:100\n"u8, 0x66, 0x80, 0x0a]; var connection = new TmuxConnection( new ServerConnectionOptions(), - (request, _) => + FakeMultiplexer.AnsweringVersion((request, _) => { captured = request; return Task.FromResult(Result(request.LogicalArguments, 0, groupedOutput, [])); - }, + }), () => Marker); TmuxCommandDispatcher dispatcher = connection.CreateEntityDispatcher(generation); string[] logical = ["display-message", "-t", "$0", "-p", ";"]; @@ -725,12 +725,12 @@ public async Task Same_process_with_new_start_time_throws_exact_generations() var actual = new ServerGeneration(42, 101); var connection = new TmuxConnection( new ServerConnectionOptions(), - (request, _) => Task.FromResult( + FakeMultiplexer.AnsweringVersion((request, _) => Task.FromResult( Result( request.LogicalArguments, 1, "42:101\n"u8.ToArray(), - Encoding.UTF8.GetBytes($"unknown command: {Marker}\n"))), + Encoding.UTF8.GetBytes($"unknown command: {Marker}\n")))), () => Marker); TmuxCommandDispatcher dispatcher = connection.CreateEntityDispatcher(expected); @@ -760,12 +760,12 @@ public async Task Marker_classification_requires_exit_one_and_one_exact_complete { var connection = new TmuxConnection( new ServerConnectionOptions(), - (request, _) => Task.FromResult( + FakeMultiplexer.AnsweringVersion((request, _) => Task.FromResult( Result( request.LogicalArguments, exitCode, "44:201\n"u8.ToArray(), - Encoding.UTF8.GetBytes(stderr))), + Encoding.UTF8.GetBytes(stderr)))), () => Marker); TmuxCommandResult result = await connection .CreateEntityDispatcher(new ServerGeneration(44, 200)) @@ -785,8 +785,8 @@ public async Task Ordinary_nonzero_without_generation_prefix_preserves_the_logic byte[] stderr = "no server running on /tmp/missing\n"u8.ToArray(); var connection = new TmuxConnection( new ServerConnectionOptions(), - (request, _) => Task.FromResult( - Result(request.LogicalArguments, 1, stdout, stderr)), + FakeMultiplexer.AnsweringVersion((request, _) => Task.FromResult( + Result(request.LogicalArguments, 1, stdout, stderr))), () => "libtmux_guard_10203040"); string[] logical = ["display-message", "-t", "$0", "-p", "#{session_id}"]; @@ -808,12 +808,12 @@ public async Task Exact_marker_without_generation_prefix_is_not_classified_or_pr const string Marker = "libtmux_guard_50607080"; var connection = new TmuxConnection( new ServerConnectionOptions(), - (request, _) => Task.FromResult( + FakeMultiplexer.AnsweringVersion((request, _) => Task.FromResult( Result( request.LogicalArguments, 1, [], - Encoding.UTF8.GetBytes($"unknown command: {Marker}\n"))), + Encoding.UTF8.GetBytes($"unknown command: {Marker}\n")))), () => Marker); await Assert.ThrowsAsync( @@ -830,11 +830,11 @@ public async Task Transport_exception_arguments_are_remapped_to_the_logical_targ var root = new IOException("transport root"); var connection = new TmuxConnection( new ServerConnectionOptions(), - (request, _) => throw new TmuxTransportException( + FakeMultiplexer.AnsweringVersion((request, _) => throw new TmuxTransportException( "transport failed", request.LogicalArguments, TmuxDispatchState.NotDispatched, - root), + root)), () => "libtmux_guard_abcd1234"); string[] logical = ["select-pane", "-t", "%0", "-P", "hostile;value"]; @@ -856,11 +856,11 @@ public void Invalid_live_generation_is_rejected_before_marker_or_transport_use() int markers = 0; var connection = new TmuxConnection( new ServerConnectionOptions(), - (request, _) => + FakeMultiplexer.AnsweringVersion((request, _) => { executions++; return Task.FromResult(Result(request.LogicalArguments, 0, [], [])); - }, + }), () => { markers++; @@ -880,7 +880,7 @@ public async Task Discovery_rejects_malformed_or_nonpositive_generation_before_v int calls = 0; var connection = new TmuxConnection( new ServerConnectionOptions(), - (request, _) => + FakeMultiplexer.AnsweringVersion((request, _) => { calls++; return Task.FromResult( @@ -889,7 +889,7 @@ public async Task Discovery_rejects_malformed_or_nonpositive_generation_before_v 0, Encoding.UTF8.GetBytes($"{malformed}\n"), [])); - }); + })); await Assert.ThrowsAsync( () => connection.DiscoverAsync(TestContext.Current.CancellationToken)); @@ -902,8 +902,8 @@ public async Task Entity_equality_binds_typed_id_to_generation() { var connection = new TmuxConnection( new ServerConnectionOptions(), - static (request, _) => Task.FromResult( - Result(request.LogicalArguments, 0, [], []))); + FakeMultiplexer.AnsweringVersion(static (request, _) => Task.FromResult( + Result(request.LogicalArguments, 0, [], [])))); var generation = new ServerGeneration(60, 400); var session = new Session(connection, generation, new SessionId(1)); diff --git a/tests/LibTmux.UnitTests/Entities/CompositeMutationDispatchTests.cs b/tests/LibTmux.UnitTests/Entities/CompositeMutationDispatchTests.cs index 5a655d2..411f109 100644 --- a/tests/LibTmux.UnitTests/Entities/CompositeMutationDispatchTests.cs +++ b/tests/LibTmux.UnitTests/Entities/CompositeMutationDispatchTests.cs @@ -207,11 +207,12 @@ public async Task Replaced_session_listing_failure_is_unknown_after_creation() AssertPartialFailure(failure, typeof(TmuxTransportException)); Assert.Equal( [ + // The banner is read once, before the first command reaches tmux. + "-V", "has-session", "kill-session", "new-session", "display-message", - "-V", ProjectionRead, ], commands.ToArray()); @@ -243,6 +244,7 @@ public async Task Select_existing_returns_the_expanded_name_match_when_detached( string command = ActualCommand(arguments); return command switch { + "-V" => Task.FromResult(Success(request)), "display-message" => Task.FromResult(Success(request, "-team-x\n")), "new-window" => Task.FromResult(Success(request)), "list-windows" => Task.FromResult(Success( @@ -353,7 +355,9 @@ public async Task Visible_environment_missing_after_set_is_unknown() public async Task Exact_missing_environment_result_remains_an_absence_answer() { Server server = CreateServer((request, _) => Task.FromResult( - Failure(request, 1, "unknown variable: MISSING\n"))); + request.LogicalArguments is ["-V"] + ? Success(request) + : Failure(request, 1, "unknown variable: MISSING\n"))); TmuxEnvironmentEntry? entry = await server.Environment.GetAsync( "MISSING", @@ -471,8 +475,7 @@ private static TmuxConnection CreateConnection( new ServerConnectionOptions( socketName: "composite-mutation-test", initializeAsync: initializeAsync), - execute, - implementation: TmuxImplementation.Tmux); + execute); private static TmuxTransportException NotDispatched( IReadOnlyList arguments, @@ -502,6 +505,14 @@ private static TmuxCommandResult Success( ServerGeneration? generation = null) { string[] arguments = [.. request.LogicalArguments]; + + // Every connection reads the version banner once before its first + // command, whatever else a test is scripting. + if (arguments is ["-V"]) + { + payload = "tmux 3.7\n"; + } + bool guarded = arguments.Contains("if-shell", StringComparer.Ordinal); ServerGeneration effectiveGeneration = generation ?? Generation; string output = guarded diff --git a/tests/LibTmux.UnitTests/Entities/PaneSendKeysDispatchTests.cs b/tests/LibTmux.UnitTests/Entities/PaneSendKeysDispatchTests.cs index 8225f2d..06f2445 100644 --- a/tests/LibTmux.UnitTests/Entities/PaneSendKeysDispatchTests.cs +++ b/tests/LibTmux.UnitTests/Entities/PaneSendKeysDispatchTests.cs @@ -3,6 +3,8 @@ using System.Text; using LibTmux.Internal; +using LibTmux.UnitTests.Connection; + namespace LibTmux.UnitTests.Entities; [UnsupportedOSPlatform("windows")] @@ -146,8 +148,7 @@ private static Pane CreatePane( { var connection = new TmuxConnection( new ServerConnectionOptions(socketName: "send-keys-dispatch-test"), - execute, - implementation: TmuxImplementation.Tmux); + FakeMultiplexer.AnsweringVersion(execute)); return new Pane(connection, Generation, new PaneId(1)); } diff --git a/tests/LibTmux.UnitTests/Mcp/JobStoreTests.cs b/tests/LibTmux.UnitTests/Mcp/JobStoreTests.cs index 12779bd..628fa2a 100644 --- a/tests/LibTmux.UnitTests/Mcp/JobStoreTests.cs +++ b/tests/LibTmux.UnitTests/Mcp/JobStoreTests.cs @@ -5,6 +5,7 @@ using System.Text.Json; using LibTmux.Internal; using LibTmux.Mcp; +using LibTmux.UnitTests.Connection; using Microsoft.Extensions.Logging; using ModelContextProtocol; @@ -1032,8 +1033,7 @@ internal FakeEndpoint(ServerConnectionOptions options, ServerGeneration generati Generation = generation; _connection = new TmuxConnection( options, - ExecuteAsync, - implementation: TmuxImplementation.Tmux); + FakeMultiplexer.AnsweringVersion(ExecuteAsync)); Server = new Server(_connection, generation, "tmux 3.7"); Pane = new Pane( Server, diff --git a/tests/LibTmux.UnitTests/Mcp/PaneReaderTests.cs b/tests/LibTmux.UnitTests/Mcp/PaneReaderTests.cs index 2453a96..488509b 100644 --- a/tests/LibTmux.UnitTests/Mcp/PaneReaderTests.cs +++ b/tests/LibTmux.UnitTests/Mcp/PaneReaderTests.cs @@ -2,6 +2,8 @@ using LibTmux.Internal; using LibTmux.Mcp; +using LibTmux.UnitTests.Connection; + namespace LibTmux.UnitTests.Mcp; [UnsupportedOSPlatform("windows")] @@ -89,14 +91,13 @@ private static Pane Pane() { var connection = new TmuxConnection( new ServerConnectionOptions(socketName: "pane-reader"), - static (request, _) => Task.FromResult(new TmuxCommandResult( + FakeMultiplexer.AnsweringVersion(static (request, _) => Task.FromResult(new TmuxCommandResult( request.LogicalArguments, 0, ReadOnlyMemory.Empty, ReadOnlyMemory.Empty, [], - [])), - implementation: TmuxImplementation.Tmux); + [])))); var server = new Server(connection, new ServerGeneration(17, 9001), "tmux 3.7"); return new Pane( server, diff --git a/tests/LibTmux.UnitTests/Mcp/PasteTextCleanupTests.cs b/tests/LibTmux.UnitTests/Mcp/PasteTextCleanupTests.cs index ae87fb1..08514f3 100644 --- a/tests/LibTmux.UnitTests/Mcp/PasteTextCleanupTests.cs +++ b/tests/LibTmux.UnitTests/Mcp/PasteTextCleanupTests.cs @@ -4,6 +4,8 @@ using LibTmux.Internal; using LibTmux.Mcp; +using LibTmux.UnitTests.Connection; + namespace LibTmux.UnitTests.Mcp; [UnsupportedOSPlatform("windows")] @@ -157,8 +159,7 @@ internal PasteFixture( : new InvalidOperationException("paste failed"); var connection = new TmuxConnection( new ServerConnectionOptions(socketName: "paste-cleanup-test"), - ExecuteAsync, - implementation: TmuxImplementation.Tmux); + FakeMultiplexer.AnsweringVersion(ExecuteAsync)); var server = new Server(connection, Generation, "tmux 3.7"); _accessor = new TmuxConnectionAccessor(server); Tools = new WriteTools(_accessor, new ServerPolicy(), _activity, _jobs); diff --git a/tests/LibTmux.UnitTests/Mcp/ReadToolsHistoryTests.cs b/tests/LibTmux.UnitTests/Mcp/ReadToolsHistoryTests.cs index 167482a..7958e48 100644 --- a/tests/LibTmux.UnitTests/Mcp/ReadToolsHistoryTests.cs +++ b/tests/LibTmux.UnitTests/Mcp/ReadToolsHistoryTests.cs @@ -4,6 +4,8 @@ using LibTmux.Internal; using LibTmux.Mcp; +using LibTmux.UnitTests.Connection; + namespace LibTmux.UnitTests; [UnsupportedOSPlatform("windows")] @@ -153,8 +155,7 @@ internal HistoryFixture() { var connection = new TmuxConnection( new ServerConnectionOptions(socketName: "history-test"), - ExecuteAsync, - implementation: TmuxImplementation.Tmux); + FakeMultiplexer.AnsweringVersion(ExecuteAsync)); var server = new Server(connection, Generation, "tmux 3.7"); Server = server; _accessor = new TmuxConnectionAccessor(server); diff --git a/tests/LibTmux.UnitTests/Mcp/SearchResultBudgetTests.cs b/tests/LibTmux.UnitTests/Mcp/SearchResultBudgetTests.cs index 151f7c6..7ca8bc9 100644 --- a/tests/LibTmux.UnitTests/Mcp/SearchResultBudgetTests.cs +++ b/tests/LibTmux.UnitTests/Mcp/SearchResultBudgetTests.cs @@ -2,6 +2,7 @@ using System.Text.RegularExpressions; using LibTmux.Internal; using LibTmux.Mcp; +using LibTmux.UnitTests.Connection; using ModelContextProtocol; namespace LibTmux.UnitTests; @@ -295,7 +296,7 @@ public async Task An_oversized_pattern_is_rejected_before_any_tmux_dispatch() int dispatches = 0; var connection = new TmuxConnection( new ServerConnectionOptions(socketName: "search-no-dispatch"), - (request, _) => + FakeMultiplexer.AnsweringVersion((request, _) => { Interlocked.Increment(ref dispatches); return Task.FromResult(new TmuxCommandResult( @@ -305,8 +306,7 @@ public async Task An_oversized_pattern_is_rejected_before_any_tmux_dispatch() ReadOnlyMemory.Empty, [], [])); - }, - implementation: TmuxImplementation.Tmux); + })); var generation = new ServerGeneration(11, 22); var server = new Server(connection, generation, "tmux 3.7"); using var accessor = new TmuxConnectionAccessor(server); diff --git a/tests/LibTmux.UnitTests/Mcp/StructuredTextResultBudgetTests.cs b/tests/LibTmux.UnitTests/Mcp/StructuredTextResultBudgetTests.cs index 9d05857..7caca74 100644 --- a/tests/LibTmux.UnitTests/Mcp/StructuredTextResultBudgetTests.cs +++ b/tests/LibTmux.UnitTests/Mcp/StructuredTextResultBudgetTests.cs @@ -3,6 +3,7 @@ using System.Text; using LibTmux.Internal; using LibTmux.Mcp; +using LibTmux.UnitTests.Connection; using ModelContextProtocol; namespace LibTmux.UnitTests; @@ -273,14 +274,13 @@ private static string WidestCursor() { var connection = new TmuxConnection( new ServerConnectionOptions(socketName: "budget-cursor"), - static (request, _) => Task.FromResult(new TmuxCommandResult( + FakeMultiplexer.AnsweringVersion(static (request, _) => Task.FromResult(new TmuxCommandResult( request.LogicalArguments, 0, ReadOnlyMemory.Empty, ReadOnlyMemory.Empty, [], - [])), - implementation: TmuxImplementation.Tmux); + [])))); var generation = new ServerGeneration(int.MaxValue, long.MaxValue); var pane = new Pane( new Server(connection, generation, "tmux 3.7"), diff --git a/tests/LibTmux.UnitTests/Mcp/TailCursorTests.cs b/tests/LibTmux.UnitTests/Mcp/TailCursorTests.cs index e262ead..1043c61 100644 --- a/tests/LibTmux.UnitTests/Mcp/TailCursorTests.cs +++ b/tests/LibTmux.UnitTests/Mcp/TailCursorTests.cs @@ -2,6 +2,7 @@ using System.Runtime.Versioning; using LibTmux.Internal; using LibTmux.Mcp; +using LibTmux.UnitTests.Connection; using ModelContextProtocol; namespace LibTmux.UnitTests; @@ -151,14 +152,13 @@ private static Pane PaneFor(string socketName, ServerGeneration generation, int { var connection = new TmuxConnection( new ServerConnectionOptions(socketName: socketName), - static (request, _) => Task.FromResult(new TmuxCommandResult( + FakeMultiplexer.AnsweringVersion(static (request, _) => Task.FromResult(new TmuxCommandResult( request.LogicalArguments, 0, ReadOnlyMemory.Empty, ReadOnlyMemory.Empty, [], - [])), - implementation: TmuxImplementation.Tmux); + [])))); var server = new Server(connection, generation, "tmux 3.7"); return new Pane( server, diff --git a/tests/LibTmux.UnitTests/Mcp/WaitInputBudgetTests.cs b/tests/LibTmux.UnitTests/Mcp/WaitInputBudgetTests.cs index 0667049..03e68ab 100644 --- a/tests/LibTmux.UnitTests/Mcp/WaitInputBudgetTests.cs +++ b/tests/LibTmux.UnitTests/Mcp/WaitInputBudgetTests.cs @@ -2,6 +2,7 @@ using System.Text.RegularExpressions; using LibTmux.Internal; using LibTmux.Mcp; +using LibTmux.UnitTests.Connection; using ModelContextProtocol; namespace LibTmux.UnitTests; @@ -51,7 +52,7 @@ public async Task Invalid_wait_inputs_are_rejected_before_tmux_dispatch() int dispatches = 0; var connection = new TmuxConnection( new ServerConnectionOptions(socketName: "wait-no-dispatch"), - (request, _) => + FakeMultiplexer.AnsweringVersion((request, _) => { Interlocked.Increment(ref dispatches); return Task.FromResult(new TmuxCommandResult( @@ -61,8 +62,7 @@ public async Task Invalid_wait_inputs_are_rejected_before_tmux_dispatch() ReadOnlyMemory.Empty, [], [])); - }, - implementation: TmuxImplementation.Tmux); + })); var generation = new ServerGeneration(11, 22); var server = new Server(connection, generation, "tmux 3.7"); using var accessor = new TmuxConnectionAccessor(server); diff --git a/tests/LibTmux.UnitTests/Mcp/WriteToolsExecutionSafetyTests.cs b/tests/LibTmux.UnitTests/Mcp/WriteToolsExecutionSafetyTests.cs index f148f1f..fe19db1 100644 --- a/tests/LibTmux.UnitTests/Mcp/WriteToolsExecutionSafetyTests.cs +++ b/tests/LibTmux.UnitTests/Mcp/WriteToolsExecutionSafetyTests.cs @@ -3,6 +3,7 @@ using System.Text; using LibTmux.Internal; using LibTmux.Mcp; +using LibTmux.UnitTests.Connection; using ModelContextProtocol; namespace LibTmux.UnitTests.Mcp; @@ -466,8 +467,7 @@ internal ToolFixture(ServerPolicy? policy = null) new InvalidOperationException("Fake control attach unavailable."))); var connection = new TmuxConnection( new ServerConnectionOptions(socketName: "execution-safety"), - ExecuteAsync, - implementation: TmuxImplementation.Tmux); + FakeMultiplexer.AnsweringVersion(ExecuteAsync)); var server = new Server(connection, Generation, "tmux 3.7"); _accessor = new TmuxConnectionAccessor(server); ServerPolicy effectivePolicy = policy ?? new ServerPolicy(); diff --git a/tests/LibTmux.UnitTests/Versioning/TmuxCapabilitiesTests.cs b/tests/LibTmux.UnitTests/Versioning/TmuxCapabilitiesTests.cs index 9aa11cf..ebdef54 100644 --- a/tests/LibTmux.UnitTests/Versioning/TmuxCapabilitiesTests.cs +++ b/tests/LibTmux.UnitTests/Versioning/TmuxCapabilitiesTests.cs @@ -3,6 +3,8 @@ using System.Text; using LibTmux.Internal; +using LibTmux.UnitTests.Connection; + namespace LibTmux.UnitTests.Versioning; public sealed class TmuxCapabilitiesTests @@ -609,14 +611,14 @@ private static Server CreateServerWithRawVersion(string rawVersion) }); var connection = new TmuxConnection( new ServerConnectionOptions(), - static (request, _) => Task.FromResult( + FakeMultiplexer.AnsweringVersion(static (request, _) => Task.FromResult( new TmuxCommandResult( request.LogicalArguments, 0, ReadOnlyMemory.Empty, ReadOnlyMemory.Empty, [], - []))); + [])))); return (Server)constructor.Invoke( [connection, new ServerGeneration(1, 1), rawVersion]); } From 6501eb33d3daf17851e4bb8daf38aac204a6460a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 17:16:40 -0500 Subject: [PATCH 086/129] Connection(refactor[dialect]): Publish the banner without a lock why: The version latch guarded its publication against two concurrent first dispatches reading different banners, which the dialect split made unreachable: the dialect is chosen from the options, and each reading is accepted or refused on its own before publication. What was left was a lock and a throw no test could reach. The comment beside the identifier check carried five facts where the house ceiling is four; the one about the server's own fields is about the read rather than the check, so it moves to the method. what: - Publish the first accepted banner with an interlocked exchange and drop the lock and the two-readings refusal - Cut the CMD_FIND_CANFAIL comment to the constraint - Declare TmuxCommandContext.Logger internal, matching its type --- src/LibTmux/Connection/MultiplexerDialect.cs | 16 ++++------------ src/LibTmux/Diagnostics/TmuxLog.cs | 2 +- .../Materialization/TmuxMaterializationQuery.cs | 16 ++++++++-------- .../Connection/TmuxConnectionTests.cs | 1 + 4 files changed, 14 insertions(+), 21 deletions(-) diff --git a/src/LibTmux/Connection/MultiplexerDialect.cs b/src/LibTmux/Connection/MultiplexerDialect.cs index 6a852a4..cdb1cf7 100644 --- a/src/LibTmux/Connection/MultiplexerDialect.cs +++ b/src/LibTmux/Connection/MultiplexerDialect.cs @@ -11,7 +11,6 @@ internal abstract class MultiplexerDialect { private readonly Func> _executeVersion; - private readonly object _publication = new(); private string? _rawVersion; private protected MultiplexerDialect( @@ -91,17 +90,10 @@ private protected async Task EnsureVerifiedAsync(CancellationToken cance } AcceptBanner(banner); - lock (_publication) - { - if (_rawVersion is not null - && !string.Equals(_rawVersion, banner.RawVersion, StringComparison.Ordinal)) - { - throw new InvalidDataException( - "The multiplexer changed version between two readings."); - } - _rawVersion = banner.RawVersion; - return banner.RawVersion; - } + // Two first dispatches can read the banner at once. Each is accepted on + // its own, so the first published reading is the one they both use. + return Interlocked.CompareExchange(ref _rawVersion, banner.RawVersion, null) + ?? banner.RawVersion; } } diff --git a/src/LibTmux/Diagnostics/TmuxLog.cs b/src/LibTmux/Diagnostics/TmuxLog.cs index 2ec42bb..628e9a7 100644 --- a/src/LibTmux/Diagnostics/TmuxLog.cs +++ b/src/LibTmux/Diagnostics/TmuxLog.cs @@ -20,7 +20,7 @@ internal TmuxCommandContext(ILogger logger, string? socket) } /// Gets the logger tmux commands are recorded through. - public ILogger Logger { get; } + internal ILogger Logger { get; } /// Gets the socket the commands are sent to, when one is named. internal string? Socket { get; } diff --git a/src/LibTmux/Materialization/TmuxMaterializationQuery.cs b/src/LibTmux/Materialization/TmuxMaterializationQuery.cs index 6a7ab38..9a473bd 100644 --- a/src/LibTmux/Materialization/TmuxMaterializationQuery.cs +++ b/src/LibTmux/Materialization/TmuxMaterializationQuery.cs @@ -57,6 +57,10 @@ internal MaterializationQuery(MaterializationContext context) /// one row per entity on it. Reading the scoped target first keeps a /// refreshed handle in the session its predecessor was read in; the bare /// identifier still answers when the entity has left that session. + /// + /// The server's own fields resolve whether or not the target does, so a + /// stale generation is rejected before absence is reported. + /// /// [UnsupportedOSPlatform("windows")] internal async Task?> FetchOneAsync( @@ -113,14 +117,10 @@ internal MaterializationQuery(MaterializationContext context) arguments); } - // display-message declares its target CMD_FIND_CANFAIL and exits zero - // on one it cannot resolve. A target that resolves to nothing leaves - // every entity field empty; one that resolves only in part -- a session - // that still exists naming a window that does not -- answers with that - // session's current window or pane. Requiring the identifier back - // separates either from the entity being there. The server's own fields - // resolve throughout, so a stale generation is still rejected before - // absence is reported. + // display-message declares its target CMD_FIND_CANFAIL and exits zero on + // one it cannot resolve: an unresolvable target leaves every entity + // field empty, and one that resolves only in part answers with its + // session's current window or pane. The identifier separates both. return rows[0].TryGetValue(idWireName, out string? id) && string.Equals(id, identifier, StringComparison.Ordinal) ? rows[0] diff --git a/tests/LibTmux.UnitTests/Connection/TmuxConnectionTests.cs b/tests/LibTmux.UnitTests/Connection/TmuxConnectionTests.cs index 508c2c6..aac6b31 100644 --- a/tests/LibTmux.UnitTests/Connection/TmuxConnectionTests.cs +++ b/tests/LibTmux.UnitTests/Connection/TmuxConnectionTests.cs @@ -28,6 +28,7 @@ public ConnectionUnixFactAttribute( public sealed class ConnectionValueTests { + [Fact] public void Typed_ids_validate_and_round_trip_canonical_values() { From 2c90101f3e161d55632d3ebccd830e35050915cc Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 17:19:11 -0500 Subject: [PATCH 087/129] Chaining(fix[commands]): Name the session a window is created in why: A probe of the chaining surface found two defects in it. NewWindowRequest.ToCommand took a bare session-target string where every other request in the family takes the object the command acts on. The string form is not checkable, and it is wrong in a way that compiles: building one with "$1:" rather than "$1" fails against tmux with "can't find window: :". Taking the session also lets the command carry that session's generation, which the identifier needs for the same reason a chained pane command does -- the identifier travels as plain text, and after a restart it could name a different session. A request that answers several commands could not join a chain whole. ToCommands returns a list and Then took one command, so a caller had to unroll the list itself. what: - Take a Session rather than a target string, and carry its generation - Add TmuxChain.Then for a sequence of commands - Cover the generation a chained window command carries, and a chain that mixes two servers --- docs/modes/chaining.md | 2 +- src/LibTmux/Chaining/TmuxChain.cs | 21 ++++++++++++ src/LibTmux/Chaining/TmuxChaining.Windows.cs | 16 ++++++---- src/LibTmux/PublicAPI.Unshipped.txt | 27 ++++++++-------- .../Chaining/TmuxChainTests.cs | 32 +++++++++++++++++-- 5 files changed, 75 insertions(+), 23 deletions(-) diff --git a/docs/modes/chaining.md b/docs/modes/chaining.md index 6052671..a0f3c80 100644 --- a/docs/modes/chaining.md +++ b/docs/modes/chaining.md @@ -37,7 +37,7 @@ rather than dropping to strings: ```csharp await server.Chain() - .Then(new NewWindowRequest(name: "build").ToCommand(session.Id.ToString())) + .Then(new NewWindowRequest(name: "build").ToCommand(session)) .Then(new SendKeysRequest("make").ToCommand(pane)) .ExecuteAsync(ct); ``` diff --git a/src/LibTmux/Chaining/TmuxChain.cs b/src/LibTmux/Chaining/TmuxChain.cs index cc29041..4721263 100644 --- a/src/LibTmux/Chaining/TmuxChain.cs +++ b/src/LibTmux/Chaining/TmuxChain.cs @@ -48,6 +48,27 @@ public TmuxChain Then(TmuxCommand command) return new TmuxChain(_dispatcher, [.. _commands, command], _guarded); } + /// Adds every command in order and returns the longer chain. + /// The commands to run, in order, after the ones already added. + /// A chain ending with . + /// + /// One request can answer several commands. This takes what + /// returns + /// without unrolling it at the call site. + /// + /// is null. + public TmuxChain Then(IEnumerable commands) + { + ArgumentNullException.ThrowIfNull(commands); + TmuxCommand[] added = [.. commands]; + if (Array.IndexOf(added, null) >= 0) + { + throw new ArgumentException("A chained command cannot be null.", nameof(commands)); + } + + return new TmuxChain(_dispatcher, [.. _commands, .. added], _guarded); + } + /// Adds one command by name and returns the longer chain. /// The tmux command name. /// Its arguments. diff --git a/src/LibTmux/Chaining/TmuxChaining.Windows.cs b/src/LibTmux/Chaining/TmuxChaining.Windows.cs index 8fa08aa..12c9954 100644 --- a/src/LibTmux/Chaining/TmuxChaining.Windows.cs +++ b/src/LibTmux/Chaining/TmuxChaining.Windows.cs @@ -7,15 +7,17 @@ public static partial class TmuxChaining { /// Returns a window request as one tmux command. /// The window to create. - /// The session the window is created in. + /// The session the window is created in. /// The command, ready to add to a . - /// is null. - /// is empty. - public static TmuxCommand ToCommand(this NewWindowRequest request, string target) + /// An argument is null. + public static TmuxCommand ToCommand(this NewWindowRequest request, Session session) { ArgumentNullException.ThrowIfNull(request); - ArgumentException.ThrowIfNullOrEmpty(target); - return Command([.. Session.BuildNewWindowArguments(request, target)]); + ArgumentNullException.ThrowIfNull(session); + return Command([.. Session.BuildNewWindowArguments(request, session.Id.ToString())]) with + { + RequiredGeneration = session.Generation, + }; } /// Returns a layout request as one tmux command for a window. @@ -167,7 +169,7 @@ public static Task ExecuteAsync( ArgumentNullException.ThrowIfNull(session); return session.Server .Chain() - .Then(request.ToCommand(session.Id.ToString())) + .Then(request.ToCommand(session)) .ExecuteAsync(cancellationToken); } } diff --git a/src/LibTmux/PublicAPI.Unshipped.txt b/src/LibTmux/PublicAPI.Unshipped.txt index 286b223..676e2c2 100644 --- a/src/LibTmux/PublicAPI.Unshipped.txt +++ b/src/LibTmux/PublicAPI.Unshipped.txt @@ -491,10 +491,6 @@ LibTmux.PsmuxWindow.Name.get -> string! LibTmux.PsmuxWindow.Server.get -> LibTmux.PsmuxServer! LibTmux.PsmuxWindow.SessionId.get -> LibTmux.SessionId LibTmux.PsmuxWindow.Width.get -> int -const LibTmux.PsmuxServer.SupportedBinarySha256 = "54e5c54db259218348f966b5d0d0b5153fdef6350074855ea9ce627d20537b0d" -> string! -const LibTmux.PsmuxServer.SupportedCommit = "66cf61354c473b35d4f0c06c57384fc46d61ffdb" -> string! -const LibTmux.PsmuxServer.SupportedImplementationBanner = "psmux 3.3.8 (66cf613 2026-08-18)" -> string! -static LibTmux.PsmuxServer.ConnectAsync(LibTmux.PsmuxConnectionOptions! options, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! LibTmux.Query.AndNode LibTmux.Query.AndNode.AndNode(System.Collections.Generic.IReadOnlyList! operands) -> void LibTmux.Query.AndNode.Equals(LibTmux.Query.AndNode? other) -> bool @@ -762,6 +758,7 @@ LibTmux.Server.KillSessionAsync(string! target, System.Threading.CancellationTok LibTmux.Server.LoadBufferAsync(string! path, string? name = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! LibTmux.Server.LockAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! LibTmux.Server.LockClientAsync(string? targetClient = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +LibTmux.Server.OpenWaitChannel(string! channel) -> LibTmux.TmuxWaitChannel! LibTmux.Server.Options.get -> LibTmux.TmuxOptions! LibTmux.Server.Panes.get -> LibTmux.CapturedRelation! LibTmux.Server.RaiseIfDeadAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! @@ -927,8 +924,8 @@ LibTmux.SplitPaneRequest.Zoom.get -> bool LibTmux.StaleServerGenerationException LibTmux.StaleServerGenerationException.Actual.get -> LibTmux.ServerGeneration? LibTmux.StaleServerGenerationException.Expected.get -> LibTmux.ServerGeneration -LibTmux.StaleServerGenerationException.StaleServerGenerationException(string! message, LibTmux.ServerGeneration expected, System.Exception? innerException = null) -> void LibTmux.StaleServerGenerationException.StaleServerGenerationException(string! message, LibTmux.ServerGeneration expected, LibTmux.ServerGeneration actual, System.Exception? innerException = null) -> void +LibTmux.StaleServerGenerationException.StaleServerGenerationException(string! message, LibTmux.ServerGeneration expected, System.Exception? innerException = null) -> void LibTmux.SwapPaneRequest LibTmux.SwapPaneRequest.$() -> LibTmux.SwapPaneRequest! LibTmux.SwapPaneRequest.Detach.get -> bool @@ -999,6 +996,7 @@ LibTmux.TmuxChain LibTmux.TmuxChain.Commands.get -> System.Collections.Generic.IReadOnlyList! LibTmux.TmuxChain.ExecuteAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! LibTmux.TmuxChain.Then(LibTmux.TmuxCommand! command) -> LibTmux.TmuxChain! +LibTmux.TmuxChain.Then(System.Collections.Generic.IEnumerable! commands) -> LibTmux.TmuxChain! LibTmux.TmuxChain.Then(string! name, params string![]! arguments) -> LibTmux.TmuxChain! LibTmux.TmuxChaining LibTmux.TmuxCleanupException @@ -1177,6 +1175,11 @@ LibTmux.TmuxVersionTooLowException LibTmux.TmuxVersionTooLowException.ActualVersion.get -> LibTmux.TmuxVersion LibTmux.TmuxVersionTooLowException.RequiredVersion.get -> LibTmux.TmuxVersion LibTmux.TmuxVersionTooLowException.TmuxVersionTooLowException(string! message, LibTmux.TmuxVersion requiredVersion, LibTmux.TmuxVersion actualVersion, System.Exception? innerException = null) -> void +LibTmux.TmuxWaitChannel +LibTmux.TmuxWaitChannel.Channel.get -> string! +LibTmux.TmuxWaitChannel.DisposeAsync() -> System.Threading.Tasks.ValueTask +LibTmux.TmuxWaitChannel.Signalled.get -> bool +LibTmux.TmuxWaitChannel.WaitAsync(System.TimeSpan budget, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! LibTmux.TmuxWaitMode LibTmux.TmuxWaitMode.Lock = 2 -> LibTmux.TmuxWaitMode LibTmux.TmuxWaitMode.Signal = 1 -> LibTmux.TmuxWaitMode @@ -1290,6 +1293,9 @@ LibTmux.WindowRotationDirection.Up = 0 -> LibTmux.WindowRotationDirection abstract LibTmux.Query.QueryConstant.$() -> LibTmux.Query.QueryConstant! abstract LibTmux.Query.QueryNode.$() -> LibTmux.Query.QueryNode! abstract LibTmux.TmuxEvent.$() -> LibTmux.TmuxEvent! +const LibTmux.PsmuxServer.SupportedBinarySha256 = "54e5c54db259218348f966b5d0d0b5153fdef6350074855ea9ce627d20537b0d" -> string! +const LibTmux.PsmuxServer.SupportedCommit = "66cf61354c473b35d4f0c06c57384fc46d61ffdb" -> string! +const LibTmux.PsmuxServer.SupportedImplementationBanner = "psmux 3.3.8 (66cf613 2026-08-18)" -> string! const LibTmux.Query.QueryDocument.CurrentSchema = "libtmux-query" -> string! const LibTmux.Query.QueryDocument.CurrentVersion = 1 -> int override LibTmux.AttachSessionRequest.Equals(object? obj) -> bool @@ -1598,8 +1604,8 @@ override sealed LibTmux.Query.RegexNode.Equals(LibTmux.Query.QueryNode? other) - override sealed LibTmux.Query.StringConstant.Equals(LibTmux.Query.QueryConstant? other) -> bool override sealed LibTmux.Query.StringNode.Equals(LibTmux.Query.QueryNode? other) -> bool override sealed LibTmux.Query.TypedIdConstant.Equals(LibTmux.Query.QueryConstant? other) -> bool -override sealed LibTmux.TmuxExitEvent.Equals(LibTmux.TmuxEvent? other) -> bool override sealed LibTmux.TmuxEventsDroppedEvent.Equals(LibTmux.TmuxEvent? other) -> bool +override sealed LibTmux.TmuxExitEvent.Equals(LibTmux.TmuxEvent? other) -> bool override sealed LibTmux.TmuxNotificationEvent.Equals(LibTmux.TmuxEvent? other) -> bool override sealed LibTmux.TmuxOutputEvent.Equals(LibTmux.TmuxEvent? other) -> bool static LibTmux.AttachSessionRequest.operator !=(LibTmux.AttachSessionRequest? left, LibTmux.AttachSessionRequest? right) -> bool @@ -1667,6 +1673,7 @@ static LibTmux.PasteBufferRequest.operator !=(LibTmux.PasteBufferRequest? left, static LibTmux.PasteBufferRequest.operator ==(LibTmux.PasteBufferRequest? left, LibTmux.PasteBufferRequest? right) -> bool static LibTmux.PipePaneRequest.operator !=(LibTmux.PipePaneRequest? left, LibTmux.PipePaneRequest? right) -> bool static LibTmux.PipePaneRequest.operator ==(LibTmux.PipePaneRequest? left, LibTmux.PipePaneRequest? right) -> bool +static LibTmux.PsmuxServer.ConnectAsync(LibTmux.PsmuxConnectionOptions! options, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! static LibTmux.Query.AndNode.operator !=(LibTmux.Query.AndNode? left, LibTmux.Query.AndNode? right) -> bool static LibTmux.Query.AndNode.operator ==(LibTmux.Query.AndNode? left, LibTmux.Query.AndNode? right) -> bool static LibTmux.Query.BooleanConstant.operator !=(LibTmux.Query.BooleanConstant? left, LibTmux.Query.BooleanConstant? right) -> bool @@ -1820,7 +1827,7 @@ static LibTmux.TmuxChaining.ToCommand(this LibTmux.MovePaneRequest! request, Lib static LibTmux.TmuxChaining.ToCommand(this LibTmux.MoveWindowRequest! request, LibTmux.Window! window) -> LibTmux.TmuxCommand! static LibTmux.TmuxChaining.ToCommand(this LibTmux.NewPaneRequest! request, LibTmux.Pane! pane) -> LibTmux.TmuxCommand! static LibTmux.TmuxChaining.ToCommand(this LibTmux.NewSessionRequest! request) -> LibTmux.TmuxCommand! -static LibTmux.TmuxChaining.ToCommand(this LibTmux.NewWindowRequest! request, string! target) -> LibTmux.TmuxCommand! +static LibTmux.TmuxChaining.ToCommand(this LibTmux.NewWindowRequest! request, LibTmux.Session! session) -> LibTmux.TmuxCommand! static LibTmux.TmuxChaining.ToCommand(this LibTmux.PasteBufferRequest! request, LibTmux.Pane! pane) -> LibTmux.TmuxCommand! static LibTmux.TmuxChaining.ToCommand(this LibTmux.PipePaneRequest! request, LibTmux.Pane! pane) -> LibTmux.TmuxCommand! static LibTmux.TmuxChaining.ToCommand(this LibTmux.ResizePaneRequest! request, LibTmux.Pane! pane) -> LibTmux.TmuxCommand! @@ -1910,9 +1917,3 @@ virtual LibTmux.Query.QueryNode.PrintMembers(System.Text.StringBuilder! builder) virtual LibTmux.TmuxEvent.EqualityContract.get -> System.Type! virtual LibTmux.TmuxEvent.Equals(LibTmux.TmuxEvent? other) -> bool virtual LibTmux.TmuxEvent.PrintMembers(System.Text.StringBuilder! builder) -> bool -LibTmux.Server.OpenWaitChannel(string! channel) -> LibTmux.TmuxWaitChannel! -LibTmux.TmuxWaitChannel -LibTmux.TmuxWaitChannel.Channel.get -> string! -LibTmux.TmuxWaitChannel.DisposeAsync() -> System.Threading.Tasks.ValueTask -LibTmux.TmuxWaitChannel.Signalled.get -> bool -LibTmux.TmuxWaitChannel.WaitAsync(System.TimeSpan budget, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! diff --git a/tests/LibTmux.IntegrationTests/Chaining/TmuxChainTests.cs b/tests/LibTmux.IntegrationTests/Chaining/TmuxChainTests.cs index 129bc14..58fc1aa 100644 --- a/tests/LibTmux.IntegrationTests/Chaining/TmuxChainTests.cs +++ b/tests/LibTmux.IntegrationTests/Chaining/TmuxChainTests.cs @@ -141,8 +141,11 @@ public async Task A_typed_request_chains_the_same_way_it_runs_alone() // The one-shot and chained paths build arguments from the same code, // so this compares the built command against what the wrapper sends. - TmuxCommand command = request.ToCommand(session.Id.ToString()); + TmuxCommand command = request.ToCommand(session); + // The session identifier travels into the chain as plain text, so the + // command carries the generation that identifier belongs to. + Assert.Equal(session.Generation, command.RequiredGeneration); Assert.Equal("new-window", command.Name); Assert.Contains("typed", command.Arguments); Assert.Contains("/tmp", command.Arguments); @@ -867,6 +870,29 @@ public async Task A_chooser_chains_and_keeps_the_dropped_sort_order_out() Assert.True(await server.IsAliveAsync(token)); } + [UnixFact] + public async Task A_chain_refuses_commands_from_two_servers() + { + await using RawTmuxTestContext raw = await RawTmuxTestContext.StartAsync( + TestContext.Current.CancellationToken); + CancellationToken token = TestContext.Current.CancellationToken; + Server server = await ConnectAsync(raw, token); + Session session = await TestHierarchy.RequireFirstSessionAsync(server, token); + + // At most one of two generations names a running server, so a chain + // carrying both cannot be valid however tmux answers it. + TmuxCommand here = new NewWindowRequest(name: "here").ToCommand(session); + TmuxCommand elsewhere = here with + { + RequiredGeneration = new ServerGeneration( + session.Generation.ProcessId + 1, + session.Generation.StartTime), + }; + + await Assert.ThrowsAsync( + () => server.Chain().Then(here).Then(elsewhere).ExecuteAsync(token)); + } + [UnixFact] public async Task Buffer_listing_and_access_chain() { @@ -1066,7 +1092,9 @@ public async Task Hook_entries_and_listings_chain() Assert.Equal(3, entries.ToCommands(server.Hooks).Count); - await entries.ExecuteAsync(server.Hooks, server, token); + // A request answering several commands joins a chain whole, so the + // clear and both entries reach tmux in one invocation. + await server.Chain().Then(entries.ToCommands(server.Hooks)).ExecuteAsync(token); TmuxCommandResult listed = await new ListHooksRequest() .ExecuteAsync(server.Hooks, server, token); From 60788a71bb558f109cdfa06f7cf28acdf1fc58cd Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 17:24:47 -0500 Subject: [PATCH 088/129] Hierarchy(fix[ownership]): Give every handle the server it came from why: A handle resolved by identifier carried no server, so almost nothing on it worked. Server.GetPaneAsync returned one: asking it for Server, Session, Window or Options threw IncompleteSnapshotException, while Hooks answered, because Hooks needs only the dispatcher and Options reaches for the owner to learn which tmux is escaping dollar signs. The same handle came out of every relation accessor, so pane.Session.Options threw where pane.Session.Hooks did not. The server is not a field a handle failed to read. It is the connection the handle was obtained through, and every public path has it. Requiring it in the constructor makes the missing-owner case unrepresentable rather than something each caller remembers to pass. what: - Require the owning server in every Session, Window and Pane constructor and pass it from the materializer, the relation accessors and the identifier lookups - Move the dollar-escaping rule to TmuxOptions, where it tolerates a handle whose server is unknown instead of throwing, and delete the four copies of it - Say in the documentation that a handle always carries its server - Cover an identifier-resolved pane reaching both its hook and option tables --- .../Materialization/TmuxMaterializer.cs | 6 ++--- src/LibTmux/Options/TmuxOptions.cs | 6 +++++ src/LibTmux/Pane.Identity.cs | 11 +++++++-- src/LibTmux/Pane.Options.cs | 7 +----- src/LibTmux/Pane.Relations.cs | 24 +++++-------------- src/LibTmux/Server.Identity.cs | 6 ++--- src/LibTmux/Server.Options.cs | 7 +----- src/LibTmux/Session.Identity.cs | 11 +++++++-- src/LibTmux/Session.Lifecycle.cs | 7 +++--- src/LibTmux/Session.Options.cs | 7 +----- src/LibTmux/Session.Relations.cs | 17 ++----------- src/LibTmux/Window.Identity.cs | 11 +++++++-- src/LibTmux/Window.Options.cs | 7 +----- src/LibTmux/Window.Relations.cs | 15 +----------- src/LibTmux/Window.State.cs | 9 +++---- .../Hierarchy/PaneOperationsTests.cs | 17 +++++++++++++ .../Connection/TmuxConnectionTests.cs | 15 +++++++----- .../CompositeMutationDispatchTests.cs | 6 ++++- .../Entities/PaneSendKeysDispatchTests.cs | 6 ++++- 19 files changed, 97 insertions(+), 98 deletions(-) diff --git a/src/LibTmux/Materialization/TmuxMaterializer.cs b/src/LibTmux/Materialization/TmuxMaterializer.cs index 18f4b2b..611210e 100644 --- a/src/LibTmux/Materialization/TmuxMaterializer.cs +++ b/src/LibTmux/Materialization/TmuxMaterializer.cs @@ -75,7 +75,7 @@ internal static Session MaterializeSession( EntityMaterializationState state = CreateState(context, fields); SessionId id = state.SessionId ?? throw new InvalidDataException("tmux row carries no session identifier."); - return new Session(RequireConnection(context), state.Generation, id, state.RawFields); + return new Session(context.Server, RequireConnection(context), state.Generation, id, state.RawFields); } /// Materializes one window from framed bytes. @@ -100,7 +100,7 @@ internal static Window MaterializeWindow( EntityMaterializationState state = CreateState(context, fields); WindowId id = state.WindowId ?? throw new InvalidDataException("tmux row carries no window identifier."); - return new Window(RequireConnection(context), state.Generation, id, state.RawFields); + return new Window(context.Server, RequireConnection(context), state.Generation, id, state.RawFields); } /// Materializes one pane from framed bytes. @@ -128,7 +128,7 @@ internal static Pane MaterializePane( throw new InvalidDataException("tmux row carries a malformed pane identifier."); } - return new Pane(RequireConnection(context), state.Generation, id, state.RawFields); + return new Pane(context.Server, RequireConnection(context), state.Generation, id, state.RawFields); } /// Builds the hierarchy state one materialized row carries. diff --git a/src/LibTmux/Options/TmuxOptions.cs b/src/LibTmux/Options/TmuxOptions.cs index a516b37..7f209d6 100644 --- a/src/LibTmux/Options/TmuxOptions.cs +++ b/src/LibTmux/Options/TmuxOptions.cs @@ -17,6 +17,12 @@ public sealed class TmuxOptions private readonly string? _target; private readonly bool _doubleEscapedDollar; + /// Reports whether a tmux escapes a dollar sign twice in an option value. + /// The server answering, or null when it is not known. + internal static bool DoubleEscapesDollar(Server? owner) => + owner?.Version is TmuxVersion version + && TmuxCapabilities.IsSupported(version, "option_dollar_double_escape"); + internal TmuxOptions( TmuxCommandDispatcher dispatcher, OptionScope scope, diff --git a/src/LibTmux/Pane.Identity.cs b/src/LibTmux/Pane.Identity.cs index 385305d..2f2f2aa 100644 --- a/src/LibTmux/Pane.Identity.cs +++ b/src/LibTmux/Pane.Identity.cs @@ -11,20 +11,27 @@ public sealed partial class Pane private readonly IReadOnlyDictionary? _snapshot; [UnsupportedOSPlatform("windows")] - internal Pane(TmuxConnection connection, ServerGeneration generation, PaneId id) + internal Pane( + Server owner, + TmuxConnection connection, + ServerGeneration generation, + PaneId id) : this(connection.CreateEntityDispatcher(generation), TmuxTarget.From(id).Value) { + ArgumentNullException.ThrowIfNull(owner); + _owner = owner; _id = id; _generation = generation; } [UnsupportedOSPlatform("windows")] internal Pane( + Server owner, TmuxConnection connection, ServerGeneration generation, PaneId id, IReadOnlyDictionary snapshot) - : this(connection, generation, id) + : this(owner, connection, generation, id) { ArgumentNullException.ThrowIfNull(snapshot); _snapshot = snapshot; diff --git a/src/LibTmux/Pane.Options.cs b/src/LibTmux/Pane.Options.cs index f5635b8..5a2e8a3 100644 --- a/src/LibTmux/Pane.Options.cs +++ b/src/LibTmux/Pane.Options.cs @@ -1,5 +1,4 @@ using System.Runtime.Versioning; -using LibTmux.Internal; namespace LibTmux; @@ -14,9 +13,5 @@ public sealed partial class Pane _commandDispatcher, OptionScope.Pane, _id.ToString(), - DoubleEscapesDollar(Server)); - - private static bool DoubleEscapesDollar(Server? owner) => - owner?.Version is TmuxVersion version - && TmuxCapabilities.IsSupported(version, "option_dollar_double_escape"); + TmuxOptions.DoubleEscapesDollar(_owner)); } diff --git a/src/LibTmux/Pane.Relations.cs b/src/LibTmux/Pane.Relations.cs index d1fe231..7894af7 100644 --- a/src/LibTmux/Pane.Relations.cs +++ b/src/LibTmux/Pane.Relations.cs @@ -8,23 +8,11 @@ public sealed partial class Pane { private readonly Server? _owner; - [UnsupportedOSPlatform("windows")] - internal Pane( - Server owner, - TmuxConnection connection, - ServerGeneration generation, - PaneId id, - IReadOnlyDictionary snapshot) - : this(connection, generation, id, snapshot) - { - ArgumentNullException.ThrowIfNull(owner); - _owner = owner; - } - /// Gets the server that owns this pane. - /// - /// The pane was resolved by identifier rather than materialized. - /// + /// + /// Every handle reached through a server carries it, whether the handle was + /// materialized from a listing or resolved from an identifier. + /// public Server Server => _owner ?? throw new IncompleteSnapshotException("server", SnapshotDepth.Server); @@ -42,7 +30,7 @@ public Session Session throw new IncompleteSnapshotException("session", SnapshotDepth.Server); } - return new Session(RequireConnection(), _generation, id); + return new Session(Server, RequireConnection(), _generation, id); } } @@ -60,7 +48,7 @@ public Window Window throw new IncompleteSnapshotException("window", SnapshotDepth.Server); } - return new Window(RequireConnection(), _generation, id); + return new Window(Server, RequireConnection(), _generation, id); } } diff --git a/src/LibTmux/Server.Identity.cs b/src/LibTmux/Server.Identity.cs index 2899040..40adccf 100644 --- a/src/LibTmux/Server.Identity.cs +++ b/src/LibTmux/Server.Identity.cs @@ -110,7 +110,7 @@ public async Task GetSessionAsync( Session? materialized = null; MaterializeSession(connection, identity.Value.Generation, identity.Value.Id, ref materialized); - return materialized ?? new Session(connection, identity.Value.Generation, identity.Value.Id); + return materialized ?? new Session(this, connection, identity.Value.Generation, identity.Value.Id); } /// Gets one window by its typed identifier. @@ -130,7 +130,7 @@ public async Task GetWindowAsync( Window? materialized = null; MaterializeWindow(connection, identity.Value.Generation, identity.Value.Id, ref materialized); - return materialized ?? new Window(connection, identity.Value.Generation, identity.Value.Id); + return materialized ?? new Window(this, connection, identity.Value.Generation, identity.Value.Id); } /// Gets one pane by its typed identifier. @@ -150,7 +150,7 @@ public async Task GetPaneAsync( Pane? materialized = null; MaterializePane(connection, identity.Value.Generation, identity.Value.Id, ref materialized); - return materialized ?? new Pane(connection, identity.Value.Generation, identity.Value.Id); + return materialized ?? new Pane(this, connection, identity.Value.Generation, identity.Value.Id); } partial void MaterializeSession( diff --git a/src/LibTmux/Server.Options.cs b/src/LibTmux/Server.Options.cs index a2bedac..4bcd49d 100644 --- a/src/LibTmux/Server.Options.cs +++ b/src/LibTmux/Server.Options.cs @@ -1,5 +1,4 @@ using System.Runtime.Versioning; -using LibTmux.Internal; namespace LibTmux; @@ -18,9 +17,5 @@ public sealed partial class Server _commandDispatcher, OptionScope.Server, null, - DoubleEscapesDollar(this)); - - private static bool DoubleEscapesDollar(Server? owner) => - owner?.Version is TmuxVersion version - && TmuxCapabilities.IsSupported(version, "option_dollar_double_escape"); + TmuxOptions.DoubleEscapesDollar(this)); } diff --git a/src/LibTmux/Session.Identity.cs b/src/LibTmux/Session.Identity.cs index ae962be..345d7f8 100644 --- a/src/LibTmux/Session.Identity.cs +++ b/src/LibTmux/Session.Identity.cs @@ -11,20 +11,27 @@ public sealed partial class Session private readonly IReadOnlyDictionary? _snapshot; [UnsupportedOSPlatform("windows")] - internal Session(TmuxConnection connection, ServerGeneration generation, SessionId id) + internal Session( + Server owner, + TmuxConnection connection, + ServerGeneration generation, + SessionId id) : this(connection.CreateEntityDispatcher(generation), TmuxTarget.From(id).Value) { + ArgumentNullException.ThrowIfNull(owner); + _owner = owner; _id = id; _generation = generation; } [UnsupportedOSPlatform("windows")] internal Session( + Server owner, TmuxConnection connection, ServerGeneration generation, SessionId id, IReadOnlyDictionary snapshot) - : this(connection, generation, id) + : this(owner, connection, generation, id) { ArgumentNullException.ThrowIfNull(snapshot); _snapshot = snapshot; diff --git a/src/LibTmux/Session.Lifecycle.cs b/src/LibTmux/Session.Lifecycle.cs index b9765a1..5c5576b 100644 --- a/src/LibTmux/Session.Lifecycle.cs +++ b/src/LibTmux/Session.Lifecycle.cs @@ -34,9 +34,10 @@ is string attached : throw new IncompleteSnapshotException("attached", SnapshotDepth.Sessions); /// Gets the server that owns this session. - /// - /// The session was resolved by identifier rather than materialized. - /// + /// + /// Every handle reached through a server carries it, whether the handle was + /// materialized from a listing or resolved from an identifier. + /// public Server Server => RequireOwner("server"); /// Re-reads this session from tmux. diff --git a/src/LibTmux/Session.Options.cs b/src/LibTmux/Session.Options.cs index 307358f..a71813c 100644 --- a/src/LibTmux/Session.Options.cs +++ b/src/LibTmux/Session.Options.cs @@ -1,5 +1,4 @@ using System.Runtime.Versioning; -using LibTmux.Internal; namespace LibTmux; @@ -14,9 +13,5 @@ public sealed partial class Session _commandDispatcher, OptionScope.Session, _id.ToString(), - DoubleEscapesDollar(Server)); - - private static bool DoubleEscapesDollar(Server? owner) => - owner?.Version is TmuxVersion version - && TmuxCapabilities.IsSupported(version, "option_dollar_double_escape"); + TmuxOptions.DoubleEscapesDollar(_owner)); } diff --git a/src/LibTmux/Session.Relations.cs b/src/LibTmux/Session.Relations.cs index 7015d65..d30e465 100644 --- a/src/LibTmux/Session.Relations.cs +++ b/src/LibTmux/Session.Relations.cs @@ -10,19 +10,6 @@ public sealed partial class Session private Func>? _windows; private CapturedRelation? _panes; - [UnsupportedOSPlatform("windows")] - internal Session( - Server owner, - TmuxConnection connection, - ServerGeneration generation, - SessionId id, - IReadOnlyDictionary snapshot) - : this(connection, generation, id, snapshot) - { - ArgumentNullException.ThrowIfNull(owner); - _owner = owner; - } - /// Gets the active window recorded when this session was read. /// /// The session was resolved by identifier rather than materialized. @@ -37,7 +24,7 @@ public Window ActiveWindow throw new IncompleteSnapshotException("active window", SnapshotDepth.Sessions); } - return new Window(RequireConnection(), _generation, id); + return new Window(RequireOwner("windows"), RequireConnection(), _generation, id); } } @@ -55,7 +42,7 @@ public Pane ActivePane throw new IncompleteSnapshotException("active pane", SnapshotDepth.Sessions); } - return new Pane(RequireConnection(), _generation, id); + return new Pane(RequireOwner("panes"), RequireConnection(), _generation, id); } } diff --git a/src/LibTmux/Window.Identity.cs b/src/LibTmux/Window.Identity.cs index 457584c..9af8517 100644 --- a/src/LibTmux/Window.Identity.cs +++ b/src/LibTmux/Window.Identity.cs @@ -11,20 +11,27 @@ public sealed partial class Window private readonly IReadOnlyDictionary? _snapshot; [UnsupportedOSPlatform("windows")] - internal Window(TmuxConnection connection, ServerGeneration generation, WindowId id) + internal Window( + Server owner, + TmuxConnection connection, + ServerGeneration generation, + WindowId id) : this(connection.CreateEntityDispatcher(generation), TmuxTarget.From(id).Value) { + ArgumentNullException.ThrowIfNull(owner); + _owner = owner; _id = id; _generation = generation; } [UnsupportedOSPlatform("windows")] internal Window( + Server owner, TmuxConnection connection, ServerGeneration generation, WindowId id, IReadOnlyDictionary snapshot) - : this(connection, generation, id) + : this(owner, connection, generation, id) { ArgumentNullException.ThrowIfNull(snapshot); _snapshot = snapshot; diff --git a/src/LibTmux/Window.Options.cs b/src/LibTmux/Window.Options.cs index 7758c85..d6618a3 100644 --- a/src/LibTmux/Window.Options.cs +++ b/src/LibTmux/Window.Options.cs @@ -1,5 +1,4 @@ using System.Runtime.Versioning; -using LibTmux.Internal; namespace LibTmux; @@ -19,9 +18,5 @@ public sealed partial class Window _commandDispatcher, OptionScope.Window, _id.ToString(), - DoubleEscapesDollar(Server)); - - private static bool DoubleEscapesDollar(Server? owner) => - owner?.Version is TmuxVersion version - && TmuxCapabilities.IsSupported(version, "option_dollar_double_escape"); + TmuxOptions.DoubleEscapesDollar(_owner)); } diff --git a/src/LibTmux/Window.Relations.cs b/src/LibTmux/Window.Relations.cs index ace6b6a..226eec8 100644 --- a/src/LibTmux/Window.Relations.cs +++ b/src/LibTmux/Window.Relations.cs @@ -12,19 +12,6 @@ public sealed partial class Window private CapturedRelation? _linkedSessions; private SessionWindowEdge? _edge; - [UnsupportedOSPlatform("windows")] - internal Window( - Server owner, - TmuxConnection connection, - ServerGeneration generation, - WindowId id, - IReadOnlyDictionary snapshot) - : this(connection, generation, id, snapshot) - { - ArgumentNullException.ThrowIfNull(owner); - _owner = owner; - } - /// Gets the active pane recorded when this window was read. /// /// The window was resolved by identifier rather than materialized. @@ -39,7 +26,7 @@ public Pane ActivePane throw new IncompleteSnapshotException("active pane", SnapshotDepth.Windows); } - return new Pane(RequireConnection(), _generation, id); + return new Pane(RequireOwner("panes"), RequireConnection(), _generation, id); } } diff --git a/src/LibTmux/Window.State.cs b/src/LibTmux/Window.State.cs index b5eef43..18c08aa 100644 --- a/src/LibTmux/Window.State.cs +++ b/src/LibTmux/Window.State.cs @@ -39,9 +39,10 @@ public sealed partial class Window public int Width => ReadCapturedInt("window_width", "width"); /// Gets the server that owns this window. - /// - /// The window was resolved by identifier rather than materialized. - /// + /// + /// Every handle reached through a server carries it, whether the handle was + /// materialized from a listing or resolved from an identifier. + /// public Server Server => RequireOwner("server"); /// Gets the session this window was read through. @@ -51,7 +52,7 @@ public sealed partial class Window [UnsupportedOSPlatform("windows")] public Session Session => SessionId.TryParse(ReadSnapshot("session_id"), out SessionId id) - ? new Session(RequireConnection(), _generation, id) + ? new Session(RequireOwner("session"), RequireConnection(), _generation, id) : throw new IncompleteSnapshotException("session", SnapshotDepth.Windows); /// Re-reads this window from tmux. diff --git a/tests/LibTmux.IntegrationTests/Hierarchy/PaneOperationsTests.cs b/tests/LibTmux.IntegrationTests/Hierarchy/PaneOperationsTests.cs index 04e735a..7a9f4d8 100644 --- a/tests/LibTmux.IntegrationTests/Hierarchy/PaneOperationsTests.cs +++ b/tests/LibTmux.IntegrationTests/Hierarchy/PaneOperationsTests.cs @@ -524,6 +524,23 @@ private static Task ConnectAsync( logger: logger), token); + [UnixFact] + public async Task A_pane_resolved_by_identifier_reaches_its_option_table() + { + await using RawTmuxTestContext raw = await RawTmuxTestContext.StartAsync( + TestContext.Current.CancellationToken); + CancellationToken token = TestContext.Current.CancellationToken; + Server server = await ConnectAsync(raw, token); + Pane materialized = await FirstPaneAsync(server, token); + + // A handle resolved by identifier carries no snapshot, so reaching a + // scope must not depend on one. + Pane resolved = await server.GetPaneAsync(materialized.Id, token); + + await resolved.Hooks.GetAllAsync(cancellationToken: token); + await resolved.Options.GetAllAsync(cancellationToken: token); + } + [UnixFact] public async Task Killed_pane_is_a_raising_tombstone() { diff --git a/tests/LibTmux.UnitTests/Connection/TmuxConnectionTests.cs b/tests/LibTmux.UnitTests/Connection/TmuxConnectionTests.cs index aac6b31..6c4e57f 100644 --- a/tests/LibTmux.UnitTests/Connection/TmuxConnectionTests.cs +++ b/tests/LibTmux.UnitTests/Connection/TmuxConnectionTests.cs @@ -906,17 +906,20 @@ public async Task Entity_equality_binds_typed_id_to_generation() FakeMultiplexer.AnsweringVersion(static (request, _) => Task.FromResult( Result(request.LogicalArguments, 0, [], [])))); var generation = new ServerGeneration(60, 400); + var server = new Server(connection, generation, "tmux 3.7"); + var successor = new Server(connection, new ServerGeneration(61, 401), "tmux 3.7"); - var session = new Session(connection, generation, new SessionId(1)); - var equalSession = new Session(connection, generation, new SessionId(1)); + var session = new Session(server, connection, generation, new SessionId(1)); + var equalSession = new Session(server, connection, generation, new SessionId(1)); var successorSession = new Session( + successor, connection, new ServerGeneration(61, 401), new SessionId(1)); - var window = new Window(connection, generation, new WindowId(2)); - var equalWindow = new Window(connection, generation, new WindowId(2)); - var pane = new Pane(connection, generation, new PaneId(3)); - var equalPane = new Pane(connection, generation, new PaneId(3)); + var window = new Window(server, connection, generation, new WindowId(2)); + var equalWindow = new Window(server, connection, generation, new WindowId(2)); + var pane = new Pane(server, connection, generation, new PaneId(3)); + var equalPane = new Pane(server, connection, generation, new PaneId(3)); Assert.Equal(session, equalSession); Assert.Equal(session.GetHashCode(), equalSession.GetHashCode()); diff --git a/tests/LibTmux.UnitTests/Entities/CompositeMutationDispatchTests.cs b/tests/LibTmux.UnitTests/Entities/CompositeMutationDispatchTests.cs index 411f109..e1755bd 100644 --- a/tests/LibTmux.UnitTests/Entities/CompositeMutationDispatchTests.cs +++ b/tests/LibTmux.UnitTests/Entities/CompositeMutationDispatchTests.cs @@ -422,7 +422,11 @@ private static Pane CreatePane( Func> execute) { var connection = CreateConnection(execute); - return new Pane(connection, Generation, new PaneId(1)); + return new Pane( + new Server(connection, Generation, "tmux 3.7"), + connection, + Generation, + new PaneId(1)); } private static Window CreateWindow( diff --git a/tests/LibTmux.UnitTests/Entities/PaneSendKeysDispatchTests.cs b/tests/LibTmux.UnitTests/Entities/PaneSendKeysDispatchTests.cs index 06f2445..d1f5860 100644 --- a/tests/LibTmux.UnitTests/Entities/PaneSendKeysDispatchTests.cs +++ b/tests/LibTmux.UnitTests/Entities/PaneSendKeysDispatchTests.cs @@ -149,7 +149,11 @@ private static Pane CreatePane( var connection = new TmuxConnection( new ServerConnectionOptions(socketName: "send-keys-dispatch-test"), FakeMultiplexer.AnsweringVersion(execute)); - return new Pane(connection, Generation, new PaneId(1)); + return new Pane( + new Server(connection, Generation, "tmux 3.7"), + connection, + Generation, + new PaneId(1)); } private static TmuxCommandResult Success(IReadOnlyList arguments) From e2e6c42a26501b94aae8b9d3c7c66ce56a388f71 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 17:27:14 -0500 Subject: [PATCH 089/129] Docs(fix[formats]): Say which names tmux expands before storing why: tmux runs the argument of select-pane -T, rename-window, rename-session, new-window -n and new-session -s through the format engine before it stores the value: cmd-select-pane.c hands the title to format_single_from_target, and a name built from untrusted text can therefore resolve #{socket_path} or #{pane_current_path} into itself. Expansion happens at set time, so reading a name back is faithful and only the setter is affected. Window.RenameAsync already said so. The other four did not, and the same convention is already documented on DisplayMessageRequest, SetOptionRequest, SendKeysRequest, CommandPromptRequest and the environment operations. Verified on tmux 3.2a and 3.7c; a value stored through set-option or set-environment is not expanded. The chaining commit added TmuxChain.Then for a sequence and changed the NewWindowRequest receiver without recording either, which the API reference renderer refuses. That renderer is not in CONTRIBUTING's list of document validators, so a green local run missed it. what: - Document the expansion on Session.RenameAsync, Pane.SetTitleAsync, NewWindowRequest.Name and NewSessionRequest.Name - Record the Then overload and the ToCommand receiver, and regenerate the API reference and the rendered public API - Cover a name and a title carrying a format --- docs/api/README.md | 3 ++- docs/public-api.json | 27 ++++++++++++++++--- docs/public-api.md | 3 ++- src/LibTmux/Pane.Topology.cs | 4 +++ src/LibTmux/Requests/NewSessionRequest.cs | 4 +++ src/LibTmux/Requests/NewWindowRequest.cs | 4 +++ src/LibTmux/Session.Lifecycle.cs | 4 +++ .../Hierarchy/PaneOperationsTests.cs | 18 +++++++++++++ 8 files changed, 61 insertions(+), 6 deletions(-) diff --git a/docs/api/README.md b/docs/api/README.md index cecaf0c..093b7df 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -412,6 +412,7 @@ modes differ. | `LibTmux.TmuxBuffer.#ctor(System.String,System.Int64,System.String)` | Initializes one buffer. | | `LibTmux.TmuxChain.ExecuteAsync(System.Threading.CancellationToken)` | Runs every command in one tmux invocation. | | `LibTmux.TmuxChain.Then(LibTmux.TmuxCommand)` | Adds one command and returns the longer chain. | +| `LibTmux.TmuxChain.Then(System.Collections.Generic.IEnumerable{LibTmux.TmuxCommand})` | Adds every command in order and returns the longer chain. | | `LibTmux.TmuxChain.Then(System.String,System.String[])` | Adds one command by name and returns the longer chain. | | `LibTmux.TmuxChaining.ExecuteAsync(LibTmux.AttachSessionRequest,LibTmux.Session,System.Threading.CancellationToken)` | Runs an attach request on its own. | | `LibTmux.TmuxChaining.ExecuteAsync(LibTmux.BindKeyRequest,LibTmux.Server,System.Threading.CancellationToken)` | Runs a key-binding request on its own. | @@ -475,7 +476,7 @@ modes differ. | `LibTmux.TmuxChaining.ToCommand(LibTmux.MoveWindowRequest,LibTmux.Window)` | Returns a window-move request as one tmux command. | | `LibTmux.TmuxChaining.ToCommand(LibTmux.NewPaneRequest,LibTmux.Pane)` | Returns a floating-pane request as one tmux command. | | `LibTmux.TmuxChaining.ToCommand(LibTmux.NewSessionRequest)` | Returns a session request as one tmux command. | -| `LibTmux.TmuxChaining.ToCommand(LibTmux.NewWindowRequest,System.String)` | Returns a window request as one tmux command. | +| `LibTmux.TmuxChaining.ToCommand(LibTmux.NewWindowRequest,LibTmux.Session)` | Returns a window request as one tmux command. | | `LibTmux.TmuxChaining.ToCommand(LibTmux.PasteBufferRequest,LibTmux.Pane)` | Returns a paste request as one tmux command. | | `LibTmux.TmuxChaining.ToCommand(LibTmux.PipePaneRequest,LibTmux.Pane)` | Returns a pane-piping request as one tmux command. | | `LibTmux.TmuxChaining.ToCommand(LibTmux.ResizePaneRequest,LibTmux.Pane)` | Returns a pane-resize request as one tmux command. | diff --git a/docs/public-api.json b/docs/public-api.json index 4fef7ce..d9bdb83 100644 --- a/docs/public-api.json +++ b/docs/public-api.json @@ -25443,6 +25443,25 @@ "portable": true, "summary": "Adds one command and returns the longer chain." }, + { + "id": "M:LibTmux.TmuxChain.Then(System.Collections.Generic.IEnumerable{LibTmux.TmuxCommand})", + "declaringType": "T:LibTmux.TmuxChain", + "name": "Then", + "kind": "method", + "visibility": "public", + "package": "LibTmux", + "static": false, + "returnType": "TmuxChain", + "parameters": [ + { + "name": "commands", + "type": "IEnumerable" + } + ], + "signature": "TmuxChain Then(IEnumerable commands)", + "portable": true, + "summary": "Adds every command in order and returns the longer chain." + }, { "id": "M:LibTmux.TmuxChain.Then(string,string[])", "declaringType": "T:LibTmux.TmuxChain", @@ -25529,7 +25548,7 @@ "summary": "Returns a session request as one tmux command." }, { - "id": "M:LibTmux.TmuxChaining.ToCommand(LibTmux.NewWindowRequest,string)", + "id": "M:LibTmux.TmuxChaining.ToCommand(LibTmux.NewWindowRequest,LibTmux.Session)", "declaringType": "T:LibTmux.TmuxChaining", "name": "ToCommand", "kind": "method", @@ -25543,11 +25562,11 @@ "type": "NewWindowRequest" }, { - "name": "target", - "type": "string" + "name": "session", + "type": "Session" } ], - "signature": "static TmuxCommand ToCommand(this NewWindowRequest request, string target)", + "signature": "static TmuxCommand ToCommand(this NewWindowRequest request, Session session)", "portable": true, "summary": "Returns a window request as one tmux command." }, diff --git a/docs/public-api.md b/docs/public-api.md index 46dec6f..25fdccb 100644 --- a/docs/public-api.md +++ b/docs/public-api.md @@ -1769,6 +1769,7 @@ internal static class Program | --- | --- | --- | --- | --- | --- | | `M:LibTmux.TmuxChain.ExecuteAsync(System.Threading.CancellationToken)` | `Task ExecuteAsync(CancellationToken cancellationToken = default)` | Public | No | Portable | Runs every command in one tmux invocation. | | `M:LibTmux.TmuxChain.Then(LibTmux.TmuxCommand)` | `TmuxChain Then(TmuxCommand command)` | Public | No | Portable | Adds one command and returns the longer chain. | +| `M:LibTmux.TmuxChain.Then(System.Collections.Generic.IEnumerable{LibTmux.TmuxCommand})` | `TmuxChain Then(IEnumerable commands)` | Public | No | Portable | Adds every command in order and returns the longer chain. | | `M:LibTmux.TmuxChain.Then(string,string[])` | `TmuxChain Then(string name, params string[] arguments)` | Public | No | Portable | Adds one command by name and returns the longer chain. | | `P:LibTmux.TmuxChain.Commands` | `IReadOnlyList LibTmux.TmuxChain.Commands { get; }` | Public | No | Portable | Gets the commands this chain will run, in order. | @@ -1838,7 +1839,7 @@ internal static class Program | `M:LibTmux.TmuxChaining.ToCommand(LibTmux.MoveWindowRequest,LibTmux.Window)` | `static static TmuxCommand ToCommand(this MoveWindowRequest request, Window window)` | Public | Yes | Portable | Returns a window-move request as one tmux command. | | `M:LibTmux.TmuxChaining.ToCommand(LibTmux.NewPaneRequest,LibTmux.Pane)` | `static static TmuxCommand ToCommand(this NewPaneRequest request, Pane pane)` | Public | Yes | Portable | Returns a floating-pane request as one tmux command. | | `M:LibTmux.TmuxChaining.ToCommand(LibTmux.NewSessionRequest)` | `static static TmuxCommand ToCommand(this NewSessionRequest request)` | Public | Yes | Portable | Returns a session request as one tmux command. | -| `M:LibTmux.TmuxChaining.ToCommand(LibTmux.NewWindowRequest,string)` | `static static TmuxCommand ToCommand(this NewWindowRequest request, string target)` | Public | Yes | Portable | Returns a window request as one tmux command. | +| `M:LibTmux.TmuxChaining.ToCommand(LibTmux.NewWindowRequest,LibTmux.Session)` | `static static TmuxCommand ToCommand(this NewWindowRequest request, Session session)` | Public | Yes | Portable | Returns a window request as one tmux command. | | `M:LibTmux.TmuxChaining.ToCommand(LibTmux.PasteBufferRequest,LibTmux.Pane)` | `static static TmuxCommand ToCommand(this PasteBufferRequest request, Pane pane)` | Public | Yes | Portable | Returns a paste request as one tmux command. | | `M:LibTmux.TmuxChaining.ToCommand(LibTmux.PipePaneRequest,LibTmux.Pane)` | `static static TmuxCommand ToCommand(this PipePaneRequest request, Pane pane)` | Public | Yes | Portable | Returns a pane-piping request as one tmux command. | | `M:LibTmux.TmuxChaining.ToCommand(LibTmux.ResizePaneRequest,LibTmux.Pane)` | `static static TmuxCommand ToCommand(this ResizePaneRequest request, Pane pane)` | Public | Yes | Portable | Returns a pane-resize request as one tmux command. | diff --git a/src/LibTmux/Pane.Topology.cs b/src/LibTmux/Pane.Topology.cs index f803709..8d6fc11 100644 --- a/src/LibTmux/Pane.Topology.cs +++ b/src/LibTmux/Pane.Topology.cs @@ -364,6 +364,10 @@ public Task SetHeightAsync(int height, CancellationToken cancellationToken /// The new title. /// Cancels the tmux command. /// A replacement handle carrying the new title. + /// + /// tmux expands the title as a format, so a # in it does not survive + /// verbatim. + /// [UnsupportedOSPlatform("windows")] public async Task SetTitleAsync( string title, diff --git a/src/LibTmux/Requests/NewSessionRequest.cs b/src/LibTmux/Requests/NewSessionRequest.cs index 88efc2b..960cc8d 100644 --- a/src/LibTmux/Requests/NewSessionRequest.cs +++ b/src/LibTmux/Requests/NewSessionRequest.cs @@ -56,6 +56,10 @@ public NewSessionRequest( } /// Gets the session name, or null to let tmux choose. + /// + /// tmux expands the name as a format, so a # in it does not survive + /// verbatim. + /// public string? Name { get; } /// Gets whether a session of the same name is removed first. diff --git a/src/LibTmux/Requests/NewWindowRequest.cs b/src/LibTmux/Requests/NewWindowRequest.cs index b41ea9a..1aa573c 100644 --- a/src/LibTmux/Requests/NewWindowRequest.cs +++ b/src/LibTmux/Requests/NewWindowRequest.cs @@ -57,6 +57,10 @@ public NewWindowRequest( } /// Gets the window name. + /// + /// tmux expands the name as a format, so a # in it does not survive + /// verbatim. + /// public string? Name { get; } /// Gets the working directory for the first pane. diff --git a/src/LibTmux/Session.Lifecycle.cs b/src/LibTmux/Session.Lifecycle.cs index 5c5576b..8fadd4e 100644 --- a/src/LibTmux/Session.Lifecycle.cs +++ b/src/LibTmux/Session.Lifecycle.cs @@ -66,6 +66,10 @@ public async Task RefreshAsync(CancellationToken cancellationToken = de /// The new name. /// Cancels the tmux command. /// A replacement handle carrying the new name. + /// + /// tmux expands the name as a format, so a # in it does not survive + /// verbatim. + /// [UnsupportedOSPlatform("windows")] public async Task RenameAsync( string name, diff --git a/tests/LibTmux.IntegrationTests/Hierarchy/PaneOperationsTests.cs b/tests/LibTmux.IntegrationTests/Hierarchy/PaneOperationsTests.cs index 7a9f4d8..4a055f8 100644 --- a/tests/LibTmux.IntegrationTests/Hierarchy/PaneOperationsTests.cs +++ b/tests/LibTmux.IntegrationTests/Hierarchy/PaneOperationsTests.cs @@ -541,6 +541,24 @@ public async Task A_pane_resolved_by_identifier_reaches_its_option_table() await resolved.Options.GetAllAsync(cancellationToken: token); } + [UnixFact] + public async Task A_name_and_a_title_are_expanded_as_formats() + { + await using RawTmuxTestContext raw = await RawTmuxTestContext.StartAsync( + TestContext.Current.CancellationToken); + CancellationToken token = TestContext.Current.CancellationToken; + Server server = await ConnectAsync(raw, token); + Pane pane = await FirstPaneAsync(server, token); + + // tmux expands the argument of select-pane -T and rename-session before + // storing it, so neither survives a '#' verbatim. + Pane titled = await pane.SetTitleAsync("#{pane_id}", token); + Session renamed = await pane.Session.RenameAsync("x#{pane_id}", token); + + Assert.Equal(pane.Id.ToString(), titled.Title); + Assert.Equal($"x{pane.Id}", renamed.Name); + } + [UnixFact] public async Task Killed_pane_is_a_raising_tombstone() { From 13ed8df99d6b7b936de2018efd1b791dbc7332f9 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 17:27:32 -0500 Subject: [PATCH 090/129] Engineering(test[capabilities]): Read the escape gate from one place why: Moving the dollar-escaping rule onto TmuxOptions left this check naming the four entity files it used to be copied into. The check exists to prove the gate is consulted at all, and it now is, once. The break went in with the ownership commit: the C# suites and the three verifiers named in CONTRIBUTING were green, and the engineering tests are a separate command that was not run. what: - Expect the gate in TmuxOptions.cs and rename the check for where it is --- eng/parity/tests/test_capabilities.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/eng/parity/tests/test_capabilities.py b/eng/parity/tests/test_capabilities.py index 19468b1..4a4a5ab 100644 --- a/eng/parity/tests/test_capabilities.py +++ b/eng/parity/tests/test_capabilities.py @@ -96,17 +96,12 @@ def test_every_gate_in_the_library_names_a_declared_capability() -> None: assert set(references) <= declared -def test_the_dollar_escape_gate_is_read_from_the_option_scopes() -> None: +def test_the_dollar_escape_gate_is_read_from_the_option_table() -> None: """Name the gate whose absence from the matrix this check was added for.""" namespace = load_verifier() references = namespace["referenced_capabilities"](namespace["SOURCE_ROOT"]) - assert references["option_dollar_double_escape"] == { - "Pane.Options.cs", - "Server.Options.cs", - "Session.Options.cs", - "Window.Options.cs", - } + assert references["option_dollar_double_escape"] == {"TmuxOptions.cs"} def test_interval_boundary_drift_is_rejected() -> None: From b8c0b902e768469c67112edd19d68301813d9111 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 17:28:04 -0500 Subject: [PATCH 091/129] Docs(fix[contributing]): List every document check CI runs why: The section said five checks and named five. dotnet.yml runs eight. The three it left out are render_public_api.py --check, verify_tmux_versions.py and render_api_reference.py --check, and the last of those is the only check that refuses a public member the contract does not record. Following this file exactly and finding the tree green is what let a missing record reach a commit. what: - Name all eight in the order the workflow runs them - Say what the two renderers hold, and that the list is worth finishing --- .github/CONTRIBUTING.md | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 08f9327..150433d 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -208,13 +208,18 @@ references, so they run only after `dotnet pack`. ### Validators that read documents, not the build -Five checks run against documents rather than code, which is what makes them -easy to forget locally: +Eight checks run against documents rather than code, which is what makes them +easy to forget locally. They are listed here in the order +[`dotnet.yml`](workflows/dotnet.yml) runs them: ```console $ uv run python eng/parity/verify_public_api.py ``` +```console +$ uv run python eng/parity/render_public_api.py --check +``` + ```console $ uv run python eng/parity/verify_capabilities.py ``` @@ -223,6 +228,14 @@ $ uv run python eng/parity/verify_capabilities.py $ uv run python eng/parity/verify_workflows.py ``` +```console +$ uv run python eng/parity/verify_tmux_versions.py +``` + +```console +$ uv run python eng/docs/render_api_reference.py --check +``` + ```console $ uv run python eng/docs/sync_snippets.py --check ``` @@ -231,6 +244,11 @@ $ uv run python eng/docs/sync_snippets.py --check $ uv run eng/mcp/dump_tools.py --check ``` +The two renderers hold `docs/api/README.md` and `docs/public-api.md` to the +documents they are generated from. Adding a public member without recording it +fails `render_api_reference.py --check` and nothing before it, so run the whole +list rather than the first few. + `sync_snippets.py --check` is the one that catches a hand-edited example. It compares each published block against the region it was quoted from and fails on any difference, so bring a change across rather than typing it into the From c91c402550a49aa36e0a06bb58050524f6693558 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 17:28:32 -0500 Subject: [PATCH 092/129] Docs(feat[one-shot]): State what bounds a command why: Nothing said that a call has no deadline of its own. The examples pass a token everywhere and one of them builds a timed source, so the idiom is visible while the contract behind it is not: a caller passing none waits as long as tmux takes, and a socket that accepts and never answers makes that forever. The Python libtmux-mcp reached the same conclusion from the other side -- it bounds every tmux call itself because a cancelled thread left the child running. This transport does not need that: cancellation kills and reaps the client, which ProcessTransportTests proves against a live process, and a descendant that outlives it is proven separately. What was missing was saying so. what: - Say that a token is the deadline, what cancelling reaps, and what it cannot --- docs/modes/one-shot.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/docs/modes/one-shot.md b/docs/modes/one-shot.md index cd2f348..0d1eddb 100644 --- a/docs/modes/one-shot.md +++ b/docs/modes/one-shot.md @@ -33,6 +33,23 @@ It also only ever sees what it asked for. To notice a window appearing, or read what a program writes into a pane, you need a client that stays — [control mode](control-mode.md). +## Cancellation is the deadline + +No call carries a deadline of its own. A `CancellationToken` is what bounds +one, and a caller that passes none waits as long as tmux takes — which is +forever against a socket that accepts a connection and never answers: + +```csharp +using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(30)); +Server server = await Server.ConnectAsync(cancellationToken: deadline.Token); +``` + +Cancelling after the client started kills and reaps it, and the failure says +so: `TmuxOperationCanceledException` carries the client's process id and +reports that the command may already have run. What it cannot reap is a +process the client left behind — a pane's program outlives the client that +spawned it, by design. + The transport this uses, and the two shapes it beat, are recorded in [ADR 0001](../decisions/0001-transport-framing-bakeoff.md). From dfbcf20c2767287ca5f64326647d9edb87f39b20 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 17:28:48 -0500 Subject: [PATCH 093/129] Engineering(fix[quality]): Measure the gates CI runs why: The script counted verify_*.py on disk and reported five document validators where dotnet.yml runs eight, and it looked for CONTRIBUTING.md only at the root, so it reported the file missing while GitHub reads it from .github. A measure that is wrong in the safe direction is worse than none, because the quality bar cites it. what: - Count the document validators from the workflow, so a check CI gains or loses moves the number - Accept a standard project file from the root or from .github --- eng/quality/measure.sh | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/eng/quality/measure.sh b/eng/quality/measure.sh index 2469e3b..3dd6f29 100755 --- a/eng/quality/measure.sh +++ b/eng/quality/measure.sh @@ -25,7 +25,10 @@ printf '%-38s %s\n' "fuzz cases per run" \ "$(rg -o 'CasesPerTarget = [0-9_]+' tests/LibTmux.UnitTests/Fuzzing/ParserFuzzTests.cs \ | rg -o '[0-9_]+' | tail -1 | tr -d '_')" printf '%-38s %s/%s\n' "actions pinned to a commit SHA" "$(count_pinned)" "$(count_uses)" -printf '%-38s %s\n' "document validators" "$(fd 'verify_.*\.py' eng/parity | wc -l | tr -d ' ')" +# Counted from the workflow rather than from the scripts on disk: the gate is +# what CI runs, and a script nobody invokes is not a check. +printf '%-38s %s\n' "document validators" \ + "$(rg -c 'uv run.*(verify_.*\.py|--check)' .github/workflows/dotnet.yml | tr -d ' ')" printf '%-38s %s\n' "decision records" "$(fd -e md . docs/decisions -d 1 | wc -l | tr -d ' ')" printf '%-38s %s\n' "recorded benchmark runs" "$(fd -e md . docs/benchmarks/runs 2>/dev/null | wc -l | tr -d ' ')" printf '%-38s %s\n' "published packages" \ @@ -34,8 +37,10 @@ printf '%-38s %s\n' "projects suppressing CS1591 (want 0)" \ "$(rg -l 'CS1591' src/LibTmux/LibTmux.csproj src/LibTmux.Query.Json/LibTmux.Query.Json.csproj 2>/dev/null | wc -l | tr -d ' ')" missing=0 +# GitHub reads these from the root or from .github, so both count as present. for f in README.md LICENSE SECURITY.md CONTRIBUTING.md CODE_OF_CONDUCT.md CHANGELOG.md; do - [ -e "$f" ] || { echo "MISSING: $f" >&2; missing=$((missing + 1)); } + [ -e "$f" ] || [ -e ".github/$f" ] \ + || { echo "MISSING: $f" >&2; missing=$((missing + 1)); } done printf '%-38s %s\n' "standard project files missing" "$missing" From fdf3da7216c002decdb3b80d4f1463632df79a65 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 17:29:01 -0500 Subject: [PATCH 094/129] Docs(fix[contract]): Say which packages the contract covers why: The page called public-api.md "the approved surface" without saying whose. It records LibTmux and LibTmux.Query.Json, the two packages decision 0004 approved. LibTmux.Workspace shipped afterwards and has 45 public members no contract records, so a reader following that link to learn the surface of the suite is told less than the sentence promises. LibTmux.Mcp is outside it by design: it installs as a tool rather than as a reference. what: - Name the two packages the contract covers and the one it does not, and point at what gates that one today --- docs/README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/README.md b/docs/README.md index d6f3e75..6f4d415 100644 --- a/docs/README.md +++ b/docs/README.md @@ -49,8 +49,10 @@ ships separately and carries this repository's version: The library's surface and behavior are recorded rather than described, and each record has a validator that fails when the code disagrees. -- [Public API](public-api.md) — the approved surface, rendered from - `public-api.json` +- [Public API](public-api.md) — the approved surface of `LibTmux` and + `LibTmux.Query.Json`, rendered from `public-api.json`. `LibTmux.Workspace` + ships a public surface this contract does not yet record; the analyzer + baseline beside its source is what gates it today. - [Version deltas](parity/version-deltas.json) — every tmux behavior difference the library gates on, each naming the test that proves it - [Parity ledger](parity/parity-ledger.json) — where each Python libtmux From 5c80586a8e02573dfc2dae19dac0abfdbd63b695 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 17:29:12 -0500 Subject: [PATCH 095/129] Docs(fix[contributing]): Build before checking the API reference why: render_api_reference.py compares against the XML documentation the compiler emitted. Run on output left by an earlier build of a different tree it reports a difference that is not in the working copy, which reads as a real failure and is not one. CI never sees this because it always builds first. what: - Say the check needs a build of the tree being checked --- .github/CONTRIBUTING.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 150433d..a074679 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -249,6 +249,10 @@ documents they are generated from. Adding a public member without recording it fails `render_api_reference.py --check` and nothing before it, so run the whole list rather than the first few. +`render_api_reference.py` reads the XML documentation the compiler emitted, so +build before running it. Against stale output it reports a difference that is +not there. + `sync_snippets.py --check` is the one that catches a hand-edited example. It compares each published block against the region it was quoted from and fails on any difference, so bring a change across rather than typing it into the From eda52277d1c4e8139a8ec612395323221ef7fc3d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 17:29:33 -0500 Subject: [PATCH 096/129] Docs(fix[contributing]): Evict the packed version before the package gates why: Every pack writes 0.0.0-alpha.9, so a local restore prefers whatever the global cache already holds and silently ignores the bytes just built. Running the package-consumer gate that way loaded a LibTmux from five days earlier against a LibTmux.Query.Json built minutes before, and the failure was a TypeLoadException naming an internal type -- which reads as a real regression rather than as a stale package. CI does not see it: it starts from a clean cache. what: - Say to evict the packed version locally, and what skipping it looks like --- .github/CONTRIBUTING.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index a074679..74cd418 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -285,6 +285,18 @@ bytes change with each commit. CI combines a clean package cache with `tests/NuGet.config` source mapping so it cannot substitute a stale or public package. +A local machine has neither. The version never changes between packs, so a +restore prefers whatever `0.0.0-alpha.9` the global cache already holds and the +freshly packed bytes are ignored. Evict them before running either gate: + +```console +$ rm -rf ~/.nuget/packages/libtmux{,.query.json,.workspace}/0.0.0-alpha.9 +``` + +Skipping that runs last week's library against this week's dependants, which +surfaces as a `TypeLoadException` naming an internal type rather than as +anything that looks like a stale package. + Adding a platform means adding its identifier to `LibTmux.AotSmoke` and adding the matching standalone restore and publish to the workflow. From 6a245ef88b15b4f763622e24c528b0c33dba4b60 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 17:32:28 -0500 Subject: [PATCH 097/129] Chaining(fix[generation]): Pin every command built from an entity why: A command built from an entity carries that entity's identifier as plain text, and tmux reuses identifiers: a pane called %0 on a restarted server is a different pane. TmuxChain runs the staleness guard only when some command in the chain names a generation, so a chain of commands that name none reaches the ungenerationed dispatcher and acts on whatever the new server calls %0. Twenty-one ToCommand overloads take a Pane, Window or Session and embed its target. Two named a generation: SendKeys, and the NewWindow overload added three commits ago. The other nineteen are the same shape and had the same hole, which is the one a1d3949 closed for the one-shot path and left open here. what: - Name the entity's generation in every ToCommand built from one - Widen the restart test from one command to four, so the class is covered rather than the instance --- src/LibTmux/Chaining/TmuxChaining.Panes.cs | 70 +++++++++++++++---- src/LibTmux/Chaining/TmuxChaining.Sessions.cs | 5 +- src/LibTmux/Chaining/TmuxChaining.Windows.cs | 20 ++++-- .../Chaining/ChainGenerationTests.cs | 24 +++++-- 4 files changed, 93 insertions(+), 26 deletions(-) diff --git a/src/LibTmux/Chaining/TmuxChaining.Panes.cs b/src/LibTmux/Chaining/TmuxChaining.Panes.cs index fd2d81c..f2ead21 100644 --- a/src/LibTmux/Chaining/TmuxChaining.Panes.cs +++ b/src/LibTmux/Chaining/TmuxChaining.Panes.cs @@ -32,7 +32,10 @@ public static TmuxCommand ToCommand(this SelectPaneRequest request, Pane pane) { ArgumentNullException.ThrowIfNull(request); ArgumentNullException.ThrowIfNull(pane); - return Command([.. pane.BuildSelectPaneArguments(request)]); + return Command([.. pane.BuildSelectPaneArguments(request)]) with + { + RequiredGeneration = pane.Generation, + }; } /// Runs a pane-selection request on its own. @@ -61,7 +64,10 @@ public static TmuxCommand ToCommand(this ResizePaneRequest request, Pane pane) { ArgumentNullException.ThrowIfNull(request); ArgumentNullException.ThrowIfNull(pane); - return Command([.. pane.BuildResizePaneArguments(request)]); + return Command([.. pane.BuildResizePaneArguments(request)]) with + { + RequiredGeneration = pane.Generation, + }; } /// Runs a pane-resize request on its own. @@ -90,7 +96,10 @@ public static TmuxCommand ToCommand(this FindWindowRequest request, Pane pane) { ArgumentNullException.ThrowIfNull(request); ArgumentNullException.ThrowIfNull(pane); - return Command([.. pane.BuildFindWindowArguments(request)]); + return Command([.. pane.BuildFindWindowArguments(request)]) with + { + RequiredGeneration = pane.Generation, + }; } /// Runs a window-search request on its own. @@ -119,7 +128,10 @@ public static TmuxCommand ToCommand(this SwapPaneRequest request, Pane pane) { ArgumentNullException.ThrowIfNull(request); ArgumentNullException.ThrowIfNull(pane); - return Command([.. pane.BuildSwapPaneArguments(request)]); + return Command([.. pane.BuildSwapPaneArguments(request)]) with + { + RequiredGeneration = pane.Generation, + }; } /// Runs a pane-swap request on its own. @@ -148,7 +160,10 @@ public static TmuxCommand ToCommand(this PipePaneRequest request, Pane pane) { ArgumentNullException.ThrowIfNull(request); ArgumentNullException.ThrowIfNull(pane); - return Command([.. pane.BuildPipePaneArguments(request)]); + return Command([.. pane.BuildPipePaneArguments(request)]) with + { + RequiredGeneration = pane.Generation, + }; } /// Runs a pane-piping request on its own. @@ -182,7 +197,10 @@ public static TmuxCommand ToCommand(this CapturePaneRequest request, Pane pane) { ArgumentNullException.ThrowIfNull(request); ArgumentNullException.ThrowIfNull(pane); - return Command([.. pane.BuildCaptureArguments(["-p"], request)]); + return Command([.. pane.BuildCaptureArguments(["-p"], request)]) with + { + RequiredGeneration = pane.Generation, + }; } /// Runs a capture request on its own. @@ -215,7 +233,10 @@ public static TmuxCommand ToCommand(this PasteBufferRequest request, Pane pane) { ArgumentNullException.ThrowIfNull(request); ArgumentNullException.ThrowIfNull(pane); - return Command([.. pane.BuildPasteBufferArguments(request)]); + return Command([.. pane.BuildPasteBufferArguments(request)]) with + { + RequiredGeneration = pane.Generation, + }; } /// Runs a paste request on its own. @@ -248,7 +269,10 @@ public static TmuxCommand ToCommand(this DisplayPopupRequest request, Pane pane) { ArgumentNullException.ThrowIfNull(request); ArgumentNullException.ThrowIfNull(pane); - return Command([.. pane.BuildDisplayPopupArguments(request)]); + return Command([.. pane.BuildDisplayPopupArguments(request)]) with + { + RequiredGeneration = pane.Generation, + }; } /// Runs a popup request on its own. @@ -281,7 +305,10 @@ public static TmuxCommand ToCommand(this CopyModeRequest request, Pane pane) { ArgumentNullException.ThrowIfNull(request); ArgumentNullException.ThrowIfNull(pane); - return Command([.. pane.BuildCopyModeArguments(request)]); + return Command([.. pane.BuildCopyModeArguments(request)]) with + { + RequiredGeneration = pane.Generation, + }; } /// Runs a copy-mode request on its own. @@ -310,7 +337,10 @@ public static TmuxCommand ToCommand(this RespawnRequest request, Pane pane) { ArgumentNullException.ThrowIfNull(request); ArgumentNullException.ThrowIfNull(pane); - return Command([.. pane.BuildRespawnPaneArguments(request)]); + return Command([.. pane.BuildRespawnPaneArguments(request)]) with + { + RequiredGeneration = pane.Generation, + }; } /// Runs a respawn request on its own. @@ -343,7 +373,10 @@ public static TmuxCommand ToCommand(this ChooseTreeRequest request, Pane pane) { ArgumentNullException.ThrowIfNull(request); ArgumentNullException.ThrowIfNull(pane); - return Command([.. pane.BuildChooseTreeArguments(request)]); + return Command([.. pane.BuildChooseTreeArguments(request)]) with + { + RequiredGeneration = pane.Generation, + }; } /// Runs a chooser request on its own. @@ -372,7 +405,10 @@ public static TmuxCommand ToCommand(this MovePaneRequest request, Pane pane) { ArgumentNullException.ThrowIfNull(request); ArgumentNullException.ThrowIfNull(pane); - return Command([.. pane.BuildRehomeArguments("move-pane", request)]); + return Command([.. pane.BuildRehomeArguments("move-pane", request)]) with + { + RequiredGeneration = pane.Generation, + }; } /// Runs a pane-move request on its own. @@ -407,7 +443,10 @@ public static TmuxCommand ToCommand(this SplitPaneRequest request, Pane pane) { ArgumentNullException.ThrowIfNull(request); ArgumentNullException.ThrowIfNull(pane); - return Command([.. pane.BuildSplitArguments(request)]); + return Command([.. pane.BuildSplitArguments(request)]) with + { + RequiredGeneration = pane.Generation, + }; } /// Runs a split request on its own. @@ -441,7 +480,10 @@ public static TmuxCommand ToCommand(this NewPaneRequest request, Pane pane) { ArgumentNullException.ThrowIfNull(request); ArgumentNullException.ThrowIfNull(pane); - return Command([.. pane.BuildNewPaneArguments(request)]); + return Command([.. pane.BuildNewPaneArguments(request)]) with + { + RequiredGeneration = pane.Generation, + }; } /// Runs a floating-pane request on its own. diff --git a/src/LibTmux/Chaining/TmuxChaining.Sessions.cs b/src/LibTmux/Chaining/TmuxChaining.Sessions.cs index d0a4f22..1923cc6 100644 --- a/src/LibTmux/Chaining/TmuxChaining.Sessions.cs +++ b/src/LibTmux/Chaining/TmuxChaining.Sessions.cs @@ -24,7 +24,10 @@ public static TmuxCommand ToCommand(this AttachSessionRequest request, Session s { ArgumentNullException.ThrowIfNull(request); ArgumentNullException.ThrowIfNull(session); - return Command([.. Session.BuildAttachArguments(request, session.Id.ToString())]); + return Command([.. Session.BuildAttachArguments(request, session.Id.ToString())]) with + { + RequiredGeneration = session.Generation, + }; } /// Runs an attach request on its own. diff --git a/src/LibTmux/Chaining/TmuxChaining.Windows.cs b/src/LibTmux/Chaining/TmuxChaining.Windows.cs index 12c9954..e31e2d2 100644 --- a/src/LibTmux/Chaining/TmuxChaining.Windows.cs +++ b/src/LibTmux/Chaining/TmuxChaining.Windows.cs @@ -35,7 +35,10 @@ public static TmuxCommand ToCommand(this SelectLayoutRequest request, Window win { ArgumentNullException.ThrowIfNull(request); ArgumentNullException.ThrowIfNull(window); - return Command([.. window.BuildSelectLayoutArguments(request)]); + return Command([.. window.BuildSelectLayoutArguments(request)]) with + { + RequiredGeneration = window.Generation, + }; } /// Runs a layout request on its own. @@ -67,7 +70,10 @@ public static TmuxCommand ToCommand(this ResizeWindowRequest request, Window win { ArgumentNullException.ThrowIfNull(request); ArgumentNullException.ThrowIfNull(window); - return Command([.. window.BuildResizeWindowArguments(request)]); + return Command([.. window.BuildResizeWindowArguments(request)]) with + { + RequiredGeneration = window.Generation, + }; } /// Runs a window-resize request on its own. @@ -104,7 +110,10 @@ public static TmuxCommand ToCommand(this LinkWindowRequest request, Window windo { ArgumentNullException.ThrowIfNull(request); ArgumentNullException.ThrowIfNull(window); - return Command([.. window.BuildLinkWindowArguments(request)]); + return Command([.. window.BuildLinkWindowArguments(request)]) with + { + RequiredGeneration = window.Generation, + }; } /// Runs a link request on its own. @@ -133,7 +142,10 @@ public static TmuxCommand ToCommand(this MoveWindowRequest request, Window windo { ArgumentNullException.ThrowIfNull(request); ArgumentNullException.ThrowIfNull(window); - return Command([.. window.BuildMoveWindowArguments(request)]); + return Command([.. window.BuildMoveWindowArguments(request)]) with + { + RequiredGeneration = window.Generation, + }; } /// Runs a window-move request on its own. diff --git a/tests/LibTmux.IntegrationTests/Chaining/ChainGenerationTests.cs b/tests/LibTmux.IntegrationTests/Chaining/ChainGenerationTests.cs index b0085de..0c101e9 100644 --- a/tests/LibTmux.IntegrationTests/Chaining/ChainGenerationTests.cs +++ b/tests/LibTmux.IntegrationTests/Chaining/ChainGenerationTests.cs @@ -42,13 +42,23 @@ public async Task A_chained_entity_command_is_refused_after_the_server_restarts( // instant, and how far apart they are depends on the machine. Server second = await ConnectWhenReadyAsync(raw, token); - // The chain runs on the new server but from a pane handle read - // through the old one -- the shape a stale-handle bug takes. - TmuxChain chain = second.Chain() - .Then(new SendKeysRequest("echo stale").ToCommand(pane)); - - await Assert.ThrowsAsync( - () => chain.ExecuteAsync(token)); + // The chain runs on the new server but from handles read through the + // old one -- the shape a stale-handle bug takes. Every command built + // from an entity carries that entity's target as plain text, so each + // has to be refused rather than only the one this started with. + TmuxCommand[] stale = + [ + new SendKeysRequest("echo stale").ToCommand(pane), + new SelectPaneRequest().ToCommand(pane), + new SelectLayoutRequest("tiled").ToCommand(window), + new NewWindowRequest(name: "stale").ToCommand(session), + ]; + + foreach (TmuxCommand command in stale) + { + await Assert.ThrowsAsync( + () => second.Chain().Then(command).ExecuteAsync(token)); + } } [UnixFact] From 629a26ada57dc615fec78140a5d3577cc3fa7e13 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 17:33:00 -0500 Subject: [PATCH 098/129] Workspace(test[readiness]): Give the builder tests a shared-machine budget why: First_pane_directory_controls_window_creation fails intermittently waiting for a pane to reach a prompt. The library's ten-second default is ample for one shell on an idle machine; these tests run beside other suites and a build, where a shell can take longer to draw its first prompt than the subject under test needs. Reproduced on master without any branch change, so it is the budget rather than a regression. The subject is what the builder does with a workspace file, not how long a shell may take, so the tests name a budget instead of inheriting the one the library ships. what: - Read the readiness budget from one constant in the four tests that waited on the default --- .../Workspace/WorkspaceBuilderTests.cs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/tests/LibTmux.IntegrationTests/Workspace/WorkspaceBuilderTests.cs b/tests/LibTmux.IntegrationTests/Workspace/WorkspaceBuilderTests.cs index 11945c9..a3b20a3 100644 --- a/tests/LibTmux.IntegrationTests/Workspace/WorkspaceBuilderTests.cs +++ b/tests/LibTmux.IntegrationTests/Workspace/WorkspaceBuilderTests.cs @@ -10,6 +10,11 @@ namespace LibTmux.IntegrationTests; [UnsupportedOSPlatform("windows")] public sealed class WorkspaceBuilderTests { + + // The library's ten-second default is ample for one shell on an idle + // machine. These run beside other suites and a build, where a shell can + // take longer to draw its first prompt than the subject under test needs. + private static readonly TimeSpan Readiness = TimeSpan.FromSeconds(60); private const string Yaml = """ session_name: libtmux-workspace start_directory: /tmp @@ -134,7 +139,7 @@ public async Task What_tmux_cannot_do_is_reported_rather_than_dropped() - echo hello """); - WorkspaceResult result = await new WorkspaceBuilder(scope.Server) + WorkspaceResult result = await new WorkspaceBuilder(scope.Server, Readiness) .BuildAsync(workspace, token); // The session is still built, and the caller is told what was asked @@ -160,7 +165,7 @@ public async Task Each_workspace_command_receives_one_enter() - panes: - shell_command: 'printf "ready\n"; read value; printf "got=<%s>\n" "$value"' """); - WorkspaceResult result = await new WorkspaceBuilder(scope.Server) + WorkspaceResult result = await new WorkspaceBuilder(scope.Server, Readiness) .BuildAsync(workspace, token); Pane pane = Assert.Single(await Assert.Single(result.Windows).GetPanesAsync(token)); @@ -345,7 +350,7 @@ public async Task First_pane_directory_controls_window_creation() - start_directory: /etc shell_command: pwd """); - WorkspaceResult result = await new WorkspaceBuilder(scope.Server) + WorkspaceResult result = await new WorkspaceBuilder(scope.Server, Readiness) .BuildAsync(workspace, token); IReadOnlyList panes = await Assert.Single(result.Windows).GetPanesAsync(token); @@ -388,7 +393,7 @@ public async Task Last_focused_window_and_pane_win() - focus: true - focus: true """); - WorkspaceResult result = await new WorkspaceBuilder(scope.Server) + WorkspaceResult result = await new WorkspaceBuilder(scope.Server, Readiness) .BuildAsync(workspace, token); Session session = await result.Session.RefreshAsync(token); Window window = await result.Windows[1].RefreshAsync(token); From 06fd46c710a2b0c6456f9dc54229281877573825 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 17:34:23 -0500 Subject: [PATCH 099/129] Hierarchy(fix[lookup]): Refuse a lookup answered by another server why: GetSessionAsync, GetWindowAsync and GetPaneAsync resolved the identifier against the running daemon and then built the handle with this server as its owner. tmux reuses identifiers, so after a restart the two disagree: the handle names the replacement's object while reporting the old server, whose generation every later read is checked against. The first refresh then throws about a generation the caller never chose. Before handles carried an owner the mismatch was loud in a different way: Server threw because there was none. Carrying one made it silent, so the lookup has to say no itself. The three partial hooks the lookups called had no implementing declaration anywhere, left over from a source generator this repository no longer has, so every call left the result null and fell through. what: - Reject a lookup whose discovered generation is not this handle's - Delete the three hooks that never ran - Extend the reused-identifier test to the lookup that produced the split --- src/LibTmux/Server.Identity.cs | 53 +++++++++---------- .../Connection/ServerGenerationTests.cs | 11 ++++ 2 files changed, 37 insertions(+), 27 deletions(-) diff --git a/src/LibTmux/Server.Identity.cs b/src/LibTmux/Server.Identity.cs index 40adccf..8058afe 100644 --- a/src/LibTmux/Server.Identity.cs +++ b/src/LibTmux/Server.Identity.cs @@ -93,6 +93,26 @@ await ConnectionOptions.InitializeAsync(materialized, cancellationToken) return materialized; } + /// Rejects a lookup answered by a server this handle is not on. + /// + /// The identifier is resolved against the running daemon while the handle + /// carries the generation it was discovered at. A replacement server hands + /// out the same identifiers, so a handle built from both would name the new + /// server's object while reporting the old server as its owner. + /// + private void RequireOwnedGeneration(ServerGeneration observed) + { + ServerGeneration expected = _generation + ?? throw new InvalidOperationException("The server has no live generation."); + if (observed != expected) + { + throw new StaleServerGenerationException( + "The tmux server generation changed before the lookup answered.", + expected, + observed); + } + } + /// Gets one session by its typed identifier. [UnsupportedOSPlatform("windows")] public async Task GetSessionAsync( @@ -108,9 +128,8 @@ public async Task GetSessionAsync( throw new TmuxObjectNotFoundException($"Session {id} was not found.", id.ToString()); } - Session? materialized = null; - MaterializeSession(connection, identity.Value.Generation, identity.Value.Id, ref materialized); - return materialized ?? new Session(this, connection, identity.Value.Generation, identity.Value.Id); + RequireOwnedGeneration(identity.Value.Generation); + return new Session(this, connection, identity.Value.Generation, identity.Value.Id); } /// Gets one window by its typed identifier. @@ -128,9 +147,8 @@ public async Task GetWindowAsync( throw new TmuxObjectNotFoundException($"Window {id} was not found.", id.ToString()); } - Window? materialized = null; - MaterializeWindow(connection, identity.Value.Generation, identity.Value.Id, ref materialized); - return materialized ?? new Window(this, connection, identity.Value.Generation, identity.Value.Id); + RequireOwnedGeneration(identity.Value.Generation); + return new Window(this, connection, identity.Value.Generation, identity.Value.Id); } /// Gets one pane by its typed identifier. @@ -148,29 +166,10 @@ public async Task GetPaneAsync( throw new TmuxObjectNotFoundException($"Pane {id} was not found.", id.ToString()); } - Pane? materialized = null; - MaterializePane(connection, identity.Value.Generation, identity.Value.Id, ref materialized); - return materialized ?? new Pane(this, connection, identity.Value.Generation, identity.Value.Id); + RequireOwnedGeneration(identity.Value.Generation); + return new Pane(this, connection, identity.Value.Generation, identity.Value.Id); } - partial void MaterializeSession( - TmuxConnection connection, - ServerGeneration generation, - SessionId id, - ref Session? result); - - partial void MaterializeWindow( - TmuxConnection connection, - ServerGeneration generation, - WindowId id, - ref Window? result); - - partial void MaterializePane( - TmuxConnection connection, - ServerGeneration generation, - PaneId id, - ref Pane? result); - /// public override bool Equals(object? obj) { diff --git a/tests/LibTmux.IntegrationTests/Connection/ServerGenerationTests.cs b/tests/LibTmux.IntegrationTests/Connection/ServerGenerationTests.cs index 1675d84..66a7d6f 100644 --- a/tests/LibTmux.IntegrationTests/Connection/ServerGenerationTests.cs +++ b/tests/LibTmux.IntegrationTests/Connection/ServerGenerationTests.cs @@ -99,6 +99,17 @@ public async Task Stale_entity_cannot_target_a_reused_id() Assert.NotEqual(expected, actual); Assert.Equal(firstServer, successorServer); + // The stale handle can still resolve the identifier, because the + // replacement reuses it. It must not answer with a handle that names + // the new server's session while reporting the old server as its owner. + StaleServerGenerationException lookup = + await Assert.ThrowsAsync( + () => firstServer.GetSessionAsync( + new SessionId(0), + TestContext.Current.CancellationToken)); + Assert.Equal(expected, lookup.Expected); + Assert.Equal(actual, lookup.Actual); + StaleServerGenerationException error = await Assert.ThrowsAsync( () => staleSession.ExecuteCommandAsync( From 1963f4d2609ef73bd20f75aeaa813c1ee6a9560a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 17:35:26 -0500 Subject: [PATCH 100/129] Chaining(fix[formats]): Say which popup and menu arguments tmux expands why: The commit that documented the names tmux expands stopped at select-pane, rename-window, rename-session, new-window and new-session. cmd-display-menu.c implements both popup and menu and runs three more arguments through format_single_from_target: the menu title at line 321, the popup start directory at 445 and the popup title at 486. Checked the rest of the family rather than guessing: link-window, move-window and respawn-pane call no format_single, and a buffer name is copied with xstrdup, so those stay undocumented because nothing happens to them. what: - Document the expansion on DisplayMenuRequest.Title, DisplayPopupRequest.Title and DisplayPopupRequest.StartDirectory - Chain the hook commands with the overload that takes a sequence, which is the call site that motivated adding it - Name the accessor that failed in Pane.Server and Window.ActivePane --- src/LibTmux/Chaining/TmuxChaining.Hooks.cs | 8 +------- src/LibTmux/Pane.Relations.cs | 6 ++++-- src/LibTmux/Requests/DisplayMenuRequest.cs | 4 ++++ src/LibTmux/Requests/DisplayPopupRequest.cs | 8 ++++++++ src/LibTmux/Window.Relations.cs | 2 +- 5 files changed, 18 insertions(+), 10 deletions(-) diff --git a/src/LibTmux/Chaining/TmuxChaining.Hooks.cs b/src/LibTmux/Chaining/TmuxChaining.Hooks.cs index 8c95e4c..5a7ad3f 100644 --- a/src/LibTmux/Chaining/TmuxChaining.Hooks.cs +++ b/src/LibTmux/Chaining/TmuxChaining.Hooks.cs @@ -164,12 +164,6 @@ public static Task ExecuteAsync( CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(server); - TmuxChain chain = server.Chain(); - foreach (TmuxCommand command in request.ToCommands(hooks)) - { - chain = chain.Then(command); - } - - return chain.ExecuteAsync(cancellationToken); + return server.Chain().Then(request.ToCommands(hooks)).ExecuteAsync(cancellationToken); } } diff --git a/src/LibTmux/Pane.Relations.cs b/src/LibTmux/Pane.Relations.cs index 7894af7..a7f91e9 100644 --- a/src/LibTmux/Pane.Relations.cs +++ b/src/LibTmux/Pane.Relations.cs @@ -13,8 +13,7 @@ public sealed partial class Pane /// Every handle reached through a server carries it, whether the handle was /// materialized from a listing or resolved from an identifier. /// - public Server Server => - _owner ?? throw new IncompleteSnapshotException("server", SnapshotDepth.Server); + public Server Server => RequireOwner("server"); /// Gets the session containing this pane. /// @@ -52,6 +51,9 @@ public Window Window } } + private Server RequireOwner(string relation) => + _owner ?? throw new IncompleteSnapshotException(relation, SnapshotDepth.Server); + private TmuxConnection RequireConnection() => Server.Connection ?? throw new IncompleteSnapshotException("connection", SnapshotDepth.Server); diff --git a/src/LibTmux/Requests/DisplayMenuRequest.cs b/src/LibTmux/Requests/DisplayMenuRequest.cs index c68083a..dce19b8 100644 --- a/src/LibTmux/Requests/DisplayMenuRequest.cs +++ b/src/LibTmux/Requests/DisplayMenuRequest.cs @@ -59,6 +59,10 @@ public DisplayMenuRequest( public IReadOnlyList Items => _items; /// Gets the title shown above them. + /// + /// tmux expands it as a format, so a # in it does not survive + /// verbatim. + /// public string? Title { get; } /// Gets the pane the menu belongs to. diff --git a/src/LibTmux/Requests/DisplayPopupRequest.cs b/src/LibTmux/Requests/DisplayPopupRequest.cs index 3e62a6e..76bc16a 100644 --- a/src/LibTmux/Requests/DisplayPopupRequest.cs +++ b/src/LibTmux/Requests/DisplayPopupRequest.cs @@ -111,9 +111,17 @@ public DisplayPopupRequest( public string? Y { get; } /// Gets the working directory for the command. + /// + /// tmux expands it as a format, so a # in it does not survive + /// verbatim. + /// public string? StartDirectory { get; } /// Gets the popup title. + /// + /// tmux expands it as a format, so a # in it does not survive + /// verbatim. + /// public string? Title { get; } /// Gets the border line style. diff --git a/src/LibTmux/Window.Relations.cs b/src/LibTmux/Window.Relations.cs index 226eec8..7f6e367 100644 --- a/src/LibTmux/Window.Relations.cs +++ b/src/LibTmux/Window.Relations.cs @@ -26,7 +26,7 @@ public Pane ActivePane throw new IncompleteSnapshotException("active pane", SnapshotDepth.Windows); } - return new Pane(RequireOwner("panes"), RequireConnection(), _generation, id); + return new Pane(RequireOwner("active pane"), RequireConnection(), _generation, id); } } From fe59b0c1fbfaf647d227fa79a2567eab34f4ecb4 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 17:37:03 -0500 Subject: [PATCH 101/129] Requests(fix[formats]): Say that a start directory is a format too why: Sweeping the class rather than waiting for the next report of it. Two rounds of this have been one member documented and its siblings not, so this time the question was asked of every tmux command that expands an argument rather than of the one that prompted it. spawn.c expands the -c value at line 229, in the path every spawning command shares, which is why grepping each command for format_single found nothing: new-session, new-window, split-window, new-pane and respawn-pane all inherit it from there. Confirmed on a live server for all of them, and if-shell expands its command the same way. Five StartDirectory members and IfShellRequest.ShellCommand said nothing about it. A caller building one from a path that carries a '#' gets a pane somewhere else, silently, because tmux falls back to its default when chdir fails. what: - Document the expansion on the five start directories and on IfShellRequest.ShellCommand - Record it beside the tilde quirk in StartDirectory, which is where what tmux does to this value is already written down - Extend the format test to a spawned pane's start directory --- src/LibTmux/Internal/StartDirectory.cs | 4 ++++ src/LibTmux/Requests/IfShellRequest.cs | 4 ++++ src/LibTmux/Requests/NewPaneRequest.cs | 4 ++++ src/LibTmux/Requests/NewSessionRequest.cs | 4 ++++ src/LibTmux/Requests/NewWindowRequest.cs | 4 ++++ src/LibTmux/Requests/RespawnRequest.cs | 4 ++++ src/LibTmux/Requests/SplitPaneRequest.cs | 4 ++++ .../Hierarchy/PaneOperationsTests.cs | 9 +++++++++ 8 files changed, 37 insertions(+) diff --git a/src/LibTmux/Internal/StartDirectory.cs b/src/LibTmux/Internal/StartDirectory.cs index 3c8d642..c3dbd06 100644 --- a/src/LibTmux/Internal/StartDirectory.cs +++ b/src/LibTmux/Internal/StartDirectory.cs @@ -6,6 +6,10 @@ namespace LibTmux.Internal; /// a home directory to it: the call fails and tmux silently falls back to its /// own default, leaving the pane somewhere the caller never asked for. Only a /// shell expands the tilde, and there is no shell in this path. +/// +/// tmux also expands the value as a format, in the spawn path every command +/// that takes -c shares, so a # in it does not reach chdir. +/// /// internal static class StartDirectory { diff --git a/src/LibTmux/Requests/IfShellRequest.cs b/src/LibTmux/Requests/IfShellRequest.cs index 6e90040..f1564f4 100644 --- a/src/LibTmux/Requests/IfShellRequest.cs +++ b/src/LibTmux/Requests/IfShellRequest.cs @@ -36,6 +36,10 @@ public IfShellRequest( } /// Gets the shell command whose success decides. + /// + /// tmux expands it as a format before running it, so a # in it does + /// not survive verbatim. + /// public string ShellCommand { get; } /// Gets the tmux command run when it succeeds. diff --git a/src/LibTmux/Requests/NewPaneRequest.cs b/src/LibTmux/Requests/NewPaneRequest.cs index 2658b6e..26f9c85 100644 --- a/src/LibTmux/Requests/NewPaneRequest.cs +++ b/src/LibTmux/Requests/NewPaneRequest.cs @@ -79,6 +79,10 @@ public NewPaneRequest( public string? Target { get; } /// Gets the working directory for the new pane. + /// + /// tmux expands it as a format before it changes directory, so a # + /// in it does not survive verbatim. + /// public string? StartDirectory { get; } /// Gets whether the new pane becomes active. diff --git a/src/LibTmux/Requests/NewSessionRequest.cs b/src/LibTmux/Requests/NewSessionRequest.cs index 960cc8d..1ff331a 100644 --- a/src/LibTmux/Requests/NewSessionRequest.cs +++ b/src/LibTmux/Requests/NewSessionRequest.cs @@ -69,6 +69,10 @@ public NewSessionRequest( public bool Attach { get; } /// Gets the working directory for the first pane. + /// + /// tmux expands it as a format before it changes directory, so a # + /// in it does not survive verbatim. + /// public string? StartDirectory { get; } /// Gets the name of the first window. diff --git a/src/LibTmux/Requests/NewWindowRequest.cs b/src/LibTmux/Requests/NewWindowRequest.cs index 1aa573c..5faf8b9 100644 --- a/src/LibTmux/Requests/NewWindowRequest.cs +++ b/src/LibTmux/Requests/NewWindowRequest.cs @@ -64,6 +64,10 @@ public NewWindowRequest( public string? Name { get; } /// Gets the working directory for the first pane. + /// + /// tmux expands it as a format before it changes directory, so a # + /// in it does not survive verbatim. + /// public string? StartDirectory { get; } /// Gets whether the new window becomes current. diff --git a/src/LibTmux/Requests/RespawnRequest.cs b/src/LibTmux/Requests/RespawnRequest.cs index 76387e2..1e1290e 100644 --- a/src/LibTmux/Requests/RespawnRequest.cs +++ b/src/LibTmux/Requests/RespawnRequest.cs @@ -36,6 +36,10 @@ public RespawnRequest( public string? Command { get; } /// Gets the working directory to respawn in. + /// + /// tmux expands it as a format before it changes directory, so a # + /// in it does not survive verbatim. + /// public string? StartDirectory { get; } /// Gets the environment entries set on the respawned target. diff --git a/src/LibTmux/Requests/SplitPaneRequest.cs b/src/LibTmux/Requests/SplitPaneRequest.cs index f8f7655..935e17c 100644 --- a/src/LibTmux/Requests/SplitPaneRequest.cs +++ b/src/LibTmux/Requests/SplitPaneRequest.cs @@ -88,6 +88,10 @@ public SplitPaneRequest( public string? Target { get; } /// Gets the working directory for the new pane. + /// + /// tmux expands it as a format before it changes directory, so a # + /// in it does not survive verbatim. + /// public string? StartDirectory { get; } /// Gets whether the new pane becomes active. diff --git a/tests/LibTmux.IntegrationTests/Hierarchy/PaneOperationsTests.cs b/tests/LibTmux.IntegrationTests/Hierarchy/PaneOperationsTests.cs index 4a055f8..7f4a122 100644 --- a/tests/LibTmux.IntegrationTests/Hierarchy/PaneOperationsTests.cs +++ b/tests/LibTmux.IntegrationTests/Hierarchy/PaneOperationsTests.cs @@ -557,6 +557,15 @@ public async Task A_name_and_a_title_are_expanded_as_formats() Assert.Equal(pane.Id.ToString(), titled.Title); Assert.Equal($"x{pane.Id}", renamed.Name); + + // The start directory goes the same way, in the spawn path every + // command taking -c shares rather than in any one of them. + Pane spawned = await pane.SplitAsync( + new SplitPaneRequest(startDirectory: "/tmp/#{session_name}-absent"), + token); + Assert.NotEqual( + "/tmp/#{session_name}-absent", + await FormatAsync(spawned, "#{pane_start_path}", token)); } [UnixFact] From 67aecbb71bb701b13504f34ff0e514ab01a169c9 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 17:38:00 -0500 Subject: [PATCH 102/129] Mcp(fix[jobs]): Say why a job was lost why: Chasing an intermittent tmux_job failure that answers with a null exit status. The state behind it is JobState.Lost, which tmux_job returns at once because the job is no longer Running, so the waitSeconds the caller asked for is never spent and the client polls its own bound out. The watcher reaches Lost two ways. An unexpected exception is captured and logged as "could no longer be watched". A LibTmuxException -- a tmux or transport failure, which is the likely way to actually lose a job -- was caught and discarded, so the failure that happens is the one that leaves no trace. The warning exists and says the right thing; it was not reached. This does not fix the underlying failure, which I have not reproduced under observation. It makes the next occurrence explain itself. what: - Capture the tmux failure that loses a job so the existing warning carries it, and keep cancellation out of that path - Cover a transport failure being reported, beside the unexpected one --- src/LibTmux.Mcp/Jobs/JobStore.cs | 16 +++++------ tests/LibTmux.UnitTests/Mcp/JobStoreTests.cs | 28 ++++++++++++++++++++ 2 files changed, 35 insertions(+), 9 deletions(-) diff --git a/src/LibTmux.Mcp/Jobs/JobStore.cs b/src/LibTmux.Mcp/Jobs/JobStore.cs index 4a8e091..4f63639 100644 --- a/src/LibTmux.Mcp/Jobs/JobStore.cs +++ b/src/LibTmux.Mcp/Jobs/JobStore.cs @@ -366,7 +366,7 @@ private void ForgetLocked() private async Task WatchAsync(Server server, Pane pane, StoredJob job) { - Exception? unexpected = null; + Exception? failure = null; try { await server.WaitForAsync( @@ -383,19 +383,17 @@ await server.WaitForAsync( { // The command belongs to tmux and survives this bookkeeping store. } - catch (LibTmuxException) + catch (Exception error) when (error is not OperationCanceledException) { - job.TryFinish(JobState.Lost, null); - } - catch (Exception error) - { - unexpected = error; + // A tmux or transport failure is the likely way to lose a job, so + // it is the one that must say why rather than the one that does not. + failure = error; job.TryFinish(JobState.Lost, null); } - if (_logger is not null && unexpected is not null) + if (_logger is not null && failure is not null) { - Log.JobWatcherFailed(_logger, unexpected, job.JobId, job.PaneId); + Log.JobWatcherFailed(_logger, failure, job.JobId, job.PaneId); } if (_logger is not null && job.State != JobState.Running) diff --git a/tests/LibTmux.UnitTests/Mcp/JobStoreTests.cs b/tests/LibTmux.UnitTests/Mcp/JobStoreTests.cs index 628fa2a..df99e89 100644 --- a/tests/LibTmux.UnitTests/Mcp/JobStoreTests.cs +++ b/tests/LibTmux.UnitTests/Mcp/JobStoreTests.cs @@ -782,6 +782,34 @@ async Task WaitForSilentActivity(CancellationToken cancellationToken) await activityCancelled.Task.WaitAsync(TimeSpan.FromSeconds(1), token); } + [Fact] + public async Task A_tmux_watcher_failure_says_why_the_job_was_lost() + { + CancellationToken token = TestContext.Current.CancellationToken; + FakeEndpoint endpoint = new("watch-tmux-fault", new ServerGeneration(809, 8009)); + endpoint.Handler = (arguments, _) => + arguments.Count > 0 && arguments[0] == "wait-for" + ? Task.FromException( + new TmuxTransportException("the client went away", arguments)) + : Task.FromResult(endpoint.Success(arguments)); + var logger = new RecordingLogger(); + await using JobStore jobs = new(logger); + + JobInfo started = await jobs.StartAsync( + endpoint.Server, + endpoint.Pane, + "echo watched", + suppressHistory: true, + token); + Task watcher = Assert.IsAssignableFrom(jobs.Resolve(started.JobId, null).Watcher); + await watcher.WaitAsync(token); + + Assert.Equal(JobState.Lost, jobs.Get(started.JobId).State); + Assert.Contains( + logger.Entries, + entry => entry.EventId.Id == 9 && entry.Error is TmuxTransportException); + } + [Fact] public async Task Unexpected_watcher_failure_is_observed_and_marks_the_job_lost() { From ba534d4b67d0c80d513d7ad7028f2de7803ca668 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 17:38:12 -0500 Subject: [PATCH 103/129] Mcp(test[protocol]): Stop clamping the wait these tests ask for why: A_waiting_tool_can_be_started_as_a_task_and_collected_later asked tmux_job to wait twenty seconds and failed at ten with no exit status, about one run in two. The harness set WaitCeiling to ten, so the request was clamped to half of what the test named, and a job whose shell was still starting under load had not finished when the wait expired. The tool then answered with the job still running, which reads as a lost exit status rather than as an expired wait. The mechanics underneath are sound: the payload shape the job sends was run against a live server twenty times and collected status 7 every time, and tmux_job polls the job's state rather than the wait-for channel, so nothing competes for the signal. The clamp is the whole difference. The sibling McpToolFixture already allows twenty seconds. Three consecutive suite runs, where it previously failed in roughly half. what: - Allow the twenty seconds the protocol tests ask for --- tests/LibTmux.IntegrationTests/Mcp/McpProtocolTests.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/LibTmux.IntegrationTests/Mcp/McpProtocolTests.cs b/tests/LibTmux.IntegrationTests/Mcp/McpProtocolTests.cs index aa112e6..0337c17 100644 --- a/tests/LibTmux.IntegrationTests/Mcp/McpProtocolTests.cs +++ b/tests/LibTmux.IntegrationTests/Mcp/McpProtocolTests.cs @@ -493,7 +493,10 @@ internal static async Task StartAsync( string socketName = $"ltp-{Guid.NewGuid():N}"[..20]; McpServerComposition.Add( services, - new ServerPolicy { Tier = tier, WaitCeiling = TimeSpan.FromSeconds(10) }, + // Ten seconds clamped what these tests ask for, so a job that + // waited on a shell starting under load reported no exit status + // rather than the one it was about to produce. + new ServerPolicy { Tier = tier, WaitCeiling = TimeSpan.FromSeconds(20) }, new ServerConnectionOptions( tmuxBinaryPath: System.Environment.GetEnvironmentVariable("LIBTMUX_TMUX") ?? "tmux", socketName: socketName, From 081e36876dfa55c9e1f515eb5101b04844cdbb86 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 17:38:31 -0500 Subject: [PATCH 104/129] ControlMode(test[restart]): Let the old daemon go before the successor why: Startup_rejects_a_server_restart_between_discovery_and_attach failed in two runs of three, in about a tenth of a second. Its wrapper script runs kill-server and then new-session on the same socket with nothing in between. kill-server returns before the daemon has finished, and the daemon unlinks the socket as it exits, so the successor can be handed a socket the old server then removes. This repository already knows that hazard: RawTmuxTestContext offers WaitForSettledAsync for it, and ChainGenerationTests says in a comment why the old server has to be gone first. The wrapper is a shell script, so it waits the same way with a bounded poll. Four consecutive suite runs, where it previously failed in two of three. what: - Wait for the old server to stop answering before starting the successor --- .../ControlMode/ControlModeSessionTests.cs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/LibTmux.IntegrationTests/ControlMode/ControlModeSessionTests.cs b/tests/LibTmux.IntegrationTests/ControlMode/ControlModeSessionTests.cs index 48d6f71..4d02e80 100644 --- a/tests/LibTmux.IntegrationTests/ControlMode/ControlModeSessionTests.cs +++ b/tests/LibTmux.IntegrationTests/ControlMode/ControlModeSessionTests.cs @@ -374,6 +374,18 @@ public async Task Startup_rejects_a_server_restart_between_discovery_and_attach( {{ShellQuote(raw.TmuxBinaryPath)}} \ -S {{ShellQuote(raw.SocketPath)}} \ kill-server + # kill-server returns before the daemon has finished, and + # the daemon unlinks the socket as it goes. Starting the + # successor first would hand it a socket the old server + # then removes. + settle=0 + while [ "$settle" -lt 200 ] \ + && {{ShellQuote(raw.TmuxBinaryPath)}} \ + -S {{ShellQuote(raw.SocketPath)}} \ + list-sessions >/dev/null 2>&1; do + sleep 0.02 + settle=$((settle + 1)) + done {{ShellQuote(raw.TmuxBinaryPath)}} \ -S {{ShellQuote(raw.SocketPath)}} \ -f /dev/null \ From 2f00741e4b6a3ed44ae766bb0829a82007b712df Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 17:44:14 -0500 Subject: [PATCH 105/129] Hierarchy(refactor[scopes]): Put an entity's tables in one file why: Ten files held one lazily built property each, between sixteen and twenty-two lines, and two of them per entity had names that differ by a suffix and mean unrelated things: X.Environment.cs resolves a handle from the process environment, X.EnvironmentOperations.cs reaches the tmux environment table. Looking for one found the other. An entity's option, hook and environment tables are one job -- how it reaches what tmux scopes to it -- so they belong together, and the file that resolves a handle from the environment can then say so in its name. The six-line files named after each type stay. They carry the type's documentation comment, which has to live on exactly one declaration. what: - Merge the option, hook and environment-table partials into X.Scopes.cs for each of the four entities, ten files into four - Name X.Environment.cs for what it holds, X.FromEnvironment.cs --- ...Environment.cs => Pane.FromEnvironment.cs} | 0 src/LibTmux/Pane.Hooks.cs | 16 ------- .../{Pane.Options.cs => Pane.Scopes.cs} | 11 ++++- src/LibTmux/Server.EnvironmentOperations.cs | 16 ------- ...vironment.cs => Server.FromEnvironment.cs} | 0 src/LibTmux/Server.Hooks.cs | 20 --------- src/LibTmux/Server.Options.cs | 21 --------- src/LibTmux/Server.Scopes.cs | 43 +++++++++++++++++++ src/LibTmux/Session.EnvironmentOperations.cs | 16 ------- ...ironment.cs => Session.FromEnvironment.cs} | 0 src/LibTmux/Session.Hooks.cs | 16 ------- src/LibTmux/Session.Options.cs | 17 -------- src/LibTmux/Session.Scopes.cs | 35 +++++++++++++++ ...vironment.cs => Window.FromEnvironment.cs} | 0 src/LibTmux/Window.Hooks.cs | 16 ------- .../{Window.Options.cs => Window.Scopes.cs} | 11 ++++- 16 files changed, 98 insertions(+), 140 deletions(-) rename src/LibTmux/{Pane.Environment.cs => Pane.FromEnvironment.cs} (100%) delete mode 100644 src/LibTmux/Pane.Hooks.cs rename src/LibTmux/{Pane.Options.cs => Pane.Scopes.cs} (56%) delete mode 100644 src/LibTmux/Server.EnvironmentOperations.cs rename src/LibTmux/{Server.Environment.cs => Server.FromEnvironment.cs} (100%) delete mode 100644 src/LibTmux/Server.Hooks.cs delete mode 100644 src/LibTmux/Server.Options.cs create mode 100644 src/LibTmux/Server.Scopes.cs delete mode 100644 src/LibTmux/Session.EnvironmentOperations.cs rename src/LibTmux/{Session.Environment.cs => Session.FromEnvironment.cs} (100%) delete mode 100644 src/LibTmux/Session.Hooks.cs delete mode 100644 src/LibTmux/Session.Options.cs create mode 100644 src/LibTmux/Session.Scopes.cs rename src/LibTmux/{Window.Environment.cs => Window.FromEnvironment.cs} (100%) delete mode 100644 src/LibTmux/Window.Hooks.cs rename src/LibTmux/{Window.Options.cs => Window.Scopes.cs} (66%) diff --git a/src/LibTmux/Pane.Environment.cs b/src/LibTmux/Pane.FromEnvironment.cs similarity index 100% rename from src/LibTmux/Pane.Environment.cs rename to src/LibTmux/Pane.FromEnvironment.cs diff --git a/src/LibTmux/Pane.Hooks.cs b/src/LibTmux/Pane.Hooks.cs deleted file mode 100644 index dd4eede..0000000 --- a/src/LibTmux/Pane.Hooks.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System.Runtime.Versioning; - -namespace LibTmux; - -// Reaches this pane's hooks. -public sealed partial class Pane -{ - private TmuxHooks? _hooks; - - /// Gets the hooks of this pane. - [UnsupportedOSPlatform("windows")] - public TmuxHooks Hooks => _hooks ??= new TmuxHooks( - _commandDispatcher, - OptionScope.Pane, - _id.ToString()); -} diff --git a/src/LibTmux/Pane.Options.cs b/src/LibTmux/Pane.Scopes.cs similarity index 56% rename from src/LibTmux/Pane.Options.cs rename to src/LibTmux/Pane.Scopes.cs index 5a2e8a3..632f525 100644 --- a/src/LibTmux/Pane.Options.cs +++ b/src/LibTmux/Pane.Scopes.cs @@ -2,7 +2,7 @@ namespace LibTmux; -// Reaches this pane's option table. +// Reaches the option and hook tables this pane scopes. public sealed partial class Pane { private TmuxOptions? _options; @@ -14,4 +14,13 @@ public sealed partial class Pane OptionScope.Pane, _id.ToString(), TmuxOptions.DoubleEscapesDollar(_owner)); + + private TmuxHooks? _hooks; + + /// Gets the hooks of this pane. + [UnsupportedOSPlatform("windows")] + public TmuxHooks Hooks => _hooks ??= new TmuxHooks( + _commandDispatcher, + OptionScope.Pane, + _id.ToString()); } diff --git a/src/LibTmux/Server.EnvironmentOperations.cs b/src/LibTmux/Server.EnvironmentOperations.cs deleted file mode 100644 index facfaf9..0000000 --- a/src/LibTmux/Server.EnvironmentOperations.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System.Runtime.Versioning; - -namespace LibTmux; - -// Reaches the server's own environment. -public sealed partial class Server -{ - private TmuxEnvironment? _environment; - - /// Gets the environment new sessions inherit from. - [UnsupportedOSPlatform("windows")] - public TmuxEnvironment Environment => _environment ??= new TmuxEnvironment( - _commandDispatcher, - global: true, - target: null); -} diff --git a/src/LibTmux/Server.Environment.cs b/src/LibTmux/Server.FromEnvironment.cs similarity index 100% rename from src/LibTmux/Server.Environment.cs rename to src/LibTmux/Server.FromEnvironment.cs diff --git a/src/LibTmux/Server.Hooks.cs b/src/LibTmux/Server.Hooks.cs deleted file mode 100644 index 907b39e..0000000 --- a/src/LibTmux/Server.Hooks.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System.Runtime.Versioning; - -namespace LibTmux; - -// Reaches this server's hooks. -public sealed partial class Server -{ - private TmuxHooks? _hooks; - - /// Gets the hooks of this server. - /// - /// tmux has no server hook table of its own: the global one is it, which is - /// why these are reached with the global flag rather than a server flag. - /// - [UnsupportedOSPlatform("windows")] - public TmuxHooks Hooks => _hooks ??= new TmuxHooks( - _commandDispatcher, - OptionScope.Server, - null); -} diff --git a/src/LibTmux/Server.Options.cs b/src/LibTmux/Server.Options.cs deleted file mode 100644 index 4bcd49d..0000000 --- a/src/LibTmux/Server.Options.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System.Runtime.Versioning; - -namespace LibTmux; - -// Reaches the server's own option table. -public sealed partial class Server -{ - private TmuxOptions? _options; - - /// Gets the options of this server. - /// - /// Server options belong to the daemon rather than to anything inside it, - /// so nothing is targeted and the global table is the only one there is. - /// - [UnsupportedOSPlatform("windows")] - public TmuxOptions Options => _options ??= new TmuxOptions( - _commandDispatcher, - OptionScope.Server, - null, - TmuxOptions.DoubleEscapesDollar(this)); -} diff --git a/src/LibTmux/Server.Scopes.cs b/src/LibTmux/Server.Scopes.cs new file mode 100644 index 0000000..ab90fc8 --- /dev/null +++ b/src/LibTmux/Server.Scopes.cs @@ -0,0 +1,43 @@ +using System.Runtime.Versioning; + +namespace LibTmux; + +// Reaches the option, hook and environment tables this server scopes. +public sealed partial class Server +{ + private TmuxOptions? _options; + + /// Gets the options of this server. + /// + /// Server options belong to the daemon rather than to anything inside it, + /// so nothing is targeted and the global table is the only one there is. + /// + [UnsupportedOSPlatform("windows")] + public TmuxOptions Options => _options ??= new TmuxOptions( + _commandDispatcher, + OptionScope.Server, + null, + TmuxOptions.DoubleEscapesDollar(this)); + + private TmuxHooks? _hooks; + + /// Gets the hooks of this server. + /// + /// tmux has no server hook table of its own: the global one is it, which is + /// why these are reached with the global flag rather than a server flag. + /// + [UnsupportedOSPlatform("windows")] + public TmuxHooks Hooks => _hooks ??= new TmuxHooks( + _commandDispatcher, + OptionScope.Server, + null); + + private TmuxEnvironment? _environment; + + /// Gets the environment new sessions inherit from. + [UnsupportedOSPlatform("windows")] + public TmuxEnvironment Environment => _environment ??= new TmuxEnvironment( + _commandDispatcher, + global: true, + target: null); +} diff --git a/src/LibTmux/Session.EnvironmentOperations.cs b/src/LibTmux/Session.EnvironmentOperations.cs deleted file mode 100644 index 1092732..0000000 --- a/src/LibTmux/Session.EnvironmentOperations.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System.Runtime.Versioning; - -namespace LibTmux; - -// Reaches this session's environment. -public sealed partial class Session -{ - private TmuxEnvironment? _environment; - - /// Gets the environment panes created in this session inherit from. - [UnsupportedOSPlatform("windows")] - public TmuxEnvironment Environment => _environment ??= new TmuxEnvironment( - _commandDispatcher, - global: false, - target: _id.ToString()); -} diff --git a/src/LibTmux/Session.Environment.cs b/src/LibTmux/Session.FromEnvironment.cs similarity index 100% rename from src/LibTmux/Session.Environment.cs rename to src/LibTmux/Session.FromEnvironment.cs diff --git a/src/LibTmux/Session.Hooks.cs b/src/LibTmux/Session.Hooks.cs deleted file mode 100644 index 1fecd59..0000000 --- a/src/LibTmux/Session.Hooks.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System.Runtime.Versioning; - -namespace LibTmux; - -// Reaches this session's hooks. -public sealed partial class Session -{ - private TmuxHooks? _hooks; - - /// Gets the hooks of this session. - [UnsupportedOSPlatform("windows")] - public TmuxHooks Hooks => _hooks ??= new TmuxHooks( - _commandDispatcher, - OptionScope.Session, - _id.ToString()); -} diff --git a/src/LibTmux/Session.Options.cs b/src/LibTmux/Session.Options.cs deleted file mode 100644 index a71813c..0000000 --- a/src/LibTmux/Session.Options.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System.Runtime.Versioning; - -namespace LibTmux; - -// Reaches this session's option table. -public sealed partial class Session -{ - private TmuxOptions? _options; - - /// Gets the options of this session. - [UnsupportedOSPlatform("windows")] - public TmuxOptions Options => _options ??= new TmuxOptions( - _commandDispatcher, - OptionScope.Session, - _id.ToString(), - TmuxOptions.DoubleEscapesDollar(_owner)); -} diff --git a/src/LibTmux/Session.Scopes.cs b/src/LibTmux/Session.Scopes.cs new file mode 100644 index 0000000..bd4c6b9 --- /dev/null +++ b/src/LibTmux/Session.Scopes.cs @@ -0,0 +1,35 @@ +using System.Runtime.Versioning; + +namespace LibTmux; + +// Reaches the option, hook and environment tables this session scopes. +public sealed partial class Session +{ + private TmuxOptions? _options; + + /// Gets the options of this session. + [UnsupportedOSPlatform("windows")] + public TmuxOptions Options => _options ??= new TmuxOptions( + _commandDispatcher, + OptionScope.Session, + _id.ToString(), + TmuxOptions.DoubleEscapesDollar(_owner)); + + private TmuxHooks? _hooks; + + /// Gets the hooks of this session. + [UnsupportedOSPlatform("windows")] + public TmuxHooks Hooks => _hooks ??= new TmuxHooks( + _commandDispatcher, + OptionScope.Session, + _id.ToString()); + + private TmuxEnvironment? _environment; + + /// Gets the environment panes created in this session inherit from. + [UnsupportedOSPlatform("windows")] + public TmuxEnvironment Environment => _environment ??= new TmuxEnvironment( + _commandDispatcher, + global: false, + target: _id.ToString()); +} diff --git a/src/LibTmux/Window.Environment.cs b/src/LibTmux/Window.FromEnvironment.cs similarity index 100% rename from src/LibTmux/Window.Environment.cs rename to src/LibTmux/Window.FromEnvironment.cs diff --git a/src/LibTmux/Window.Hooks.cs b/src/LibTmux/Window.Hooks.cs deleted file mode 100644 index 5ab77b3..0000000 --- a/src/LibTmux/Window.Hooks.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System.Runtime.Versioning; - -namespace LibTmux; - -// Reaches this window's hooks. -public sealed partial class Window -{ - private TmuxHooks? _hooks; - - /// Gets the hooks of this window. - [UnsupportedOSPlatform("windows")] - public TmuxHooks Hooks => _hooks ??= new TmuxHooks( - _commandDispatcher, - OptionScope.Window, - _id.ToString()); -} diff --git a/src/LibTmux/Window.Options.cs b/src/LibTmux/Window.Scopes.cs similarity index 66% rename from src/LibTmux/Window.Options.cs rename to src/LibTmux/Window.Scopes.cs index d6618a3..0e737d7 100644 --- a/src/LibTmux/Window.Options.cs +++ b/src/LibTmux/Window.Scopes.cs @@ -2,7 +2,7 @@ namespace LibTmux; -// Reaches this window's option table. +// Reaches the option and hook tables this window scopes. public sealed partial class Window { private TmuxOptions? _options; @@ -19,4 +19,13 @@ public sealed partial class Window OptionScope.Window, _id.ToString(), TmuxOptions.DoubleEscapesDollar(_owner)); + + private TmuxHooks? _hooks; + + /// Gets the hooks of this window. + [UnsupportedOSPlatform("windows")] + public TmuxHooks Hooks => _hooks ??= new TmuxHooks( + _commandDispatcher, + OptionScope.Window, + _id.ToString()); } From 76a0b37132f8b97ab407ba03b32a9c419ce78ad3 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 17:46:57 -0500 Subject: [PATCH 106/129] Tests(fix[budget]): Wait one budget rather than twenty-four literals why: Four different integration tests failed at the ten-second mark while the suite ran beside a build -- a workspace build, an MCP job, a control mode restart, a paste-buffer parity check -- each looking like a distinct bug. They are one: twenty-four polls hard-coded ten seconds, and every one waits for something tmux is about to do rather than asserting a timeout. Fixing them one at a time is what let the fourth appear after the third was closed, and a fifth would have followed. Naming the budget once puts the class behind a single value. Five consecutive suite runs, and the run time did not move: these polls return as soon as the state arrives, so a longer ceiling costs nothing. what: - Add TestBudget.Settle and read every ten-second poll deadline from it --- .../Chaining/TmuxChainTests.cs | 10 +++++----- .../Clients/ClientAdministrationTests.cs | 4 ++-- .../ControlMode/ControlModeSessionTests.cs | 2 +- .../Environment/EnvironmentOperationsTests.cs | 2 +- .../Hierarchy/PaneOperationsTests.cs | 2 +- .../Infrastructure/TestBudget.cs | 14 ++++++++++++++ .../Mcp/HierarchyWatcherTests.cs | 7 ++++--- .../Parity/Component13ParityTests.cs | 4 ++-- .../Parity/Component16ParityTests.cs | 2 +- .../Testing/TestingHelpersTests.cs | 5 +++-- .../Utilities/ServerUtilitiesTests.cs | 2 +- .../Versioning/VersionParityTests.cs | 4 ++-- .../Workspace/WorkspaceBuilderTests.cs | 14 ++++++++------ 13 files changed, 45 insertions(+), 27 deletions(-) create mode 100644 tests/LibTmux.IntegrationTests/Infrastructure/TestBudget.cs diff --git a/tests/LibTmux.IntegrationTests/Chaining/TmuxChainTests.cs b/tests/LibTmux.IntegrationTests/Chaining/TmuxChainTests.cs index 58fc1aa..f28d054 100644 --- a/tests/LibTmux.IntegrationTests/Chaining/TmuxChainTests.cs +++ b/tests/LibTmux.IntegrationTests/Chaining/TmuxChainTests.cs @@ -189,7 +189,7 @@ await server.Chain() string seen = await TmuxWait.UntilAsync( async inner => string.Join('\n', await pane.CaptureAsync(cancellationToken: inner)), text => text.Contains("chained-keys", StringComparison.Ordinal), - TimeSpan.FromSeconds(10), + TestBudget.Settle, TimeSpan.FromMilliseconds(20), token); @@ -251,7 +251,7 @@ await server.GetSessionsAsync(token), string seen = await TmuxWait.UntilAsync( async inner => string.Join('\n', await pane.CaptureAsync(cancellationToken: inner)), text => text.Contains("executed-keys", StringComparison.Ordinal), - TimeSpan.FromSeconds(10), + TestBudget.Settle, TimeSpan.FromMilliseconds(20), token); @@ -484,7 +484,7 @@ public async Task A_capture_chains_and_drops_the_flags_this_tmux_lacks() await TmuxWait.UntilAsync( async inner => string.Join('\n', await pane.CaptureAsync(cancellationToken: inner)), text => text.Contains("captured-by-chain", StringComparison.Ordinal), - TimeSpan.FromSeconds(10), + TestBudget.Settle, TimeSpan.FromMilliseconds(20), token); @@ -567,7 +567,7 @@ public async Task A_paste_chains_and_keeps_its_version_gate() '\n', await pane.CaptureAsync(new CapturePaneRequest(joinWrappedLines: true), inner)), text => text.Contains("chained-paste", StringComparison.Ordinal), - TimeSpan.FromSeconds(10), + TestBudget.Settle, TimeSpan.FromMilliseconds(20), token); @@ -835,7 +835,7 @@ public async Task Window_resize_and_pane_respawn_chain() ["display-message", "-p", "-t", pane.Id.ToString(), "#{pane_current_command}"], inner)).StandardOutputLines[0], command => command == "cat", - TimeSpan.FromSeconds(10), + TestBudget.Settle, TimeSpan.FromMilliseconds(20), token); diff --git a/tests/LibTmux.IntegrationTests/Clients/ClientAdministrationTests.cs b/tests/LibTmux.IntegrationTests/Clients/ClientAdministrationTests.cs index 971cfe2..3116481 100644 --- a/tests/LibTmux.IntegrationTests/Clients/ClientAdministrationTests.cs +++ b/tests/LibTmux.IntegrationTests/Clients/ClientAdministrationTests.cs @@ -189,7 +189,7 @@ private static async Task WaitForClientAsync(Server server, Cancellation { // Attaching is asynchronous on tmux's side, so the client appears a // moment after the process starts. - DateTimeOffset deadline = DateTimeOffset.UtcNow + TimeSpan.FromSeconds(10); + DateTimeOffset deadline = DateTimeOffset.UtcNow + TestBudget.Settle; while (DateTimeOffset.UtcNow < deadline) { IReadOnlyList clients = await server.GetClientsAsync(token); @@ -209,7 +209,7 @@ private static async Task WaitForClientCountAsync( int expected, CancellationToken token) { - DateTimeOffset deadline = DateTimeOffset.UtcNow + TimeSpan.FromSeconds(10); + DateTimeOffset deadline = DateTimeOffset.UtcNow + TestBudget.Settle; IReadOnlyList clients = []; while (DateTimeOffset.UtcNow < deadline) { diff --git a/tests/LibTmux.IntegrationTests/ControlMode/ControlModeSessionTests.cs b/tests/LibTmux.IntegrationTests/ControlMode/ControlModeSessionTests.cs index 4d02e80..4b0e3bd 100644 --- a/tests/LibTmux.IntegrationTests/ControlMode/ControlModeSessionTests.cs +++ b/tests/LibTmux.IntegrationTests/ControlMode/ControlModeSessionTests.cs @@ -636,7 +636,7 @@ private static async Task WaitUntilAsync( { using CancellationTokenSource timeout = CancellationTokenSource.CreateLinkedTokenSource( cancellationToken); - timeout.CancelAfter(TimeSpan.FromSeconds(10)); + timeout.CancelAfter(TestBudget.Settle); while (!condition()) { await Task.Delay(TimeSpan.FromMilliseconds(20), timeout.Token); diff --git a/tests/LibTmux.IntegrationTests/Environment/EnvironmentOperationsTests.cs b/tests/LibTmux.IntegrationTests/Environment/EnvironmentOperationsTests.cs index b0f7edc..3c6eb84 100644 --- a/tests/LibTmux.IntegrationTests/Environment/EnvironmentOperationsTests.cs +++ b/tests/LibTmux.IntegrationTests/Environment/EnvironmentOperationsTests.cs @@ -112,7 +112,7 @@ public async Task A_session_environment_reaches_the_panes_it_spawns() token); Pane pane = await TestHierarchy.RequireFirstPaneAsync(window, token); - DateTimeOffset deadline = DateTimeOffset.UtcNow + TimeSpan.FromSeconds(10); + DateTimeOffset deadline = DateTimeOffset.UtcNow + TestBudget.Settle; string text = string.Empty; while (DateTimeOffset.UtcNow < deadline) { diff --git a/tests/LibTmux.IntegrationTests/Hierarchy/PaneOperationsTests.cs b/tests/LibTmux.IntegrationTests/Hierarchy/PaneOperationsTests.cs index 7f4a122..2137e95 100644 --- a/tests/LibTmux.IntegrationTests/Hierarchy/PaneOperationsTests.cs +++ b/tests/LibTmux.IntegrationTests/Hierarchy/PaneOperationsTests.cs @@ -599,7 +599,7 @@ private static async Task FirstPaneAsync(Server server, CancellationToken private static async Task WaitForClearedHistoryAsync(Pane pane, CancellationToken token) { - DateTimeOffset deadline = DateTimeOffset.UtcNow + TimeSpan.FromSeconds(10); + DateTimeOffset deadline = DateTimeOffset.UtcNow + TestBudget.Settle; string size = string.Empty; while (DateTimeOffset.UtcNow < deadline) { diff --git a/tests/LibTmux.IntegrationTests/Infrastructure/TestBudget.cs b/tests/LibTmux.IntegrationTests/Infrastructure/TestBudget.cs new file mode 100644 index 0000000..97d930f --- /dev/null +++ b/tests/LibTmux.IntegrationTests/Infrastructure/TestBudget.cs @@ -0,0 +1,14 @@ +namespace LibTmux.IntegrationTests.Infrastructure; + +/// How long a test waits for tmux to reach a state it expects. +internal static class TestBudget +{ + /// The budget a poll expecting success is given. + /// + /// Every one of these waits for something tmux is about to do, so the + /// budget only has to outlast a slow machine. Ten seconds did not: four + /// different tests failed at that mark while the suite ran beside a build, + /// each looking like a distinct bug. + /// + internal static readonly TimeSpan Settle = TimeSpan.FromSeconds(60); +} diff --git a/tests/LibTmux.IntegrationTests/Mcp/HierarchyWatcherTests.cs b/tests/LibTmux.IntegrationTests/Mcp/HierarchyWatcherTests.cs index 618ca57..c134d8f 100644 --- a/tests/LibTmux.IntegrationTests/Mcp/HierarchyWatcherTests.cs +++ b/tests/LibTmux.IntegrationTests/Mcp/HierarchyWatcherTests.cs @@ -1,4 +1,5 @@ using System.Runtime.Versioning; +using LibTmux.IntegrationTests.Infrastructure; using LibTmux.IntegrationTests.Transport; using LibTmux.Mcp; using LibTmux.Testing; @@ -192,7 +193,7 @@ await watcher.SubscribeAsync( IReadOnlyList clients = await TmuxWait.UntilAsync( cancellation => scope.Session.Server.GetClientsAsync(cancellation), current => current.Count == 0, - TimeSpan.FromSeconds(10), + TestBudget.Settle, TimeSpan.FromMilliseconds(250), token); Assert.Empty(clients); @@ -263,7 +264,7 @@ await Task.WhenAny(bothTold, Task.Delay(TimeSpan.FromSeconds(20), token)) == bot IReadOnlyList oneReference = await TmuxWait.UntilAsync( cancellation => scope.Session.Server.GetClientsAsync(cancellation), current => current.Count == 1, - TimeSpan.FromSeconds(10), + TestBudget.Settle, TimeSpan.FromMilliseconds(250), token); Assert.Single(oneReference); @@ -272,7 +273,7 @@ await Task.WhenAny(bothTold, Task.Delay(TimeSpan.FromSeconds(20), token)) == bot IReadOnlyList noReferences = await TmuxWait.UntilAsync( cancellation => scope.Session.Server.GetClientsAsync(cancellation), current => current.Count == 0, - TimeSpan.FromSeconds(10), + TestBudget.Settle, TimeSpan.FromMilliseconds(250), token); Assert.Empty(noReferences); diff --git a/tests/LibTmux.IntegrationTests/Parity/Component13ParityTests.cs b/tests/LibTmux.IntegrationTests/Parity/Component13ParityTests.cs index 38a1c3c..673afd6 100644 --- a/tests/LibTmux.IntegrationTests/Parity/Component13ParityTests.cs +++ b/tests/LibTmux.IntegrationTests/Parity/Component13ParityTests.cs @@ -322,7 +322,7 @@ private static async Task WaitForClientAsync(Server server, Cancellation { // Attaching is asynchronous on tmux's side, so the client appears a // moment after the process starts. - DateTimeOffset deadline = DateTimeOffset.UtcNow + TimeSpan.FromSeconds(10); + DateTimeOffset deadline = DateTimeOffset.UtcNow + TestBudget.Settle; while (DateTimeOffset.UtcNow < deadline) { IReadOnlyList clients = await server.GetClientsAsync(token); @@ -342,7 +342,7 @@ private static async Task WaitForClientCountAsync( int expected, CancellationToken token) { - DateTimeOffset deadline = DateTimeOffset.UtcNow + TimeSpan.FromSeconds(10); + DateTimeOffset deadline = DateTimeOffset.UtcNow + TestBudget.Settle; while (DateTimeOffset.UtcNow < deadline) { if ((await server.GetClientsAsync(token)).Count == expected) diff --git a/tests/LibTmux.IntegrationTests/Parity/Component16ParityTests.cs b/tests/LibTmux.IntegrationTests/Parity/Component16ParityTests.cs index e1e1f5d..1f87928 100644 --- a/tests/LibTmux.IntegrationTests/Parity/Component16ParityTests.cs +++ b/tests/LibTmux.IntegrationTests/Parity/Component16ParityTests.cs @@ -398,7 +398,7 @@ private static async Task WaitForOptionAsync( { // A conditional command runs on tmux's own schedule, so the option it // sets appears a moment after the call returns. - DateTimeOffset deadline = DateTimeOffset.UtcNow + TimeSpan.FromSeconds(10); + DateTimeOffset deadline = DateTimeOffset.UtcNow + TestBudget.Settle; string? seen = null; while (DateTimeOffset.UtcNow < deadline) { diff --git a/tests/LibTmux.IntegrationTests/Testing/TestingHelpersTests.cs b/tests/LibTmux.IntegrationTests/Testing/TestingHelpersTests.cs index 3c95ce2..d33e73f 100644 --- a/tests/LibTmux.IntegrationTests/Testing/TestingHelpersTests.cs +++ b/tests/LibTmux.IntegrationTests/Testing/TestingHelpersTests.cs @@ -1,4 +1,5 @@ using System.Runtime.Versioning; +using LibTmux.IntegrationTests.Infrastructure; using LibTmux.IntegrationTests.Transport; using LibTmux.Testing; @@ -76,7 +77,7 @@ public async Task Temporary_hierarchy_is_xunit_independent_and_cleans_up() '\n', await scope.Pane.CaptureAsync(cancellationToken: cancellation)), captured => captured.Contains("hierarchy", StringComparison.Ordinal), - TimeSpan.FromSeconds(10), + TestBudget.Settle, TimeSpan.FromMilliseconds(20), token); Assert.Contains("hierarchy", text, StringComparison.Ordinal); @@ -203,7 +204,7 @@ await Assert.ThrowsAsync( await Assert.ThrowsAnyAsync( () => TmuxWait.UntilAsync( static _ => Task.FromResult(false), - TimeSpan.FromSeconds(10), + TestBudget.Settle, TimeSpan.FromMilliseconds(10), cancellationToken: cancelled.Token)); } diff --git a/tests/LibTmux.IntegrationTests/Utilities/ServerUtilitiesTests.cs b/tests/LibTmux.IntegrationTests/Utilities/ServerUtilitiesTests.cs index 0e86766..b6be524 100644 --- a/tests/LibTmux.IntegrationTests/Utilities/ServerUtilitiesTests.cs +++ b/tests/LibTmux.IntegrationTests/Utilities/ServerUtilitiesTests.cs @@ -593,7 +593,7 @@ private static async Task WaitForOptionAsync( { // A conditional command runs on tmux's own schedule, so the option it // sets appears a moment after the call returns. - DateTimeOffset deadline = DateTimeOffset.UtcNow + TimeSpan.FromSeconds(10); + DateTimeOffset deadline = DateTimeOffset.UtcNow + TestBudget.Settle; string? seen = null; while (DateTimeOffset.UtcNow < deadline) { diff --git a/tests/LibTmux.IntegrationTests/Versioning/VersionParityTests.cs b/tests/LibTmux.IntegrationTests/Versioning/VersionParityTests.cs index caa1601..c34298a 100644 --- a/tests/LibTmux.IntegrationTests/Versioning/VersionParityTests.cs +++ b/tests/LibTmux.IntegrationTests/Versioning/VersionParityTests.cs @@ -1093,7 +1093,7 @@ private static async Task WaitForPaneCommandAsync( RawTmuxTestContext context, params string[] accepted) { - DateTimeOffset deadline = DateTimeOffset.UtcNow + TimeSpan.FromSeconds(10); + DateTimeOffset deadline = DateTimeOffset.UtcNow + TestBudget.Settle; string running = string.Empty; while (DateTimeOffset.UtcNow < deadline) { @@ -1120,7 +1120,7 @@ private static async Task> WaitForPaneAsync( RawTmuxTestContext context, Func, bool> settled) { - DateTimeOffset deadline = DateTimeOffset.UtcNow + TimeSpan.FromSeconds(10); + DateTimeOffset deadline = DateTimeOffset.UtcNow + TestBudget.Settle; IReadOnlyList lines = []; while (DateTimeOffset.UtcNow < deadline) { diff --git a/tests/LibTmux.IntegrationTests/Workspace/WorkspaceBuilderTests.cs b/tests/LibTmux.IntegrationTests/Workspace/WorkspaceBuilderTests.cs index a3b20a3..0c692c0 100644 --- a/tests/LibTmux.IntegrationTests/Workspace/WorkspaceBuilderTests.cs +++ b/tests/LibTmux.IntegrationTests/Workspace/WorkspaceBuilderTests.cs @@ -1,10 +1,11 @@ using System.Runtime.Versioning; +// A namespace segment named Workspace would shadow LibTmux.Workspace for +// every file in the assembly, so this sits at the assembly root instead. +using LibTmux.IntegrationTests.Infrastructure; using LibTmux.IntegrationTests.Transport; using LibTmux.Testing; using LibTmux.Workspace; -// A namespace segment named Workspace would shadow LibTmux.Workspace for -// every file in the assembly, so this sits at the assembly root instead. namespace LibTmux.IntegrationTests; [UnsupportedOSPlatform("windows")] @@ -107,7 +108,7 @@ public async Task A_workspace_file_becomes_a_session() '\n', await shell[0].CaptureAsync(cancellationToken: cancellation)), captured => captured.Contains("command-two", StringComparison.Ordinal), - TimeSpan.FromSeconds(10), + TestBudget.Settle, TimeSpan.FromMilliseconds(20), token); Assert.Contains("command-one", text, StringComparison.Ordinal); @@ -309,7 +310,8 @@ public async Task Session_options_launch_the_real_first_pane() WorkspaceResult result = await new WorkspaceBuilder( scope.Server, - paneReadiness: PaneReadiness.Always) + Readiness, + PaneReadiness.Always) .BuildAsync(workspace, token); string firstInput = await TmuxWait.UntilAsync( @@ -357,13 +359,13 @@ public async Task First_pane_directory_controls_window_creation() IReadOnlyList first = await TmuxWait.UntilAsync( cancellation => panes[0].CaptureAsync(cancellationToken: cancellation), lines => lines.Contains("/usr", StringComparer.Ordinal), - TimeSpan.FromSeconds(10), + TestBudget.Settle, TimeSpan.FromMilliseconds(20), token); IReadOnlyList second = await TmuxWait.UntilAsync( cancellation => panes[1].CaptureAsync(cancellationToken: cancellation), lines => lines.Contains("/etc", StringComparer.Ordinal), - TimeSpan.FromSeconds(10), + TestBudget.Settle, TimeSpan.FromMilliseconds(20), token); From cfc19849d31adbf62701d6d385847d000911d15f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 17:48:12 -0500 Subject: [PATCH 107/129] Psmux(fix[trust]): Read the audited build markers from the pin why: An independent review called the marker scan redundant, because the SHA-256 already proves the bytes are the audited binary and ValidateExpectedBinarySha256 refuses any other hash. That is right about the public path, so the scan cannot fail through it. It is defence in depth on a security-sensitive path reached by an internal method, so it stays. What was wrong is where the markers came from. "66cf613" and "2026-08-18" were byte literals in the verifier, a third spelling of a build identity already held twice in PsmuxCompatibility, with nothing tying them together. Moving the pin would have left the verifier looking for the previous build. The analyzer catches a changed public constant, because its value is recorded in the baseline. It cannot see that the short commit is a prefix of the long one, which is the mistake a maintainer makes while updating both. what: - Name the short commit and the build date once, and compose the banner from them so the three cannot disagree - Scan for the markers the pin names rather than for literals - Cover the short commit and date agreeing with the commit and the banner --- src/LibTmux/Internal/PsmuxBinaryTrust.cs | 13 +++++++++++-- src/LibTmux/Internal/PsmuxCompatibility.cs | 14 +++++++++++++- .../Connection/PsmuxConnectionTests.cs | 19 +++++++++++++++++++ 3 files changed, 43 insertions(+), 3 deletions(-) diff --git a/src/LibTmux/Internal/PsmuxBinaryTrust.cs b/src/LibTmux/Internal/PsmuxBinaryTrust.cs index 0fc116d..97f9744 100644 --- a/src/LibTmux/Internal/PsmuxBinaryTrust.cs +++ b/src/LibTmux/Internal/PsmuxBinaryTrust.cs @@ -8,6 +8,15 @@ internal static class PsmuxBinaryTrust private const int BufferSize = 81920; private const long MaximumBinaryBytes = 128L * 1024 * 1024; + // The hash already proves the bytes are the audited build. These markers + // are the second reading of the same fact, kept because the caller reaches + // this through an internal method, and read from the pin so they cannot + // name a build the rest of the preview does not accept. + private static readonly byte[] CommitMarker = + System.Text.Encoding.ASCII.GetBytes(PsmuxCompatibility.SupportedShortCommit); + private static readonly byte[] BuildDateMarker = + System.Text.Encoding.ASCII.GetBytes(PsmuxCompatibility.SupportedBuildDate); + /// Verifies the executable when, and only when, the preview is in use. internal static ValueTask VerifyIfPreviewAsync( ServerConnectionOptions options, @@ -59,8 +68,8 @@ private static async Task VerifyCoreAsync( BufferSize, FileOptions.Asynchronous | FileOptions.SequentialScan); using IncrementalHash hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); - var commit = new MarkerMatcher("66cf613"u8); - var date = new MarkerMatcher("2026-08-18"u8); + var commit = new MarkerMatcher(CommitMarker); + var date = new MarkerMatcher(BuildDateMarker); long total = 0; while (true) { diff --git a/src/LibTmux/Internal/PsmuxCompatibility.cs b/src/LibTmux/Internal/PsmuxCompatibility.cs index ce140f0..d5451cc 100644 --- a/src/LibTmux/Internal/PsmuxCompatibility.cs +++ b/src/LibTmux/Internal/PsmuxCompatibility.cs @@ -8,8 +8,20 @@ internal static class PsmuxCompatibility "66cf61354c473b35d4f0c06c57384fc46d61ffdb"; internal const string SupportedBinarySha256 = "54e5c54db259218348f966b5d0d0b5153fdef6350074855ea9ce627d20537b0d"; + + /// The abbreviated commit the build stamps into itself. + internal const string SupportedShortCommit = "66cf613"; + + /// The date the build stamps into itself. + internal const string SupportedBuildDate = "2026-08-18"; + + /// What the accepted build reports for its second banner line. + /// + /// Composed rather than spelled out, so the banner, the markers the binary + /// is scanned for, and the commit cannot drift apart when the pin moves. + /// internal const string SupportedImplementationLine = - "psmux 3.3.8 (66cf613 2026-08-18)"; + $"psmux {SupportedVersion} ({SupportedShortCommit} {SupportedBuildDate})"; internal static string ValidateExpectedBinarySha256(string value, string parameterName) { diff --git a/tests/LibTmux.UnitTests/Connection/PsmuxConnectionTests.cs b/tests/LibTmux.UnitTests/Connection/PsmuxConnectionTests.cs index 9c224b2..8581e73 100644 --- a/tests/LibTmux.UnitTests/Connection/PsmuxConnectionTests.cs +++ b/tests/LibTmux.UnitTests/Connection/PsmuxConnectionTests.cs @@ -120,6 +120,25 @@ public void Psmux_endpoint_identity_includes_the_frozen_data_directory() Assert.NotEqual(first, other); } + [Fact] + public void The_pinned_build_identity_agrees_with_itself() + { + // The banner, the markers the binary is scanned for and the commit are + // one fact spelled three ways. Moving the pin must move all of them. + Assert.StartsWith( + PsmuxCompatibility.SupportedShortCommit, + PsmuxCompatibility.SupportedCommit, + StringComparison.Ordinal); + Assert.Contains( + PsmuxCompatibility.SupportedShortCommit, + PsmuxCompatibility.SupportedImplementationLine, + StringComparison.Ordinal); + Assert.Contains( + PsmuxCompatibility.SupportedBuildDate, + PsmuxCompatibility.SupportedImplementationLine, + StringComparison.Ordinal); + } + [Fact] public async Task Binary_trust_rejects_missing_build_markers() { From 66224b35e40ef95e317f82b38dc008394323b542 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 17:49:15 -0500 Subject: [PATCH 108/129] Async(fix[context]): Configure the await on a using declaration too why: The audit behind "ConfigureAwait(false) on every await" excluded await using by construction, so it never looked at the one form that can carry it and be missed. An independent review counted 303 of 304 in LibTmux; across every shipped project it was eight sites. TmuxProcessTransport already writes await using (process.ConfigureAwait (false)), so the pattern was established and the rest simply did not follow it. A using declaration cannot take the call inline, so the disposable is named first and configured on the next line. The three in the MCP tool's entry point stay as they are: a console Main has no context to return to, and the comment says so, so the next sweep does not read them as an omission. what: - Configure the await on the five library await using declarations - Say why the entry point's three do not --- src/LibTmux.Mcp/Program.cs | 3 +++ src/LibTmux.Mcp/Tools/ReadTools.Wait.cs | 4 +++- src/LibTmux.Mcp/Tools/WriteTools.Jobs.cs | 4 +++- src/LibTmux.Mcp/Tools/WriteTools.Run.cs | 4 +++- src/LibTmux.Mcp/Tools/WriteTools.Wait.cs | 4 +++- src/LibTmux/Internal/PsmuxBinaryTrust.cs | 4 +++- 6 files changed, 18 insertions(+), 5 deletions(-) diff --git a/src/LibTmux.Mcp/Program.cs b/src/LibTmux.Mcp/Program.cs index 330d6e6..5161360 100644 --- a/src/LibTmux.Mcp/Program.cs +++ b/src/LibTmux.Mcp/Program.cs @@ -44,6 +44,9 @@ private static async Task Main(string[] args) logging.SetMinimumLevel(LogLevel.Warning); }); + // The library configures every await away from a caller's context. This + // is the entry point rather than the library: there is no context here + // to return to, so these say nothing about it. await using ServiceProvider provider = BuildProvider(services, args); ILoggerFactory logging = provider.GetRequiredService(); diff --git a/src/LibTmux.Mcp/Tools/ReadTools.Wait.cs b/src/LibTmux.Mcp/Tools/ReadTools.Wait.cs index b05d2cc..a09468d 100644 --- a/src/LibTmux.Mcp/Tools/ReadTools.Wait.cs +++ b/src/LibTmux.Mcp/Tools/ReadTools.Wait.cs @@ -1,5 +1,6 @@ using System.ComponentModel; using System.Diagnostics; +using System.Runtime.CompilerServices; using System.Runtime.Versioning; using System.Text; using System.Text.RegularExpressions; @@ -73,8 +74,9 @@ public async Task WaitForTextAsync( // The lease turns this from a poll into a sleep: tmux reports the // pane's output as it happens, and the loop below wakes on it. - await using IAsyncDisposable lease = await _activity.WatchAsync(pane, cancellationToken) + IAsyncDisposable lease = await _activity.WatchAsync(pane, cancellationToken) .ConfigureAwait(false); + await using ConfiguredAsyncDisposable _ = lease.ConfigureAwait(false); PaneRead first = await PaneReader.ReadVisibleAsync(pane, null, cancellationToken) .ConfigureAwait(false); diff --git a/src/LibTmux.Mcp/Tools/WriteTools.Jobs.cs b/src/LibTmux.Mcp/Tools/WriteTools.Jobs.cs index 09f7e3b..4cd13e0 100644 --- a/src/LibTmux.Mcp/Tools/WriteTools.Jobs.cs +++ b/src/LibTmux.Mcp/Tools/WriteTools.Jobs.cs @@ -1,4 +1,5 @@ using System.ComponentModel; +using System.Runtime.CompilerServices; using System.Runtime.Versioning; using ModelContextProtocol; using ModelContextProtocol.Server; @@ -88,9 +89,10 @@ public async Task JobAsync( if (waitSeconds is double seconds && job.State == JobState.Running) { TimeSpan budget = _policy.EffectiveTimeout(TimeSpan.FromSeconds(seconds)); - await using IAsyncDisposable lease = await _activity + IAsyncDisposable lease = await _activity .WatchAsync(pane, cancellationToken) .ConfigureAwait(false); + await using ConfiguredAsyncDisposable _ = lease.ConfigureAwait(false); await WaitForFinishAsync( stored, pane, diff --git a/src/LibTmux.Mcp/Tools/WriteTools.Run.cs b/src/LibTmux.Mcp/Tools/WriteTools.Run.cs index b315567..5a8995b 100644 --- a/src/LibTmux.Mcp/Tools/WriteTools.Run.cs +++ b/src/LibTmux.Mcp/Tools/WriteTools.Run.cs @@ -1,6 +1,7 @@ using System.ComponentModel; using System.Diagnostics; using System.Globalization; +using System.Runtime.CompilerServices; using System.Runtime.Versioning; using LibTmux.Internal; using ModelContextProtocol; @@ -317,7 +318,8 @@ internal static async Task AwaitChannelAsync( // A command signals its channel once. Cancelling a waiting client to // enforce the budget leaves tmux holding the registration, and that // registration takes the signal instead of the next caller. - await using TmuxWaitChannel wait = server.OpenWaitChannel(channel); + TmuxWaitChannel wait = server.OpenWaitChannel(channel); + await using ConfiguredAsyncDisposable _ = wait.ConfigureAwait(false); if (!await wait.WaitAsync(budget, cancellationToken).ConfigureAwait(false)) { await wait.DisposeAsync().ConfigureAwait(false); diff --git a/src/LibTmux.Mcp/Tools/WriteTools.Wait.cs b/src/LibTmux.Mcp/Tools/WriteTools.Wait.cs index 02388ba..d5c56a4 100644 --- a/src/LibTmux.Mcp/Tools/WriteTools.Wait.cs +++ b/src/LibTmux.Mcp/Tools/WriteTools.Wait.cs @@ -1,4 +1,5 @@ using System.ComponentModel; +using System.Runtime.CompilerServices; using System.Runtime.Versioning; using System.Text; using ModelContextProtocol; @@ -46,7 +47,8 @@ public async Task WaitForChannelAsync( TimeSpan budget = _policy.EffectiveTimeout( timeoutSeconds is double seconds ? TimeSpan.FromSeconds(seconds) : null); - await using TmuxWaitChannel wait = server.OpenWaitChannel(channel); + TmuxWaitChannel wait = server.OpenWaitChannel(channel); + await using ConfiguredAsyncDisposable _ = wait.ConfigureAwait(false); if (!await wait.WaitAsync(budget, cancellationToken).ConfigureAwait(false)) { // Withdraw before answering. A signal landing as the attempt ended diff --git a/src/LibTmux/Internal/PsmuxBinaryTrust.cs b/src/LibTmux/Internal/PsmuxBinaryTrust.cs index 97f9744..945dbcc 100644 --- a/src/LibTmux/Internal/PsmuxBinaryTrust.cs +++ b/src/LibTmux/Internal/PsmuxBinaryTrust.cs @@ -1,4 +1,5 @@ using System.Buffers; +using System.Runtime.CompilerServices; using System.Security.Cryptography; namespace LibTmux.Internal; @@ -60,13 +61,14 @@ private static async Task VerifyCoreAsync( byte[] buffer = ArrayPool.Shared.Rent(BufferSize); try { - await using var stream = new FileStream( + var stream = new FileStream( path, FileMode.Open, FileAccess.Read, FileShare.Read, BufferSize, FileOptions.Asynchronous | FileOptions.SequentialScan); + await using ConfiguredAsyncDisposable _ = stream.ConfigureAwait(false); using IncrementalHash hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); var commit = new MarkerMatcher(CommitMarker); var date = new MarkerMatcher(BuildDateMarker); From 0b7f76279930c4135916735096f4c34cc6468451 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 17:53:12 -0500 Subject: [PATCH 109/129] Chaining(fix[generation]): Pin an option and hook command too why: The commit that pinned every command built from an entity enumerated the overloads taking a Pane, Window or Session and stopped there. An option or hook table is reached through one of those entities and carries its identifier as the same plain text, so nine more builders in TmuxChaining.Options.cs and TmuxChaining.Hooks.cs left RequiredGeneration null. A chain of them reached the unguarded dispatcher and set or read against whatever the replacement server gave that identifier to. The entity methods were never affected: TmuxOptions.SetAsync and its siblings dispatch through the entity dispatcher, which validates the generation on every command. Only the batching surface was open. The twelve builders still carrying no generation take either no receiver or a Server, and none of them names an identifier this library read from a handle, so there is nothing for a restart to reassign. Server.Chain now says that rather than "built from an entity", which did not distinguish them. what: - Carry the owning generation on TmuxOptions and TmuxHooks - Name it in all nine option and hook command builders - Say what actually decides whether a chained command needs a generation - Extend the restart test to an option and a hook command --- src/LibTmux/Chaining/TmuxChaining.Hooks.cs | 29 +++++++++++++++---- src/LibTmux/Chaining/TmuxChaining.Options.cs | 20 ++++++++++--- src/LibTmux/Hooks/TmuxHooks.cs | 14 ++++++++- src/LibTmux/Options/TmuxOptions.cs | 11 ++++++- src/LibTmux/Pane.Scopes.cs | 6 ++-- src/LibTmux/Server.Chaining.cs | 7 +++-- src/LibTmux/Server.Scopes.cs | 6 ++-- src/LibTmux/Session.Scopes.cs | 6 ++-- src/LibTmux/Window.Scopes.cs | 6 ++-- .../Chaining/ChainGenerationTests.cs | 3 ++ 10 files changed, 87 insertions(+), 21 deletions(-) diff --git a/src/LibTmux/Chaining/TmuxChaining.Hooks.cs b/src/LibTmux/Chaining/TmuxChaining.Hooks.cs index 5a7ad3f..8b8400b 100644 --- a/src/LibTmux/Chaining/TmuxChaining.Hooks.cs +++ b/src/LibTmux/Chaining/TmuxChaining.Hooks.cs @@ -15,7 +15,10 @@ public static TmuxCommand ToCommand(this SetHookRequest request, TmuxHooks hooks { ArgumentNullException.ThrowIfNull(request); ArgumentNullException.ThrowIfNull(hooks); - return Command([.. hooks.BuildSetArguments(request)]); + return Command([.. hooks.BuildSetArguments(request)]) with + { + RequiredGeneration = hooks.Generation, + }; } /// Runs a hook request on its own. @@ -52,7 +55,10 @@ public static TmuxCommand ToCommand(this ListHooksRequest request, TmuxHooks hoo { ArgumentNullException.ThrowIfNull(request); ArgumentNullException.ThrowIfNull(hooks); - return Command([.. hooks.BuildListArguments(request)]); + return Command([.. hooks.BuildListArguments(request)]) with + { + RequiredGeneration = hooks.Generation, + }; } /// Runs a hook listing on its own. @@ -89,7 +95,10 @@ public static TmuxCommand ToRunCommand(this HookRequest request, TmuxHooks hooks { ArgumentNullException.ThrowIfNull(request); ArgumentNullException.ThrowIfNull(hooks); - return Command([.. hooks.BuildRunArguments(request)]); + return Command([.. hooks.BuildRunArguments(request)]) with + { + RequiredGeneration = hooks.Generation, + }; } /// Returns removing a hook as one tmux command. @@ -102,7 +111,10 @@ public static TmuxCommand ToUnsetCommand(this HookRequest request, TmuxHooks hoo { ArgumentNullException.ThrowIfNull(request); ArgumentNullException.ThrowIfNull(hooks); - return Command([.. hooks.BuildUnsetArguments(request)]); + return Command([.. hooks.BuildUnsetArguments(request)]) with + { + RequiredGeneration = hooks.Generation, + }; } /// Runs a hook on its own. @@ -141,7 +153,14 @@ public static IReadOnlyList ToCommands( { ArgumentNullException.ThrowIfNull(request); ArgumentNullException.ThrowIfNull(hooks); - return [.. hooks.BuildSetAllArguments(request).Select(arguments => Command([.. arguments]))]; + return + [ + .. hooks.BuildSetAllArguments(request) + .Select(arguments => Command([.. arguments]) with + { + RequiredGeneration = hooks.Generation, + }), + ]; } /// Runs a multi-entry hook request in one invocation. diff --git a/src/LibTmux/Chaining/TmuxChaining.Options.cs b/src/LibTmux/Chaining/TmuxChaining.Options.cs index 1f1af97..d8fdfb4 100644 --- a/src/LibTmux/Chaining/TmuxChaining.Options.cs +++ b/src/LibTmux/Chaining/TmuxChaining.Options.cs @@ -21,7 +21,10 @@ public static TmuxCommand ToCommand(this SetOptionRequest request, TmuxOptions o { ArgumentNullException.ThrowIfNull(request); ArgumentNullException.ThrowIfNull(options); - return Command([.. options.BuildSetArguments(request)]); + return Command([.. options.BuildSetArguments(request)]) with + { + RequiredGeneration = options.Generation, + }; } /// Runs an option request on its own. @@ -53,7 +56,10 @@ public static TmuxCommand ToCommand(this UnsetOptionRequest request, TmuxOptions { ArgumentNullException.ThrowIfNull(request); ArgumentNullException.ThrowIfNull(options); - return Command([.. options.BuildUnsetArguments(request)]); + return Command([.. options.BuildUnsetArguments(request)]) with + { + RequiredGeneration = options.Generation, + }; } /// Runs an unset request on its own. @@ -91,7 +97,10 @@ public static TmuxCommand ToCommand(this GetOptionRequest request, TmuxOptions o { ArgumentNullException.ThrowIfNull(request); ArgumentNullException.ThrowIfNull(options); - return Command([.. options.BuildGetArguments(request)]); + return Command([.. options.BuildGetArguments(request)]) with + { + RequiredGeneration = options.Generation, + }; } /// Runs a named option read on its own. @@ -127,7 +136,10 @@ public static TmuxCommand ToCommand(this GetOptionsRequest request, TmuxOptions { ArgumentNullException.ThrowIfNull(request); ArgumentNullException.ThrowIfNull(options); - return Command([.. options.BuildGetAllArguments(request)]); + return Command([.. options.BuildGetAllArguments(request)]) with + { + RequiredGeneration = options.Generation, + }; } /// Runs a whole-scope option read on its own. diff --git a/src/LibTmux/Hooks/TmuxHooks.cs b/src/LibTmux/Hooks/TmuxHooks.cs index 70ce5ea..7840456 100644 --- a/src/LibTmux/Hooks/TmuxHooks.cs +++ b/src/LibTmux/Hooks/TmuxHooks.cs @@ -63,14 +63,26 @@ public sealed class TmuxHooks private readonly TmuxCommandDispatcher _dispatcher; private readonly string? _target; - internal TmuxHooks(TmuxCommandDispatcher dispatcher, OptionScope scope, string? target) + internal TmuxHooks( + TmuxCommandDispatcher dispatcher, + OptionScope scope, + string? target, + ServerGeneration? generation = null) { ArgumentNullException.ThrowIfNull(dispatcher); _dispatcher = dispatcher; _target = target; Scope = scope; + Generation = generation; } + /// Gets the server generation this table was reached through. + /// + /// A batched hook command carries the target as plain text, so it needs the + /// generation for the same reason a batched pane command does. + /// + internal ServerGeneration? Generation { get; } + /// Gets the scope these hooks are read and written in by default. public OptionScope Scope { get; } diff --git a/src/LibTmux/Options/TmuxOptions.cs b/src/LibTmux/Options/TmuxOptions.cs index 7f209d6..323ed3f 100644 --- a/src/LibTmux/Options/TmuxOptions.cs +++ b/src/LibTmux/Options/TmuxOptions.cs @@ -27,15 +27,24 @@ internal TmuxOptions( TmuxCommandDispatcher dispatcher, OptionScope scope, string? target, - bool doubleEscapedDollar = false) + bool doubleEscapedDollar = false, + ServerGeneration? generation = null) { ArgumentNullException.ThrowIfNull(dispatcher); _dispatcher = dispatcher; _target = target; _doubleEscapedDollar = doubleEscapedDollar; Scope = scope; + Generation = generation; } + /// Gets the server generation this table was reached through. + /// + /// A batched option command carries the target as plain text, so it needs + /// the generation for the same reason a batched pane command does. + /// + internal ServerGeneration? Generation { get; } + /// Gets the scope these options are read and written in by default. public OptionScope Scope { get; } diff --git a/src/LibTmux/Pane.Scopes.cs b/src/LibTmux/Pane.Scopes.cs index 632f525..8220412 100644 --- a/src/LibTmux/Pane.Scopes.cs +++ b/src/LibTmux/Pane.Scopes.cs @@ -13,7 +13,8 @@ public sealed partial class Pane _commandDispatcher, OptionScope.Pane, _id.ToString(), - TmuxOptions.DoubleEscapesDollar(_owner)); + TmuxOptions.DoubleEscapesDollar(_owner), + _generation); private TmuxHooks? _hooks; @@ -22,5 +23,6 @@ public sealed partial class Pane public TmuxHooks Hooks => _hooks ??= new TmuxHooks( _commandDispatcher, OptionScope.Pane, - _id.ToString()); + _id.ToString(), + _generation); } diff --git a/src/LibTmux/Server.Chaining.cs b/src/LibTmux/Server.Chaining.cs index 65d83e8..8419a1e 100644 --- a/src/LibTmux/Server.Chaining.cs +++ b/src/LibTmux/Server.Chaining.cs @@ -19,8 +19,11 @@ public TmuxChain Chain() { TmuxConnection connection = _connection ?? throw new InvalidOperationException("The server handle has no connection."); - // Starts with no generation guard: only a command built from an - // entity supplies one for the dispatcher to check. + // Starts with no generation guard. A command supplies one when it + // carries an identifier this library read from a handle -- a pane, a + // window, a session, or one of their option and hook tables -- because + // tmux gives that identifier to something else after a restart. A + // server-wide command names no such identifier and needs none. return new TmuxChain( connection.ServerDispatcher, [], diff --git a/src/LibTmux/Server.Scopes.cs b/src/LibTmux/Server.Scopes.cs index ab90fc8..cfc6305 100644 --- a/src/LibTmux/Server.Scopes.cs +++ b/src/LibTmux/Server.Scopes.cs @@ -17,7 +17,8 @@ public sealed partial class Server _commandDispatcher, OptionScope.Server, null, - TmuxOptions.DoubleEscapesDollar(this)); + TmuxOptions.DoubleEscapesDollar(this), + Generation); private TmuxHooks? _hooks; @@ -30,7 +31,8 @@ public sealed partial class Server public TmuxHooks Hooks => _hooks ??= new TmuxHooks( _commandDispatcher, OptionScope.Server, - null); + null, + Generation); private TmuxEnvironment? _environment; diff --git a/src/LibTmux/Session.Scopes.cs b/src/LibTmux/Session.Scopes.cs index bd4c6b9..4962712 100644 --- a/src/LibTmux/Session.Scopes.cs +++ b/src/LibTmux/Session.Scopes.cs @@ -13,7 +13,8 @@ public sealed partial class Session _commandDispatcher, OptionScope.Session, _id.ToString(), - TmuxOptions.DoubleEscapesDollar(_owner)); + TmuxOptions.DoubleEscapesDollar(_owner), + _generation); private TmuxHooks? _hooks; @@ -22,7 +23,8 @@ public sealed partial class Session public TmuxHooks Hooks => _hooks ??= new TmuxHooks( _commandDispatcher, OptionScope.Session, - _id.ToString()); + _id.ToString(), + _generation); private TmuxEnvironment? _environment; diff --git a/src/LibTmux/Window.Scopes.cs b/src/LibTmux/Window.Scopes.cs index 0e737d7..aee2267 100644 --- a/src/LibTmux/Window.Scopes.cs +++ b/src/LibTmux/Window.Scopes.cs @@ -18,7 +18,8 @@ public sealed partial class Window _commandDispatcher, OptionScope.Window, _id.ToString(), - TmuxOptions.DoubleEscapesDollar(_owner)); + TmuxOptions.DoubleEscapesDollar(_owner), + _generation); private TmuxHooks? _hooks; @@ -27,5 +28,6 @@ public sealed partial class Window public TmuxHooks Hooks => _hooks ??= new TmuxHooks( _commandDispatcher, OptionScope.Window, - _id.ToString()); + _id.ToString(), + _generation); } diff --git a/tests/LibTmux.IntegrationTests/Chaining/ChainGenerationTests.cs b/tests/LibTmux.IntegrationTests/Chaining/ChainGenerationTests.cs index 0c101e9..b2ed29f 100644 --- a/tests/LibTmux.IntegrationTests/Chaining/ChainGenerationTests.cs +++ b/tests/LibTmux.IntegrationTests/Chaining/ChainGenerationTests.cs @@ -52,6 +52,9 @@ public async Task A_chained_entity_command_is_refused_after_the_server_restarts( new SelectPaneRequest().ToCommand(pane), new SelectLayoutRequest("tiled").ToCommand(window), new NewWindowRequest(name: "stale").ToCommand(session), + new SetOptionRequest("@stale", "1").ToCommand(pane.Options), + new SetHookRequest("after-new-window", "display-message x") + .ToCommand(session.Hooks), ]; foreach (TmuxCommand command in stale) From a1f9914ef12ef176327ed0f02db6fab09f2ad738 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 17:54:37 -0500 Subject: [PATCH 110/129] Requests(fix[formats]): Say that an option and hook name is a format why: The previous sweep asked which tmux commands expand an argument and answered for titles, names of sessions and windows, start directories and if-shell. It missed the arguments that name a thing rather than describe it. Confirmed on a live server: set-option '@myopt#{session_name}' hello stores the option as @myoptprobe3, and new-session -n '#{session_id}-w' names the window from the expansion. The option case is the sharp one. A caller building a name from data sets one option and reads another as soon as the surrounding session changes, with nothing to see. cmd-server-access.c expands its user argument the same way. what: - Document the expansion on the option, hook and first-window names, and on the users a server-access request allows or denies - Drop the two assertions that could not fail: the banner is interpolated from the short commit and the date, so it agrees with them by construction, and only the prefix check does work --- src/LibTmux/Requests/GetOptionRequest.cs | 4 ++++ src/LibTmux/Requests/HookRequest.cs | 4 ++++ src/LibTmux/Requests/NewSessionRequest.cs | 4 ++++ src/LibTmux/Requests/SetHookRequest.cs | 4 ++++ src/LibTmux/Requests/SetHooksRequest.cs | 4 ++++ src/LibTmux/Requests/SetOptionRequest.cs | 4 ++++ src/LibTmux/Requests/UnsetOptionRequest.cs | 4 ++++ .../Connection/PsmuxConnectionTests.cs | 13 +++---------- 8 files changed, 31 insertions(+), 10 deletions(-) diff --git a/src/LibTmux/Requests/GetOptionRequest.cs b/src/LibTmux/Requests/GetOptionRequest.cs index f9feb69..b1aad78 100644 --- a/src/LibTmux/Requests/GetOptionRequest.cs +++ b/src/LibTmux/Requests/GetOptionRequest.cs @@ -28,6 +28,10 @@ public GetOptionRequest( } /// Gets the option to read. + /// + /// tmux expands it as a format before it names anything, so a # in + /// it does not survive verbatim. + /// public string Name { get; } /// Gets the scope to read in, or null for the owner's own. diff --git a/src/LibTmux/Requests/HookRequest.cs b/src/LibTmux/Requests/HookRequest.cs index 36d2f59..5a1261e 100644 --- a/src/LibTmux/Requests/HookRequest.cs +++ b/src/LibTmux/Requests/HookRequest.cs @@ -16,6 +16,10 @@ public HookRequest(string name, OptionScope? scope = null, bool global = false) } /// Gets the hook name. + /// + /// tmux expands it as a format before it names anything, so a # in + /// it does not survive verbatim. + /// public string Name { get; } /// Gets the scope to reach it in, or null for the owner's own. diff --git a/src/LibTmux/Requests/NewSessionRequest.cs b/src/LibTmux/Requests/NewSessionRequest.cs index 1ff331a..c0201dc 100644 --- a/src/LibTmux/Requests/NewSessionRequest.cs +++ b/src/LibTmux/Requests/NewSessionRequest.cs @@ -76,6 +76,10 @@ public NewSessionRequest( public string? StartDirectory { get; } /// Gets the name of the first window. + /// + /// tmux expands it as a format before it names anything, so a # in + /// it does not survive verbatim. + /// public string? WindowName { get; } /// Gets the command the first pane runs. diff --git a/src/LibTmux/Requests/SetHookRequest.cs b/src/LibTmux/Requests/SetHookRequest.cs index 94c0e8f..d73a41e 100644 --- a/src/LibTmux/Requests/SetHookRequest.cs +++ b/src/LibTmux/Requests/SetHookRequest.cs @@ -32,6 +32,10 @@ public SetHookRequest( } /// Gets the hook name, optionally with an array index. + /// + /// tmux expands it as a format before it names anything, so a # in + /// it does not survive verbatim. + /// public string Name { get; } /// Gets the tmux command to run when the hook fires. diff --git a/src/LibTmux/Requests/SetHooksRequest.cs b/src/LibTmux/Requests/SetHooksRequest.cs index c896691..501c13f 100644 --- a/src/LibTmux/Requests/SetHooksRequest.cs +++ b/src/LibTmux/Requests/SetHooksRequest.cs @@ -56,6 +56,10 @@ public SetHooksRequest( } /// Gets the hook name, without an index. + /// + /// tmux expands it as a format before it names anything, so a # in + /// it does not survive verbatim. + /// public string Name { get; } /// Gets the command to place at each index. diff --git a/src/LibTmux/Requests/SetOptionRequest.cs b/src/LibTmux/Requests/SetOptionRequest.cs index cd6228e..f7f9100 100644 --- a/src/LibTmux/Requests/SetOptionRequest.cs +++ b/src/LibTmux/Requests/SetOptionRequest.cs @@ -35,6 +35,10 @@ public SetOptionRequest( } /// Gets the option to set, optionally with an array index. + /// + /// tmux expands it as a format before it names anything, so a # in + /// it does not survive verbatim. + /// public string Name { get; } /// Gets the value to store. diff --git a/src/LibTmux/Requests/UnsetOptionRequest.cs b/src/LibTmux/Requests/UnsetOptionRequest.cs index 15ea87f..d7743d2 100644 --- a/src/LibTmux/Requests/UnsetOptionRequest.cs +++ b/src/LibTmux/Requests/UnsetOptionRequest.cs @@ -25,6 +25,10 @@ public UnsetOptionRequest( } /// Gets the option to unset, optionally with an array index. + /// + /// tmux expands it as a format before it names anything, so a # in + /// it does not survive verbatim. + /// public string Name { get; } /// Gets the scope to unset in, or null for the owner's own. diff --git a/tests/LibTmux.UnitTests/Connection/PsmuxConnectionTests.cs b/tests/LibTmux.UnitTests/Connection/PsmuxConnectionTests.cs index 8581e73..a9127d7 100644 --- a/tests/LibTmux.UnitTests/Connection/PsmuxConnectionTests.cs +++ b/tests/LibTmux.UnitTests/Connection/PsmuxConnectionTests.cs @@ -123,20 +123,13 @@ public void Psmux_endpoint_identity_includes_the_frozen_data_directory() [Fact] public void The_pinned_build_identity_agrees_with_itself() { - // The banner, the markers the binary is scanned for and the commit are - // one fact spelled three ways. Moving the pin must move all of them. + // The banner is interpolated from the short commit and the date, so it + // agrees with them by construction. That the short commit is the long + // one's prefix is the half nothing else checks. Assert.StartsWith( PsmuxCompatibility.SupportedShortCommit, PsmuxCompatibility.SupportedCommit, StringComparison.Ordinal); - Assert.Contains( - PsmuxCompatibility.SupportedShortCommit, - PsmuxCompatibility.SupportedImplementationLine, - StringComparison.Ordinal); - Assert.Contains( - PsmuxCompatibility.SupportedBuildDate, - PsmuxCompatibility.SupportedImplementationLine, - StringComparison.Ordinal); } [Fact] From 1238f2b3231df17151867f26f819624e59abefbb Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 22:54:21 -0500 Subject: [PATCH 111/129] Materialization(fix[3.2a]): Avoid unsafe formats why: tmux 3.2a can terminate its server while expanding selected format callbacks for a missing target. Direct point lookups therefore made an ordinary stale handle destructive. what: - List and match records before formatting a missing 3.2a target - Preserve session context while resolving linked windows and panes - Record and test the capability boundary in the parity ledger --- docs/parity/version-deltas.json | 11 ++++ eng/parity/generate_inventory.py | 2 + eng/parity/reconcile_versions.py | 2 + eng/parity/tests/test_ledger.py | 1 + eng/parity/tests/test_reconcile_versions.py | 3 ++ .../TmuxMaterializationQuery.cs | 51 +++++++++++++++++++ src/LibTmux/Targets/TmuxTarget.cs | 8 +-- src/LibTmux/Versioning/TmuxCapabilities.cs | 1 + .../Versioning/VersionParityTests.cs | 31 +++++++++++ .../Versioning/TmuxCapabilitiesTests.cs | 2 + 10 files changed, 109 insertions(+), 3 deletions(-) diff --git a/docs/parity/version-deltas.json b/docs/parity/version-deltas.json index e500314..3dd38c0 100644 --- a/docs/parity/version-deltas.json +++ b/docs/parity/version-deltas.json @@ -180,6 +180,17 @@ "3.7b": "https://github.com/tmux/tmux/tree/3.7b" } }, + { + "capability": "missing_target_format_safety", + "evidenceStatus": "pending", + "introducedIn": "3.3", + "namedRealServerTest": "tests/LibTmux.IntegrationTests/Versioning/VersionParityTests.cs::MissingTargetFormatSafety", + "removedIn": "unknown", + "tmuxSourceEndpoints": { + "3.2a": "https://github.com/tmux/tmux/tree/3.2a", + "3.7b": "https://github.com/tmux/tmux/tree/3.7b" + } + }, { "capability": "option_dollar_double_escape", "evidence": { diff --git a/eng/parity/generate_inventory.py b/eng/parity/generate_inventory.py index d49ac6d..058d162 100644 --- a/eng/parity/generate_inventory.py +++ b/eng/parity/generate_inventory.py @@ -791,12 +791,14 @@ def version_deltas() -> dict[str, t.Any]: "byte_length_framing", "control_notifications", "format_fields_and_operators", + "missing_target_format_safety", "option_dollar_double_escape", "semicolon_grouping", ) # Most of what the protocol reads is true of every supported tmux, so # a capability names its bounds only when it holds for some of them. capability_bounds = { + "missing_target_format_safety": ("3.3", "unknown"), "option_dollar_double_escape": ("3.4", "3.5"), } capabilities: list[dict[str, t.Any]] = [ diff --git a/eng/parity/reconcile_versions.py b/eng/parity/reconcile_versions.py index 496b787..52d2588 100644 --- a/eng/parity/reconcile_versions.py +++ b/eng/parity/reconcile_versions.py @@ -31,6 +31,7 @@ "byte_length_framing", "control_notifications", "format_fields_and_operators", + "missing_target_format_safety", "option_dollar_double_escape", "semicolon_grouping", } @@ -100,6 +101,7 @@ "display_popup_3_3_options": "DisplayPopup33Options", "display_popup_3_6_key_policy": "DisplayPopup36KeyPolicy", "format_fields_and_operators": "FormatFieldsAndOperators", + "missing_target_format_safety": "MissingTargetFormatSafety", "hook_scope_pane_window_set": "HookScopePaneWindowSet", "hook_scope_pane_window_show": "HookScopePaneWindowShow", "kill_session_group": "KillSessionGroup", diff --git a/eng/parity/tests/test_ledger.py b/eng/parity/tests/test_ledger.py index 64e4854..2692ab9 100644 --- a/eng/parity/tests/test_ledger.py +++ b/eng/parity/tests/test_ledger.py @@ -101,6 +101,7 @@ def test_version_deltas_cover_the_required_capabilities() -> None: "semicolon_grouping", "byte_length_framing", "attachment_accounting", + "missing_target_format_safety", "option_dollar_double_escape", } <= capabilities diff --git a/eng/parity/tests/test_reconcile_versions.py b/eng/parity/tests/test_reconcile_versions.py index 5c0fe93..8fbad25 100644 --- a/eng/parity/tests/test_reconcile_versions.py +++ b/eng/parity/tests/test_reconcile_versions.py @@ -138,6 +138,7 @@ def write_fixture( "byte_length_framing", "control_notifications", "format_fields_and_operators", + "missing_target_format_safety", "option_dollar_double_escape", "semicolon_grouping", } @@ -313,6 +314,7 @@ def test_cohort_maps_only_protocol_observations_to_frozen_production_tests( "display_popup_3_3_options": "DisplayPopup33Options", "display_popup_3_6_key_policy": "DisplayPopup36KeyPolicy", "format_fields_and_operators": "FormatFieldsAndOperators", + "missing_target_format_safety": "MissingTargetFormatSafety", "hook_scope_pane_window_set": "HookScopePaneWindowSet", "hook_scope_pane_window_show": "HookScopePaneWindowShow", "kill_session_group": "KillSessionGroup", @@ -373,6 +375,7 @@ def test_cohort_maps_only_protocol_observations_to_frozen_production_tests( "byte_length_framing", "control_notifications", "format_fields_and_operators", + "missing_target_format_safety", "option_dollar_double_escape", "semicolon_grouping", ) diff --git a/src/LibTmux/Materialization/TmuxMaterializationQuery.cs b/src/LibTmux/Materialization/TmuxMaterializationQuery.cs index 9a473bd..6630d29 100644 --- a/src/LibTmux/Materialization/TmuxMaterializationQuery.cs +++ b/src/LibTmux/Materialization/TmuxMaterializationQuery.cs @@ -73,6 +73,19 @@ internal MaterializationQuery(MaterializationContext context) ArgumentException.ThrowIfNullOrWhiteSpace(listCommand); ArgumentException.ThrowIfNullOrWhiteSpace(idWireName); ArgumentException.ThrowIfNullOrWhiteSpace(identifier); + if (!TmuxCapabilities.IsSupported( + _context.TmuxVersion, + "missing_target_format_safety")) + { + return await ReadFromListingAsync( + listCommand, + idWireName, + identifier, + inSession, + cancellationToken) + .ConfigureAwait(false); + } + if (inSession is TmuxTarget scoped) { IReadOnlyDictionary? row = await ReadAsync( @@ -97,6 +110,44 @@ internal MaterializationQuery(MaterializationContext context) .ConfigureAwait(false); } + [UnsupportedOSPlatform("windows")] + private async Task?> ReadFromListingAsync( + string listCommand, + string idWireName, + string identifier, + TmuxTarget? inSession, + CancellationToken cancellationToken) + { + // tmux 3.2a crashes when a missing target expands a time or pane-colour + // callback. Listing first never builds a format tree without an entity. + string[] extra = listCommand is "list-windows" or "list-panes" ? ["-a"] : []; + IReadOnlyList> rows = await FetchAsync( + listCommand, + extra, + cancellationToken) + .ConfigureAwait(false); + IReadOnlyDictionary? first = null; + foreach (IReadOnlyDictionary row in rows) + { + if (!row.TryGetValue(idWireName, out string? id) + || !string.Equals(id, identifier, StringComparison.Ordinal)) + { + continue; + } + + first ??= row; + if (inSession is not TmuxTarget scoped + || scoped.Session is not SessionId session + || row.TryGetValue("session_id", out string? rowSession) + && string.Equals(rowSession, session.ToString(), StringComparison.Ordinal)) + { + return row; + } + } + + return first; + } + [UnsupportedOSPlatform("windows")] private async Task?> ReadAsync( string listCommand, diff --git a/src/LibTmux/Targets/TmuxTarget.cs b/src/LibTmux/Targets/TmuxTarget.cs index 4e4f379..9b69292 100644 --- a/src/LibTmux/Targets/TmuxTarget.cs +++ b/src/LibTmux/Targets/TmuxTarget.cs @@ -1,6 +1,6 @@ namespace LibTmux.Internal; -internal readonly record struct TmuxTarget(string Value) +internal readonly record struct TmuxTarget(string Value, SessionId? Session = null) { internal static TmuxTarget From(SessionId id) => new(id.ToString()); @@ -14,12 +14,14 @@ internal readonly record struct TmuxTarget(string Value) /// whichever session tmux ranks best, which need not be the one a handle /// was read in. Naming the session keeps the answer where the caller is. /// - internal static TmuxTarget In(SessionId session, WindowId id) => new($"{session}:{id}"); + internal static TmuxTarget In(SessionId session, WindowId id) => + new($"{session}:{id}", session); /// Names a pane inside one session. /// /// The empty window part asks tmux to resolve the pane identifier globally /// and keep the session, which is what a linked window needs. /// - internal static TmuxTarget In(SessionId session, PaneId id) => new($"{session}:.{id}"); + internal static TmuxTarget In(SessionId session, PaneId id) => + new($"{session}:.{id}", session); } diff --git a/src/LibTmux/Versioning/TmuxCapabilities.cs b/src/LibTmux/Versioning/TmuxCapabilities.cs index e43ee00..0458e1e 100644 --- a/src/LibTmux/Versioning/TmuxCapabilities.cs +++ b/src/LibTmux/Versioning/TmuxCapabilities.cs @@ -28,6 +28,7 @@ internal static class TmuxCapabilities "confirm_before_background", "display_message_client", "display_popup_3_3_options", + "missing_target_format_safety", "server_access_command", "show_prompt_history_command", ]; diff --git a/tests/LibTmux.IntegrationTests/Versioning/VersionParityTests.cs b/tests/LibTmux.IntegrationTests/Versioning/VersionParityTests.cs index c34298a..c3b47ec 100644 --- a/tests/LibTmux.IntegrationTests/Versioning/VersionParityTests.cs +++ b/tests/LibTmux.IntegrationTests/Versioning/VersionParityTests.cs @@ -176,6 +176,37 @@ public async Task FormatFieldsAndOperators() Assert.Matches("^1:%[0-9]+$", result.StandardOutputLines[0]); } + [UnixFact] + public async Task MissingTargetFormatSafety() + { + await using RawTmuxTestContext context = await StartAsync(); + TmuxVersion version = await GetVersionAsync(context); + bool safe = TmuxCapabilities.IsSupported(version, "missing_target_format_safety"); + + RawTmuxResult result = await ExecuteAsync( + context, + ["display-message", "-p", "-t", "%99999", "#{pane_bg}"]); + + if (version != TmuxVersion.Parse("3.2a")) + { + if (version.IsStableRelease) + { + Assert.True(safe); + } + + Assert.Equal(0, result.ExitCode); + Assert.Equal("\n", result.StandardOutputText); + Assert.Empty(result.StandardOutputLines); + Assert.Empty(result.StandardErrorLines); + } + else + { + Assert.False(safe); + Assert.NotEqual(0, result.ExitCode); + Assert.Contains("server exited unexpectedly", result.StandardErrorText); + } + } + [UnixFact] public async Task OptionDollarDoubleEscape() { diff --git a/tests/LibTmux.UnitTests/Versioning/TmuxCapabilitiesTests.cs b/tests/LibTmux.UnitTests/Versioning/TmuxCapabilitiesTests.cs index ebdef54..ee3947f 100644 --- a/tests/LibTmux.UnitTests/Versioning/TmuxCapabilitiesTests.cs +++ b/tests/LibTmux.UnitTests/Versioning/TmuxCapabilitiesTests.cs @@ -369,7 +369,9 @@ public void Format_descriptor_validates_and_copies_ordinal_scopes() [Theory] [InlineData("3.2a", "attachment_accounting", "Supported")] [InlineData("3.2a", "display_message_client", "Unsupported")] + [InlineData("3.2a", "missing_target_format_safety", "Unsupported")] [InlineData("3.3", "display_message_client", "Supported")] + [InlineData("3.3", "missing_target_format_safety", "Supported")] [InlineData("3.3a", "capture_pane_trim_trailing", "Unsupported")] [InlineData("3.4", "capture_pane_trim_trailing", "Supported")] [InlineData("3.4", "display_menu_mouse", "Unsupported")] From b28276ddbcdf968848009565f633f274c2e21d61 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 22:54:21 -0500 Subject: [PATCH 112/129] Build(fix[uv]): Refresh engineering test lock why: The checked-in script lock retained obsolete relative cutoff options that current uv no longer reproduces. what: - Remove the inert cutoff options from the lock --- eng/run_tests.py.lock | 4 ---- 1 file changed, 4 deletions(-) diff --git a/eng/run_tests.py.lock b/eng/run_tests.py.lock index 807a875..1d68407 100644 --- a/eng/run_tests.py.lock +++ b/eng/run_tests.py.lock @@ -2,10 +2,6 @@ version = 1 revision = 3 requires-python = ">=3.10" -[options] -exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. -exclude-newer-span = "P3D" - [manifest] requirements = [ { name = "pytest", specifier = ">=8.3" }, From 2db0d599117a3bd260baecdfdaba3fca513aa341 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 22:54:21 -0500 Subject: [PATCH 113/129] Workspace(test[budgets]): Scale readiness waits why: Fixed two-second waits expired under the constrained CI lane even when the workspace was still progressing normally. what: - Use the shared readiness and settle budgets --- .../Workspace/WorkspaceBuilderTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/LibTmux.IntegrationTests/Workspace/WorkspaceBuilderTests.cs b/tests/LibTmux.IntegrationTests/Workspace/WorkspaceBuilderTests.cs index 0c692c0..49b2557 100644 --- a/tests/LibTmux.IntegrationTests/Workspace/WorkspaceBuilderTests.cs +++ b/tests/LibTmux.IntegrationTests/Workspace/WorkspaceBuilderTests.cs @@ -257,7 +257,7 @@ public async Task Startup_output_can_look_ready_before_a_prompt_exists() _ = await new WorkspaceBuilder( scope.Server, - TimeSpan.FromSeconds(2), + Readiness, PaneReadiness.Always) .BuildAsync(workspace, token); @@ -266,7 +266,7 @@ public async Task Startup_output_can_look_ready_before_a_prompt_exists() ? await File.ReadAllTextAsync(received, cancellation) : "", input => input.Length > 0, - TimeSpan.FromSeconds(2), + TestBudget.Settle, TimeSpan.FromMilliseconds(20), token); Assert.Equal("WORKSPACE_USER_COMMAND\n", firstInput); From e2bc6898a8524ce81cf6b001ef9938e7ed3aa712 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 29 Aug 2026 23:43:40 -0500 Subject: [PATCH 114/129] Workspace(test[macos]): Pin readiness shell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: macOS tmux may reject an arbitrary SHELL environment value and start its platform default, so the readiness fixture waited for a test shell that never ran. What: Point the fixture’s private tmux configuration at the executable explicitly while preserving its child environment. --- .../Workspace/WorkspaceBuilderTests.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/LibTmux.IntegrationTests/Workspace/WorkspaceBuilderTests.cs b/tests/LibTmux.IntegrationTests/Workspace/WorkspaceBuilderTests.cs index 49b2557..d225da3 100644 --- a/tests/LibTmux.IntegrationTests/Workspace/WorkspaceBuilderTests.cs +++ b/tests/LibTmux.IntegrationTests/Workspace/WorkspaceBuilderTests.cs @@ -420,7 +420,9 @@ private static TmuxTestOptions HarnessOptions(string? shell = null) => new(new ServerConnectionOptions( tmuxBinaryPath: Environment.GetEnvironmentVariable("LIBTMUX_TMUX") ?? "tmux", socketName: $"ltw-{Guid.NewGuid():N}"[..20], - configurationFile: "/dev/null", + configurationFile: shell is null + ? "/dev/null" + : Path.ChangeExtension(shell, ".tmux.conf"), childEnvironment: shell is null ? null : new Dictionary { ["SHELL"] = shell })); @@ -442,6 +444,10 @@ await File.WriteAllTextAsync( File.SetUnixFileMode( program, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + await File.WriteAllTextAsync( + Path.ChangeExtension(program, ".tmux.conf"), + $"set-option -g default-shell {ShellQuote(program)}\n", + cancellationToken); return (program, received); } From ca421976d58ae242fb4f648ee0aa893a9525c053 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 30 Aug 2026 00:31:35 -0500 Subject: [PATCH 115/129] Workspace(test[macos]): Use real shell fixture why: Script-backed shells expose platform-dependent process names, so the macOS readiness fixture never matched its configured shell. what: - Drive startup output through a private /bin/sh profile - Pin the test shell, home, profile, and tmux configuration --- .../Workspace/WorkspaceBuilderTests.cs | 44 +++++++++++++++++-- 1 file changed, 40 insertions(+), 4 deletions(-) diff --git a/tests/LibTmux.IntegrationTests/Workspace/WorkspaceBuilderTests.cs b/tests/LibTmux.IntegrationTests/Workspace/WorkspaceBuilderTests.cs index d225da3..2e4fc42 100644 --- a/tests/LibTmux.IntegrationTests/Workspace/WorkspaceBuilderTests.cs +++ b/tests/LibTmux.IntegrationTests/Workspace/WorkspaceBuilderTests.cs @@ -239,14 +239,15 @@ public async Task Startup_output_can_look_ready_before_a_prompt_exists() try { - (string shell, string received) = await WriteReceiverAsync( + // Script-backed shell process names differ by platform. A startup + // profile keeps pane_current_command bound to a real /bin/sh. + (string configuration, string profile, string received) = + await WriteStartupProfileAsync( directory, - "sh", - writeStartup: true, token); TmuxTestFactory factory = new(); await using TemporaryServerScope scope = await factory.CreateServerAsync( - HarnessOptions(shell), + StartupProfileHarnessOptions(configuration, profile, directory), token); WorkspaceFile workspace = WorkspaceFile.Parse(""" session_name: libtmux-shell-false-positive @@ -427,6 +428,41 @@ private static TmuxTestOptions HarnessOptions(string? shell = null) => ? null : new Dictionary { ["SHELL"] = shell })); + private static TmuxTestOptions StartupProfileHarnessOptions( + string configuration, + string profile, + string home) => + new(new ServerConnectionOptions( + tmuxBinaryPath: Environment.GetEnvironmentVariable("LIBTMUX_TMUX") ?? "tmux", + socketName: $"ltw-{Guid.NewGuid():N}"[..20], + configurationFile: configuration, + childEnvironment: new Dictionary + { + ["ENV"] = profile, + ["HOME"] = home, + ["SHELL"] = "/bin/sh", + })); + + private static async Task<(string Configuration, string Profile, string Received)> + WriteStartupProfileAsync( + string directory, + CancellationToken cancellationToken) + { + string configuration = Path.Combine(directory, "tmux.conf"); + string profile = Path.Combine(directory, ".profile"); + string received = Path.Combine(directory, "received"); + await File.WriteAllTextAsync( + profile, + "unset ENV\nprintf 'startup output\\n'\nIFS= read -r first\n" + + $"printf '%s\\n' \"$first\" > {ShellQuote(received)}\n", + cancellationToken); + await File.WriteAllTextAsync( + configuration, + "set-option -g default-shell /bin/sh\n", + cancellationToken); + return (configuration, profile, received); + } + private static async Task<(string Program, string Received)> WriteReceiverAsync( string directory, string name, From 68b9dc6cc1fdf2e72b903ca11841886634b0388c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 30 Aug 2026 00:49:10 -0500 Subject: [PATCH 116/129] Workspace(test[macos]): Pin Bash startup why: macOS runs /bin/sh as a bash process, so a fixture configured as sh cannot satisfy the readiness check's exact process-name match. what: - Run the startup fixture with an explicit /bin/bash default shell - Load its receiver from a private Bash login profile and BASH_ENV --- .../Workspace/WorkspaceBuilderTests.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/LibTmux.IntegrationTests/Workspace/WorkspaceBuilderTests.cs b/tests/LibTmux.IntegrationTests/Workspace/WorkspaceBuilderTests.cs index 2e4fc42..142b1b2 100644 --- a/tests/LibTmux.IntegrationTests/Workspace/WorkspaceBuilderTests.cs +++ b/tests/LibTmux.IntegrationTests/Workspace/WorkspaceBuilderTests.cs @@ -240,7 +240,7 @@ public async Task Startup_output_can_look_ready_before_a_prompt_exists() try { // Script-backed shell process names differ by platform. A startup - // profile keeps pane_current_command bound to a real /bin/sh. + // profile keeps pane_current_command bound to a real /bin/bash. (string configuration, string profile, string received) = await WriteStartupProfileAsync( directory, @@ -438,9 +438,9 @@ private static TmuxTestOptions StartupProfileHarnessOptions( configurationFile: configuration, childEnvironment: new Dictionary { - ["ENV"] = profile, + ["BASH_ENV"] = profile, ["HOME"] = home, - ["SHELL"] = "/bin/sh", + ["SHELL"] = "/bin/bash", })); private static async Task<(string Configuration, string Profile, string Received)> @@ -449,16 +449,16 @@ private static TmuxTestOptions StartupProfileHarnessOptions( CancellationToken cancellationToken) { string configuration = Path.Combine(directory, "tmux.conf"); - string profile = Path.Combine(directory, ".profile"); + string profile = Path.Combine(directory, ".bash_profile"); string received = Path.Combine(directory, "received"); await File.WriteAllTextAsync( profile, - "unset ENV\nprintf 'startup output\\n'\nIFS= read -r first\n" + "unset BASH_ENV\nprintf 'startup output\\n'\nIFS= read -r first\n" + $"printf '%s\\n' \"$first\" > {ShellQuote(received)}\n", cancellationToken); await File.WriteAllTextAsync( configuration, - "set-option -g default-shell /bin/sh\n", + "set-option -g default-shell /bin/bash\n", cancellationToken); return (configuration, profile, received); } From 52b665209b21181627353fd9835dfe6dd6759245 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 30 Aug 2026 06:44:10 -0500 Subject: [PATCH 117/129] Waiting(fix[attribution]): Report withdrawal races why: tmux uses the channel signal itself to withdraw a waiter, so a real signal racing disposal cannot be attributed to either source. what: - define Signalled as observation before withdrawal rather than proof that no signal arrived - report an unattributable MCP timeout without losing the pending signal - reproduce the disposal race and the next caller's outcome deterministically --- docs/api/README.md | 2 +- docs/public-api.json | 2 +- docs/public-api.md | 2 +- src/LibTmux.Mcp/Tools/WriteTools.Wait.cs | 10 +- src/LibTmux/Waiting/TmuxWaitChannel.cs | 10 +- .../Mcp/WaitChannelAttributionTests.cs | 146 ++++++++++++++++++ 6 files changed, 160 insertions(+), 12 deletions(-) create mode 100644 tests/LibTmux.UnitTests/Mcp/WaitChannelAttributionTests.cs diff --git a/docs/api/README.md b/docs/api/README.md index 093b7df..4eefc51 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -1101,7 +1101,7 @@ modes differ. | `LibTmux.TmuxVersionTooLowException.ActualVersion` | Gets the actual tmux version. | | `LibTmux.TmuxVersionTooLowException.RequiredVersion` | Gets the required tmux version. | | `LibTmux.TmuxWaitChannel.Channel` | Gets the channel being waited on. | -| `LibTmux.TmuxWaitChannel.Signalled` | Gets whether something really signalled the channel. | +| `LibTmux.TmuxWaitChannel.Signalled` | Gets whether the wait completed before withdrawal began. | | `LibTmux.TmuxWaitTimeoutException.Timeout` | Gets the expired timeout. | | `LibTmux.TmuxWindowException.WindowId` | Gets the window the request named. | | `LibTmux.UnbindKeyRequest.All` | Gets whether every binding in the table goes. | diff --git a/docs/public-api.json b/docs/public-api.json index d9bdb83..5e86637 100644 --- a/docs/public-api.json +++ b/docs/public-api.json @@ -27660,7 +27660,7 @@ "parameters": [], "signature": "bool LibTmux.TmuxWaitChannel.Signalled { get; }", "portable": true, - "summary": "Gets whether something really signalled the channel." + "summary": "Gets whether the wait completed before withdrawal began." }, { "id": "M:LibTmux.TmuxWaitChannel.WaitAsync(TimeSpan,CancellationToken)", diff --git a/docs/public-api.md b/docs/public-api.md index 25fdccb..69a5178 100644 --- a/docs/public-api.md +++ b/docs/public-api.md @@ -2139,7 +2139,7 @@ internal static class Program | `M:LibTmux.TmuxWaitChannel.DisposeAsync()` | `ValueTask LibTmux.TmuxWaitChannel.DisposeAsync()` | Public | No | `UnsupportedOSPlatform("windows")` | Withdraws the waiter from tmux. | | `M:LibTmux.TmuxWaitChannel.WaitAsync(TimeSpan,CancellationToken)` | `Task LibTmux.TmuxWaitChannel.WaitAsync(TimeSpan budget, CancellationToken cancellationToken = default)` | Public | No | `UnsupportedOSPlatform("windows")` | Waits for the signal, giving this attempt a budget. | | `P:LibTmux.TmuxWaitChannel.Channel` | `string LibTmux.TmuxWaitChannel.Channel { get; }` | Public | No | Portable | Gets the channel being waited on. | -| `P:LibTmux.TmuxWaitChannel.Signalled` | `bool LibTmux.TmuxWaitChannel.Signalled { get; }` | Public | No | Portable | Gets whether something really signalled the channel. | +| `P:LibTmux.TmuxWaitChannel.Signalled` | `bool LibTmux.TmuxWaitChannel.Signalled { get; }` | Public | No | Portable | Gets whether the wait completed before withdrawal began. | ### `T:LibTmux.TmuxWaitMode` diff --git a/src/LibTmux.Mcp/Tools/WriteTools.Wait.cs b/src/LibTmux.Mcp/Tools/WriteTools.Wait.cs index d5c56a4..00c6d2a 100644 --- a/src/LibTmux.Mcp/Tools/WriteTools.Wait.cs +++ b/src/LibTmux.Mcp/Tools/WriteTools.Wait.cs @@ -51,9 +51,8 @@ public async Task WaitForChannelAsync( await using ConfiguredAsyncDisposable _ = wait.ConfigureAwait(false); if (!await wait.WaitAsync(budget, cancellationToken).ConfigureAwait(false)) { - // Withdraw before answering. A signal landing as the attempt ended - // was taken by this waiter, and only withdrawing settles whether - // that happened. + // Withdraw before answering so a racing signal stays pending for + // the next caller even though tmux cannot attribute the race. await wait.DisposeAsync().ConfigureAwait(false); } @@ -64,8 +63,9 @@ public async Task WaitForChannelAsync( /// Says a wait ran out without claiming the channel is untouched. private static string NotSignalled(string channel, TimeSpan budget) => - $"Channel '{channel}' was not signalled within {budget.TotalSeconds:0.#}s. " - + "The wait was withdrawn, so a signal arriving now still counts; call again."; + $"No signal was observed on channel '{channel}' within {budget.TotalSeconds:0.#}s. " + + "The wait was withdrawn, and tmux cannot tell whether a signal raced that " + + "withdrawal. Any resulting pending signal remains for the next caller."; internal static void ValidateChannel(string channel, int resultMaxBytes) { diff --git a/src/LibTmux/Waiting/TmuxWaitChannel.cs b/src/LibTmux/Waiting/TmuxWaitChannel.cs index 7b74815..1d0ce08 100644 --- a/src/LibTmux/Waiting/TmuxWaitChannel.cs +++ b/src/LibTmux/Waiting/TmuxWaitChannel.cs @@ -45,10 +45,11 @@ internal TmuxWaitChannel(Server server, string channel) /// Gets the channel being waited on. public string Channel { get; } - /// Gets whether something really signalled the channel. + /// Gets whether the wait completed before withdrawal began. /// - /// Withdrawing signals the channel too, so finishing is not the same as - /// having been signalled: this stays false for a wait that was withdrawn. + /// A false value does not prove that no signal arrived. Withdrawing must + /// signal the same channel, so tmux cannot attribute a completion that + /// races the decision to withdraw. /// public bool Signalled => _waiter.IsCompletedSuccessfully && !_withdrew; @@ -99,7 +100,8 @@ public async Task WaitAsync( /// A signal landing between the check below and the withdrawal is woken by /// this waiter and then re-raised by the withdrawal itself, because by then /// no waiter is left to take it. That leaves the channel pending rather - /// than empty — an extra wake for the next caller, never a lost one. + /// than empty — an extra wake for the next caller, never a lost one. tmux + /// cannot say which signal completed this waiter in that race. /// /// /// A signal wakes every waiter on the channel and tmux offers no way to diff --git a/tests/LibTmux.UnitTests/Mcp/WaitChannelAttributionTests.cs b/tests/LibTmux.UnitTests/Mcp/WaitChannelAttributionTests.cs new file mode 100644 index 0000000..16b2496 --- /dev/null +++ b/tests/LibTmux.UnitTests/Mcp/WaitChannelAttributionTests.cs @@ -0,0 +1,146 @@ +using System.Runtime.Versioning; +using LibTmux.Internal; +using LibTmux.Mcp; +using LibTmux.UnitTests.Connection; + +namespace LibTmux.UnitTests; + +[UnsupportedOSPlatform("windows")] +public sealed class WaitChannelAttributionTests +{ + [Fact] + public async Task A_signal_racing_withdrawal_is_not_attributed_and_stays_pending() + { + CancellationToken token = TestContext.Current.CancellationToken; + var endpoint = new WaitChannelEndpoint(); + Server server = endpoint.Server; + using var accessor = new TmuxConnectionAccessor(server); + await using var activity = new PaneActivityHub(); + await using var jobs = new JobStore(); + var tools = new WriteTools( + accessor, + new ServerPolicy(), + activity, + jobs); + + Task timingOut = tools.WaitForChannelAsync( + "attribution-race", + timeoutSeconds: 0.01, + cancellationToken: token); + await endpoint.WithdrawalStarted.WaitAsync(token); + + try + { + await server.WaitForAsync( + new WaitForRequest("attribution-race", TmuxWaitMode.Signal), + token); + } + finally + { + endpoint.ReleaseWithdrawal(); + } + + ActionResult raced = await timingOut.WaitAsync(token); + Assert.Contains("cannot tell whether a signal raced", raced.Changed, StringComparison.Ordinal); + + ActionResult next = await tools.WaitForChannelAsync( + "attribution-race", + timeoutSeconds: 1, + cancellationToken: token); + Assert.Equal("Channel 'attribution-race' was signalled.", next.Changed); + } + + private sealed class WaitChannelEndpoint + { + private readonly object _gate = new(); + private readonly TaskCompletionSource _withdrawalStarted = new( + TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _releaseWithdrawal = new( + TaskCreationOptions.RunContinuationsAsynchronously); + private TaskCompletionSource? _waiter; + private bool _pending; + private int _signals; + + internal WaitChannelEndpoint() + { + var connection = new TmuxConnection( + new ServerConnectionOptions(socketName: "wait-attribution"), + FakeMultiplexer.AnsweringVersion(ExecuteAsync)); + Server = new Server(connection, new ServerGeneration(17, 29), "tmux 3.7"); + } + + internal Server Server { get; } + + internal Task WithdrawalStarted => _withdrawalStarted.Task; + + internal void ReleaseWithdrawal() => _releaseWithdrawal.TrySetResult(); + + private async Task ExecuteAsync( + TmuxCommandRequest request, + CancellationToken cancellationToken) + { + IReadOnlyList arguments = request.LogicalArguments; + if (arguments.Count > 0 && arguments[0] == "wait-for") + { + if (arguments.Contains("-S", StringComparer.Ordinal)) + { + if (Interlocked.Increment(ref _signals) == 1) + { + _withdrawalStarted.TrySetResult(); + await _releaseWithdrawal.Task.WaitAsync(cancellationToken) + .ConfigureAwait(false); + } + + Signal(arguments); + return Success(arguments); + } + + return await WaitAsync(arguments, cancellationToken).ConfigureAwait(false); + } + + return Success(arguments); + } + + private Task WaitAsync( + IReadOnlyList arguments, + CancellationToken cancellationToken) + { + lock (_gate) + { + if (_pending) + { + _pending = false; + return Task.FromResult(Success(arguments)); + } + + _waiter = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + return _waiter.Task.WaitAsync(cancellationToken); + } + } + + private void Signal(IReadOnlyList arguments) + { + TaskCompletionSource? waiter; + lock (_gate) + { + waiter = _waiter; + _waiter = null; + if (waiter is null) + { + _pending = true; + } + } + + waiter?.TrySetResult(Success(arguments)); + } + + private static TmuxCommandResult Success(IReadOnlyList arguments) => new( + arguments, + 0, + ReadOnlyMemory.Empty, + ReadOnlyMemory.Empty, + [], + []); + } +} From 6e0198c419aaaecbaeb70adac39fbcc9e55403a4 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 30 Aug 2026 07:04:34 -0500 Subject: [PATCH 118/129] Jobs(fix[shutdown]): Withdraw owned waiters why: Cancelling a job watcher stopped only the local client while tmux kept its wait registration, and convenience-created write tools exposed no way to dispose the store and activity hub they owned. what: - observe job channels through TmuxWaitChannel and withdraw them on shutdown - make WriteTools dispose only resources created by McpTools.Writing - prove retained signals, caller ownership, idempotent shutdown, and live tmux cleanup --- docs/mcp/README.md | 2 +- examples/LibTmux.Examples/Snippets/Mcp.cs | 4 +- src/LibTmux.Mcp/Jobs/JobStore.cs | 6 +- src/LibTmux.Mcp/McpTools.cs | 44 +++- src/LibTmux.Mcp/Tools/WriteTools.cs | 90 +++++++- src/LibTmux/Waiting/TmuxWaitChannel.cs | 3 + .../Mcp/TmuxToolsTests.cs | 24 +++ tests/LibTmux.UnitTests/Mcp/JobStoreTests.cs | 195 ++++++++++++++---- .../Mcp/PaneActivityHubLifecycleTests.cs | 20 ++ 9 files changed, 334 insertions(+), 54 deletions(-) diff --git a/docs/mcp/README.md b/docs/mcp/README.md index ff322fd..23c6bed 100644 --- a/docs/mcp/README.md +++ b/docs/mcp/README.md @@ -62,7 +62,7 @@ assistant can run one directly instead of launching a second process: using LibTmux; using LibTmux.Mcp; -WriteTools tools = McpTools.Writing(server); +await using WriteTools tools = McpTools.Writing(server); RunResult result = await tools.RunAsync( "test -f /etc/hostname && echo present", diff --git a/examples/LibTmux.Examples/Snippets/Mcp.cs b/examples/LibTmux.Examples/Snippets/Mcp.cs index 30958e6..4df2970 100644 --- a/examples/LibTmux.Examples/Snippets/Mcp.cs +++ b/examples/LibTmux.Examples/Snippets/Mcp.cs @@ -23,7 +23,7 @@ public static async Task RunAndReadExitStatus(Server server, CancellationToken c Pane pane = session.ActivePane!; #region RunAndReadExitStatus - WriteTools tools = McpTools.Writing(server); + await using WriteTools tools = McpTools.Writing(server); RunResult result = await tools.RunAsync( "test -f /etc/hostname && echo present", @@ -78,7 +78,7 @@ public static async Task KeepTheNewestLines(Server server, CancellationToken ct) ct); Pane pane = session.ActivePane!; - WriteTools writing = McpTools.Writing(server); + await using WriteTools writing = McpTools.Writing(server); await writing.RunAsync( "seq 1 200", pane.Id.ToString(), diff --git a/src/LibTmux.Mcp/Jobs/JobStore.cs b/src/LibTmux.Mcp/Jobs/JobStore.cs index 4f63639..2513421 100644 --- a/src/LibTmux.Mcp/Jobs/JobStore.cs +++ b/src/LibTmux.Mcp/Jobs/JobStore.cs @@ -369,10 +369,8 @@ private async Task WatchAsync(Server server, Pane pane, StoredJob job) Exception? failure = null; try { - await server.WaitForAsync( - new WaitForRequest(job.Token.Channel, TmuxWaitMode.Wait), - _shutdown.Token) - .ConfigureAwait(false); + await using TmuxWaitChannel wait = server.OpenWaitChannel(job.Token.Channel); + await wait.WaitUntilSignalledAsync(_shutdown.Token).ConfigureAwait(false); int? status = await WriteTools .ReadStatusAsync(pane, job.Token, _shutdown.Token) diff --git a/src/LibTmux.Mcp/McpTools.cs b/src/LibTmux.Mcp/McpTools.cs index 92595e9..05151b4 100644 --- a/src/LibTmux.Mcp/McpTools.cs +++ b/src/LibTmux.Mcp/McpTools.cs @@ -38,17 +38,30 @@ public static ReadTools Reading( /// /// Pass the same to every caller that needs to /// collect a job somebody else started; a handle is only meaningful to the - /// store that issued it. + /// store that issued it. Dispose the returned tools asynchronously. The + /// factory disposes its connection cache, activity hub, and any job store + /// it created; a supplied remains caller-owned. /// public static WriteTools Writing( ServerConnectionOptions? options = null, ServerPolicy? policy = null, - JobStore? jobs = null) => - new( + JobStore? jobs = null) + { + JobStore effectiveJobs = jobs ?? new JobStore(); + WriteTools.ResourceOwnership ownership = WriteTools.ResourceOwnership.Connection + | WriteTools.ResourceOwnership.Activity; + if (jobs is null) + { + ownership |= WriteTools.ResourceOwnership.Jobs; + } + + return new WriteTools( Accessor(options), policy ?? new ServerPolicy(), new PaneActivityHub(), - jobs ?? new JobStore()); + effectiveJobs, + ownership); + } /// Builds the tools that remove what they act on. /// How to reach tmux, or null for the ambient server. @@ -78,15 +91,32 @@ public static ReadTools Reading(Server server, ServerPolicy? policy = null) => /// What the tools may spend, or null for the defaults. /// Where background commands are tracked, or null for a new store. /// The changing tools. + /// + /// Dispose the returned tools asynchronously. The factory disposes its + /// connection cache, activity hub, and any job store it created; the + /// supplied and remain + /// caller-owned. + /// public static WriteTools Writing( Server server, ServerPolicy? policy = null, - JobStore? jobs = null) => - new( + JobStore? jobs = null) + { + JobStore effectiveJobs = jobs ?? new JobStore(); + WriteTools.ResourceOwnership ownership = WriteTools.ResourceOwnership.Connection + | WriteTools.ResourceOwnership.Activity; + if (jobs is null) + { + ownership |= WriteTools.ResourceOwnership.Jobs; + } + + return new WriteTools( new TmuxConnectionAccessor(server), policy ?? new ServerPolicy(), new PaneActivityHub(), - jobs ?? new JobStore()); + effectiveJobs, + ownership); + } private static TmuxConnectionAccessor Accessor(ServerConnectionOptions? options) => new(options, options?.SocketName); diff --git a/src/LibTmux.Mcp/Tools/WriteTools.cs b/src/LibTmux.Mcp/Tools/WriteTools.cs index fd45067..bbd0d63 100644 --- a/src/LibTmux.Mcp/Tools/WriteTools.cs +++ b/src/LibTmux.Mcp/Tools/WriteTools.cs @@ -1,3 +1,4 @@ +using System.Runtime.ExceptionServices; using System.Runtime.Versioning; using System.Text; using ModelContextProtocol.Server; @@ -9,26 +10,43 @@ namespace LibTmux.Mcp; /// Registered only when the operator's tier is mutating or higher. /// Tools that remove what they act on live in /// instead, so raising the tier to allow a split does not also allow a kill. +/// Resources passed to the public constructor remain owned by the caller. +/// Instances returned by +/// dispose only the resources that factory created. /// [McpServerToolType] [UnsupportedOSPlatform("windows")] -public sealed partial class WriteTools +public sealed partial class WriteTools : IAsyncDisposable { + private readonly object _lifetimeGate = new(); private readonly TmuxConnectionAccessor _connection; private readonly ServerPolicy _policy; private readonly PaneActivityHub _activity; private readonly JobStore _jobs; + private readonly ResourceOwnership _ownership; + private Task? _disposeTask; /// Initializes the changing tools. /// The servers the tools talk to. /// What the tools are allowed to spend. /// Tells a wait when a pane has printed something. /// Holds commands that outlive the call that started them. + /// Disposing the tools does not dispose these caller-owned resources. public WriteTools( TmuxConnectionAccessor connection, ServerPolicy policy, PaneActivityHub activity, JobStore jobs) + : this(connection, policy, activity, jobs, ResourceOwnership.None) + { + } + + internal WriteTools( + TmuxConnectionAccessor connection, + ServerPolicy policy, + PaneActivityHub activity, + JobStore jobs, + ResourceOwnership ownership) { ArgumentNullException.ThrowIfNull(connection); ArgumentNullException.ThrowIfNull(policy); @@ -38,11 +56,81 @@ public WriteTools( _policy = policy; _activity = activity; _jobs = jobs; + _ownership = ownership; + } + + /// + public ValueTask DisposeAsync() + { + lock (_lifetimeGate) + { + _disposeTask ??= DisposeOwnedAsync(); + return new ValueTask(_disposeTask); + } + } + + private async Task DisposeOwnedAsync() + { + List failures = []; + if (_ownership.HasFlag(ResourceOwnership.Activity)) + { + try + { + await _activity.DisposeAsync().ConfigureAwait(false); + } + catch (Exception error) + { + failures.Add(error); + } + } + + if (_ownership.HasFlag(ResourceOwnership.Jobs)) + { + try + { + await _jobs.DisposeAsync().ConfigureAwait(false); + } + catch (Exception error) + { + failures.Add(error); + } + } + + if (_ownership.HasFlag(ResourceOwnership.Connection)) + { + try + { + _connection.Dispose(); + } + catch (Exception error) + { + failures.Add(error); + } + } + + if (failures.Count == 1) + { + ExceptionDispatchInfo.Capture(failures[0]).Throw(); + } + + if (failures.Count > 1) + { + throw new AggregateException(failures); + } } private Task ServerAsync(string? socketName, CancellationToken cancellationToken) => _connection.GetAsync(socketName, cancellationToken); + [Flags] + internal enum ResourceOwnership + { + None = 0, + Connection = 1, + Activity = 2, + Jobs = 4, + } + /// Quotes a word so a POSIX shell reads it as exactly that word. /// The word. /// The quoted word. diff --git a/src/LibTmux/Waiting/TmuxWaitChannel.cs b/src/LibTmux/Waiting/TmuxWaitChannel.cs index 1d0ce08..1ddbf78 100644 --- a/src/LibTmux/Waiting/TmuxWaitChannel.cs +++ b/src/LibTmux/Waiting/TmuxWaitChannel.cs @@ -89,6 +89,9 @@ public async Task WaitAsync( return true; } + internal Task WaitUntilSignalledAsync(CancellationToken cancellationToken) => + _waiter.WaitAsync(cancellationToken); + /// Withdraws the waiter from tmux. /// /// diff --git a/tests/LibTmux.IntegrationTests/Mcp/TmuxToolsTests.cs b/tests/LibTmux.IntegrationTests/Mcp/TmuxToolsTests.cs index 49ef022..be08445 100644 --- a/tests/LibTmux.IntegrationTests/Mcp/TmuxToolsTests.cs +++ b/tests/LibTmux.IntegrationTests/Mcp/TmuxToolsTests.cs @@ -300,6 +300,30 @@ public async Task A_job_returns_at_once_and_is_collected_later() line => line.Contains("JOB_FINISHED", StringComparison.Ordinal)); } + [UnixFact] + public async Task Convenience_tools_withdraw_owned_job_waiters_on_shutdown() + { + CancellationToken token = TestContext.Current.CancellationToken; + TmuxTestFactory factory = new(); + await using TemporaryHierarchyScope scope = await factory.CreateHierarchyAsync( + cancellationToken: token); + WriteTools tools = McpTools.Writing(scope.Server); + + JobInfo started = await tools.StartJobAsync( + "sleep 30", + scope.Pane.Id.ToString(), + cancellationToken: token); + await tools.DisposeAsync().AsTask().WaitAsync(token); + + string channel = $"lt_r_{started.JobId}"; + await scope.Server.WaitForAsync( + new WaitForRequest(channel, TmuxWaitMode.Signal), + token); + await using TmuxWaitChannel next = scope.Server.OpenWaitChannel(channel); + + Assert.True(await next.WaitAsync(TimeSpan.FromSeconds(1), token)); + } + [UnixFact] public async Task A_job_handle_nobody_issued_is_refused_with_advice() { diff --git a/tests/LibTmux.UnitTests/Mcp/JobStoreTests.cs b/tests/LibTmux.UnitTests/Mcp/JobStoreTests.cs index df99e89..037ecee 100644 --- a/tests/LibTmux.UnitTests/Mcp/JobStoreTests.cs +++ b/tests/LibTmux.UnitTests/Mcp/JobStoreTests.cs @@ -254,7 +254,8 @@ public async Task Not_dispatched_enter_after_payload_retains_a_recovery_handle() if (arguments.Count > 0 && arguments[0] == "wait-for") { - await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + return await endpoint.WaitForAsync(arguments, cancellationToken) + .ConfigureAwait(false); } return endpoint.Success(arguments); @@ -418,7 +419,8 @@ public async Task Ambiguous_dispatch_cancellation_retains_a_collectable_job() if (arguments.Count > 0 && arguments[0] == "wait-for") { - await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + return await endpoint.WaitForAsync(arguments, cancellationToken) + .ConfigureAwait(false); } return endpoint.Success(arguments); @@ -499,7 +501,8 @@ public async Task Non_definitive_dispatch_failures_retain_a_recovery_handle( if (arguments.Count > 0 && arguments[0] == "wait-for") { - await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + return await endpoint.WaitForAsync(arguments, cancellationToken) + .ConfigureAwait(false); } return endpoint.Success(arguments); @@ -560,27 +563,9 @@ public async Task Cancelled_jobs_retain_capacity_until_their_watchers_end() { CancellationToken token = TestContext.Current.CancellationToken; FakeEndpoint endpoint = new("cancel-capacity", new ServerGeneration(757, 7507)); - var releaseFirstWatcher = new TaskCompletionSource( - TaskCreationOptions.RunContinuationsAsynchronously); - int watchers = 0; - endpoint.Handler = async (arguments, cancellationToken) => - { - if (arguments.Count > 0 && arguments[0] == "wait-for") - { - if (Interlocked.Increment(ref watchers) == 1) - { - await releaseFirstWatcher.Task.WaitAsync(cancellationToken); - } - else - { - await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); - } - } - - return endpoint.Success(arguments); - }; await using JobStore jobs = new(); Task? firstWatcher = null; + string? firstChannel = null; for (int index = 0; index < JobStore.Capacity; index++) { @@ -597,6 +582,7 @@ public async Task Cancelled_jobs_retain_capacity_until_their_watchers_end() Task watcher = Assert.IsAssignableFrom( jobs.Resolve(started.JobId, null).Watcher); firstWatcher ??= watcher; + firstChannel ??= $"lt_r_{started.JobId}"; Assert.False(watcher.IsCompleted); } @@ -614,7 +600,9 @@ public async Task Cancelled_jobs_retain_capacity_until_their_watchers_end() Assert.Equal(JobStore.Capacity, jobs.List().TotalJobs); Assert.All(jobs.List().Jobs, job => Assert.Equal(JobState.Cancelled, job.State)); - releaseFirstWatcher.TrySetResult(); + await endpoint.Server.WaitForAsync( + new WaitForRequest(firstChannel!, TmuxWaitMode.Signal), + token); await Assert.IsAssignableFrom(firstWatcher).WaitAsync(token); _ = await jobs.StartAsync( endpoint.Server, @@ -838,20 +826,81 @@ public async Task Unexpected_watcher_failure_is_observed_and_marks_the_job_lost( } [Fact] - public async Task Disposal_cancels_then_waits_for_detached_watchers() + public async Task Disposal_withdraws_the_tmux_waiter_before_it_finishes() + { + CancellationToken token = TestContext.Current.CancellationToken; + FakeEndpoint endpoint = new("dispose-withdraw", new ServerGeneration(889, 8809)); + JobStore jobs = new(); + try + { + JobInfo started = await jobs.StartAsync( + endpoint.Server, + endpoint.Pane, + "echo watched", + suppressHistory: true, + token); + string channel = $"lt_r_{started.JobId}"; + await endpoint.WaitUntilRegisteredAsync(channel, token); + + await jobs.DisposeAsync().AsTask().WaitAsync(token); + await endpoint.Server.WaitForAsync( + new WaitForRequest(channel, TmuxWaitMode.Signal), + token); + + await using TmuxWaitChannel next = endpoint.Server.OpenWaitChannel(channel); + Assert.True(await next.WaitAsync(TimeSpan.FromSeconds(1), token)); + } + finally + { + await jobs.DisposeAsync(); + } + } + + [Fact] + public async Task Convenience_tools_have_async_shutdown_without_taking_a_supplied_store() + { + CancellationToken token = TestContext.Current.CancellationToken; + FakeEndpoint endpoint = new("tools-dispose", new ServerGeneration(899, 8909)); + endpoint.Handler = (arguments, _) => Task.FromResult(endpoint.Success(arguments)); + await using JobStore jobs = new(); + WriteTools tools = McpTools.Writing(endpoint.Server, jobs: jobs); + + IAsyncDisposable lifetime = Assert.IsAssignableFrom(tools); + await lifetime.DisposeAsync(); + await lifetime.DisposeAsync(); + + JobInfo started = await jobs.StartAsync( + endpoint.Server, + endpoint.Pane, + "echo still-open", + suppressHistory: true, + token); + await Assert.IsAssignableFrom(jobs.Resolve(started.JobId, null).Watcher!) + .WaitAsync(token); + + Assert.Equal(JobState.Exited, jobs.Get(started.JobId).State); + } + + [Fact] + public async Task Disposal_withdraws_then_waits_for_detached_watchers() { CancellationToken token = TestContext.Current.CancellationToken; FakeEndpoint endpoint = new("dispose", new ServerGeneration(909, 9009)); - var cancellationSeen = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var withdrawalSeen = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); endpoint.Handler = async (arguments, cancellationToken) => { if (arguments.Count > 0 && arguments[0] == "wait-for") { - using CancellationTokenRegistration registration = cancellationToken.Register( - () => cancellationSeen.TrySetResult()); - await release.Task; - cancellationToken.ThrowIfCancellationRequested(); + if (arguments.Contains("-S", StringComparer.Ordinal)) + { + withdrawalSeen.TrySetResult(); + await release.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + } + + return await endpoint.WaitForAsync(arguments, cancellationToken) + .ConfigureAwait(false); } return endpoint.Success(arguments); @@ -867,7 +916,7 @@ public async Task Disposal_cancels_then_waits_for_detached_watchers() token); Task disposing = jobs.DisposeAsync().AsTask(); - await cancellationSeen.Task.WaitAsync(token); + await withdrawalSeen.Task.WaitAsync(token); Assert.False(disposing.IsCompleted); release.TrySetResult(); @@ -891,17 +940,21 @@ public async Task Disposal_preserves_a_retired_watcher_failure_and_drains_the_re new InvalidOperationException("watch failed before the next start")) : Task.FromResult(faulting.Success(arguments)); FakeEndpoint held = new("dispose-held", new ServerGeneration(960, 9510)); - var cancellationSeen = new TaskCompletionSource( + var withdrawalSeen = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously); var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); held.Handler = async (arguments, cancellationToken) => { if (arguments.Count > 0 && arguments[0] == "wait-for") { - using CancellationTokenRegistration registration = cancellationToken.Register( - () => cancellationSeen.TrySetResult()); - await release.Task; - cancellationToken.ThrowIfCancellationRequested(); + if (arguments.Contains("-S", StringComparer.Ordinal)) + { + withdrawalSeen.TrySetResult(); + await release.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + } + + return await held.WaitForAsync(arguments, cancellationToken) + .ConfigureAwait(false); } return held.Success(arguments); @@ -929,7 +982,7 @@ public async Task Disposal_preserves_a_retired_watcher_failure_and_drains_the_re token); Task disposing = jobs.DisposeAsync().AsTask(); - await cancellationSeen.Task.WaitAsync(token); + await withdrawalSeen.Task.WaitAsync(token); Assert.False(disposing.IsCompleted); release.TrySetResult(); @@ -1049,6 +1102,12 @@ private delegate Task CommandHandler( private sealed class FakeEndpoint { private readonly TmuxConnection _connection; + private readonly object _waitGate = new(); + private readonly Dictionary> _waiters = + new(StringComparer.Ordinal); + private readonly Dictionary _waitRegistrations = + new(StringComparer.Ordinal); + private readonly HashSet _pendingSignals = new(StringComparer.Ordinal); private int _jobDispatched; internal FakeEndpoint(string socketName, ServerGeneration generation) @@ -1137,11 +1196,13 @@ private async Task ExecuteAsync( { if (arguments.Length > 0 && arguments[0] == "wait-for") { - await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken) + result = await WaitForAsync(arguments, cancellationToken) .ConfigureAwait(false); } - - result = Success(arguments); + else + { + result = Success(arguments); + } } if (result.ExitCode == 0 && arguments.Contains("send-keys", StringComparer.Ordinal)) @@ -1152,6 +1213,62 @@ await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken) return result; } + internal Task WaitForAsync( + IReadOnlyList arguments, + CancellationToken cancellationToken) + { + string channel = arguments[^1]; + lock (_waitGate) + { + if (arguments.Contains("-S", StringComparer.Ordinal)) + { + if (_waiters.Remove( + channel, + out TaskCompletionSource? registeredWaiter)) + { + registeredWaiter.TrySetResult(Success(arguments)); + } + else + { + _pendingSignals.Add(channel); + } + + return Task.FromResult(Success(arguments)); + } + + if (_pendingSignals.Remove(channel)) + { + return Task.FromResult(Success(arguments)); + } + + var newWaiter = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + _waiters.Add(channel, newWaiter); + if (_waitRegistrations.Remove(channel, out TaskCompletionSource? registered)) + { + registered.TrySetResult(); + } + + return newWaiter.Task.WaitAsync(cancellationToken); + } + } + + internal Task WaitUntilRegisteredAsync(string channel, CancellationToken cancellationToken) + { + lock (_waitGate) + { + if (_waiters.ContainsKey(channel)) + { + return Task.CompletedTask; + } + + var registered = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + _waitRegistrations.Add(channel, registered); + return registered.Task.WaitAsync(cancellationToken); + } + } + private static bool IsGuarded(IReadOnlyList arguments) => arguments.Count > 2 && arguments[0] == "display-message" diff --git a/tests/LibTmux.UnitTests/Mcp/PaneActivityHubLifecycleTests.cs b/tests/LibTmux.UnitTests/Mcp/PaneActivityHubLifecycleTests.cs index 74854e4..555db53 100644 --- a/tests/LibTmux.UnitTests/Mcp/PaneActivityHubLifecycleTests.cs +++ b/tests/LibTmux.UnitTests/Mcp/PaneActivityHubLifecycleTests.cs @@ -7,6 +7,26 @@ namespace LibTmux.UnitTests.Mcp; [UnsupportedOSPlatform("windows")] public sealed class PaneActivityHubLifecycleTests { + [Fact] + public async Task Write_tools_leave_a_supplied_activity_hub_alive() + { + CancellationToken token = TestContext.Current.CancellationToken; + await using PaneActivityHub hub = new(); + await using JobStore jobs = new(); + using var accessor = new TmuxConnectionAccessor(Server.Open( + new ServerConnectionOptions(socketName: "supplied-tools"))); + var tools = new WriteTools(accessor, new ServerPolicy(), hub, jobs); + + await Assert.IsAssignableFrom(tools).DisposeAsync(); + + FakeControlModeSession session = new(); + await using IAsyncDisposable lease = await hub.WatchAsync( + "$1", + _ => Task.FromResult(session), + token); + Assert.True(hub.IsStreaming); + } + [Fact] public async Task A_later_watch_restarts_after_the_control_stream_ends() { From 521df13d1405b9730f99934e7ec44805f9a43cf6 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 30 Aug 2026 07:36:30 -0500 Subject: [PATCH 119/129] Waiting(fix[disposal]): Join concurrent cleanup why: Concurrent disposal callers returned while the first channel withdrawal was still in flight. what: - cache and share asynchronous waiter cleanup - gate repeat disposal against a blocked channel signal --- src/LibTmux/Waiting/TmuxWaitChannel.cs | 17 +++++++++++--- .../Mcp/WaitChannelAttributionTests.cs | 23 +++++++++++++++++++ 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/src/LibTmux/Waiting/TmuxWaitChannel.cs b/src/LibTmux/Waiting/TmuxWaitChannel.cs index 1ddbf78..83d3dce 100644 --- a/src/LibTmux/Waiting/TmuxWaitChannel.cs +++ b/src/LibTmux/Waiting/TmuxWaitChannel.cs @@ -23,8 +23,10 @@ namespace LibTmux; [UnsupportedOSPlatform("windows")] public sealed class TmuxWaitChannel : IAsyncDisposable { + private readonly object _disposeGate = new(); private readonly Server _server; private readonly Task _waiter; + private Task? _disposeTask; private int _disposed; private bool _withdrew; @@ -112,13 +114,22 @@ internal Task WaitUntilSignalledAsync(CancellationToken cancellationToken) => /// wait open on the same channel. Keep one open wait per channel. /// /// - public async ValueTask DisposeAsync() + public ValueTask DisposeAsync() { - if (Interlocked.Exchange(ref _disposed, 1) != 0) + lock (_disposeGate) { - return; + if (_disposeTask is null) + { + Interlocked.Exchange(ref _disposed, 1); + _disposeTask = DisposeCoreAsync(); + } + + return new ValueTask(_disposeTask); } + } + private async Task DisposeCoreAsync() + { if (!_waiter.IsCompleted) { _withdrew = true; diff --git a/tests/LibTmux.UnitTests/Mcp/WaitChannelAttributionTests.cs b/tests/LibTmux.UnitTests/Mcp/WaitChannelAttributionTests.cs index 16b2496..417fbac 100644 --- a/tests/LibTmux.UnitTests/Mcp/WaitChannelAttributionTests.cs +++ b/tests/LibTmux.UnitTests/Mcp/WaitChannelAttributionTests.cs @@ -8,6 +8,29 @@ namespace LibTmux.UnitTests; [UnsupportedOSPlatform("windows")] public sealed class WaitChannelAttributionTests { + [Fact] + public async Task Concurrent_disposal_waits_for_the_same_withdrawal() + { + CancellationToken token = TestContext.Current.CancellationToken; + var endpoint = new WaitChannelEndpoint(); + TmuxWaitChannel wait = endpoint.Server.OpenWaitChannel("concurrent-disposal"); + + Task first = wait.DisposeAsync().AsTask(); + await endpoint.WithdrawalStarted.WaitAsync(token); + Task second = wait.DisposeAsync().AsTask(); + try + { + Assert.Same(first, second); + Assert.False(second.IsCompleted); + } + finally + { + endpoint.ReleaseWithdrawal(); + } + + await Task.WhenAll(first, second).WaitAsync(token); + } + [Fact] public async Task A_signal_racing_withdrawal_is_not_attributed_and_stays_pending() { From 8d1e4621cbee6b9e29a1465201daee1f42f3bbca Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 30 Aug 2026 07:41:28 -0500 Subject: [PATCH 120/129] Waiting(fix[withdrawal]): Signal faulted waiters why: A completed transport failure did not prove that tmux removed its server-side wait registration. what: - withdraw unless the wait completed successfully - retain the original fault after proving the channel signal occurred --- src/LibTmux/Waiting/TmuxWaitChannel.cs | 2 +- .../Mcp/WaitChannelAttributionTests.cs | 46 +++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src/LibTmux/Waiting/TmuxWaitChannel.cs b/src/LibTmux/Waiting/TmuxWaitChannel.cs index 83d3dce..e00b925 100644 --- a/src/LibTmux/Waiting/TmuxWaitChannel.cs +++ b/src/LibTmux/Waiting/TmuxWaitChannel.cs @@ -130,7 +130,7 @@ public ValueTask DisposeAsync() private async Task DisposeCoreAsync() { - if (!_waiter.IsCompleted) + if (!_waiter.IsCompletedSuccessfully) { _withdrew = true; try diff --git a/tests/LibTmux.UnitTests/Mcp/WaitChannelAttributionTests.cs b/tests/LibTmux.UnitTests/Mcp/WaitChannelAttributionTests.cs index 417fbac..9646ce7 100644 --- a/tests/LibTmux.UnitTests/Mcp/WaitChannelAttributionTests.cs +++ b/tests/LibTmux.UnitTests/Mcp/WaitChannelAttributionTests.cs @@ -8,6 +8,24 @@ namespace LibTmux.UnitTests; [UnsupportedOSPlatform("windows")] public sealed class WaitChannelAttributionTests { + [Fact] + public async Task Faulted_wait_is_still_withdrawn_during_disposal() + { + CancellationToken token = TestContext.Current.CancellationToken; + var endpoint = new WaitChannelEndpoint(); + TmuxWaitChannel wait = endpoint.Server.OpenWaitChannel("faulted-wait"); + endpoint.FailWait(); + _ = await Assert.ThrowsAsync( + () => wait.WaitAsync(TimeSpan.FromSeconds(1), token)); + endpoint.ReleaseWithdrawal(); + + _ = await Assert.ThrowsAsync( + () => wait.DisposeAsync().AsTask()); + + Assert.Equal(1, endpoint.SignalCount); + Assert.False(endpoint.HasWaiter); + } + [Fact] public async Task Concurrent_disposal_waits_for_the_same_withdrawal() { @@ -96,6 +114,34 @@ internal WaitChannelEndpoint() internal Task WithdrawalStarted => _withdrawalStarted.Task; + internal int SignalCount => Volatile.Read(ref _signals); + + internal bool HasWaiter + { + get + { + lock (_gate) + { + return _waiter is not null; + } + } + } + + internal void FailWait() + { + lock (_gate) + { + if (_waiter is null) + { + throw new InvalidOperationException("No waiter is registered."); + } + + _waiter.TrySetException(new TmuxTransportException( + "The waiting client failed.", + ["wait-for"])); + } + } + internal void ReleaseWithdrawal() => _releaseWithdrawal.TrySetResult(); private async Task ExecuteAsync( From 0fddfb2684b30521c569c9e8bb2fac7f05831577 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 30 Aug 2026 07:55:38 -0500 Subject: [PATCH 121/129] Waiting(fix[withdrawal]): Classify completed failures why: Signalling a wait that never registered seeds a pending channel event for the next caller instead of withdrawing anything. what: - skip withdrawal for command and not-dispatched failures - retain conservative cleanup for canceled and uncertain waits - prove next-caller state and the existing fault policy --- src/LibTmux/Waiting/TmuxWaitChannel.cs | 32 ++++++- .../Mcp/WaitChannelAttributionTests.cs | 90 ++++++++++++++++--- 2 files changed, 111 insertions(+), 11 deletions(-) diff --git a/src/LibTmux/Waiting/TmuxWaitChannel.cs b/src/LibTmux/Waiting/TmuxWaitChannel.cs index e00b925..9c2ac63 100644 --- a/src/LibTmux/Waiting/TmuxWaitChannel.cs +++ b/src/LibTmux/Waiting/TmuxWaitChannel.cs @@ -113,6 +113,11 @@ internal Task WaitUntilSignalledAsync(CancellationToken cancellationToken) => /// deregister one on its own, so withdrawing here also completes any other /// wait open on the same channel. Keep one open wait per channel. /// + /// + /// A wait that did not dispatch or returned a command failure never + /// registered, so disposal does not signal it. Command failures and + /// cancellation remain cleanup-only; other failures remain observable. + /// /// public ValueTask DisposeAsync() { @@ -130,7 +135,7 @@ public ValueTask DisposeAsync() private async Task DisposeCoreAsync() { - if (!_waiter.IsCompletedSuccessfully) + if (WaitMayRemainRegistered()) { _withdrew = true; try @@ -159,4 +164,29 @@ await _server.WaitForAsync( // Same: the wait is being withdrawn, not observed. } } + + private bool WaitMayRemainRegistered() + { + if (!_waiter.IsCompleted) + { + return true; + } + + if (_waiter.IsCompletedSuccessfully) + { + return false; + } + + if (!_waiter.IsFaulted) + { + return true; + } + + Exception failure = _waiter.Exception!.GetBaseException(); + return failure is not TmuxCommandException + && failure is not LibTmuxException + { + Dispatch: TmuxDispatchState.NotDispatched, + }; + } } diff --git a/tests/LibTmux.UnitTests/Mcp/WaitChannelAttributionTests.cs b/tests/LibTmux.UnitTests/Mcp/WaitChannelAttributionTests.cs index 9646ce7..a421c91 100644 --- a/tests/LibTmux.UnitTests/Mcp/WaitChannelAttributionTests.cs +++ b/tests/LibTmux.UnitTests/Mcp/WaitChannelAttributionTests.cs @@ -14,18 +14,68 @@ public async Task Faulted_wait_is_still_withdrawn_during_disposal() CancellationToken token = TestContext.Current.CancellationToken; var endpoint = new WaitChannelEndpoint(); TmuxWaitChannel wait = endpoint.Server.OpenWaitChannel("faulted-wait"); - endpoint.FailWait(); - _ = await Assert.ThrowsAsync( + var failure = new TmuxTransportException( + "The waiting client failed.", + ["wait-for"]); + endpoint.FailWait(failure, registrationRemains: true); + TmuxTransportException observed = await Assert.ThrowsAsync( () => wait.WaitAsync(TimeSpan.FromSeconds(1), token)); + Assert.Same(failure, observed); endpoint.ReleaseWithdrawal(); - _ = await Assert.ThrowsAsync( + TmuxTransportException disposalFailure = await Assert.ThrowsAsync( () => wait.DisposeAsync().AsTask()); + Assert.Same(failure, disposalFailure); Assert.Equal(1, endpoint.SignalCount); Assert.False(endpoint.HasWaiter); } + [Fact] + public async Task Not_dispatched_failure_does_not_seed_the_next_wait() + { + CancellationToken token = TestContext.Current.CancellationToken; + var endpoint = new WaitChannelEndpoint(); + const string channel = "not-dispatched"; + TmuxWaitChannel wait = endpoint.Server.OpenWaitChannel(channel); + var failure = new TmuxTransportException( + "The waiting client did not start.", + ["wait-for"], + TmuxDispatchState.NotDispatched); + endpoint.FailWait(failure, registrationRemains: false); + TmuxTransportException observed = await Assert.ThrowsAsync( + () => wait.WaitAsync(TimeSpan.FromSeconds(1), token)); + Assert.Same(failure, observed); + endpoint.ReleaseWithdrawal(); + + TmuxTransportException disposalFailure = await Assert.ThrowsAsync( + () => wait.DisposeAsync().AsTask()); + + Assert.Same(failure, disposalFailure); + await AssertNoPendingSignalAsync(endpoint.Server, channel, token); + } + + [Fact] + public async Task Command_failure_does_not_seed_the_next_wait_or_escape_disposal() + { + CancellationToken token = TestContext.Current.CancellationToken; + var endpoint = new WaitChannelEndpoint(); + const string channel = "command-failure"; + TmuxWaitChannel wait = endpoint.Server.OpenWaitChannel(channel); + var failure = new TmuxCommandException( + "tmux refused the wait.", + WaitChannelEndpoint.Failure(["wait-for", channel])); + endpoint.FailWait(failure, registrationRemains: false); + TmuxCommandException observed = await Assert.ThrowsAsync( + () => wait.WaitAsync(TimeSpan.FromSeconds(1), token)); + Assert.Same(failure, observed); + endpoint.ReleaseWithdrawal(); + + await wait.DisposeAsync(); + + await AssertNoPendingSignalAsync(endpoint.Server, channel, token); + } + [Fact] public async Task Concurrent_disposal_waits_for_the_same_withdrawal() { @@ -91,6 +141,17 @@ await server.WaitForAsync( Assert.Equal("Channel 'attribution-race' was signalled.", next.Changed); } + private static async Task AssertNoPendingSignalAsync( + Server server, + string channel, + CancellationToken cancellationToken) + { + await using TmuxWaitChannel next = server.OpenWaitChannel(channel); + Assert.False(await next.WaitAsync( + TimeSpan.FromMilliseconds(10), + cancellationToken)); + } + private sealed class WaitChannelEndpoint { private readonly object _gate = new(); @@ -127,19 +188,20 @@ internal bool HasWaiter } } - internal void FailWait() + internal void FailWait(Exception failure, bool registrationRemains) { + TaskCompletionSource waiter; lock (_gate) { - if (_waiter is null) + waiter = _waiter + ?? throw new InvalidOperationException("No waiter is registered."); + if (!registrationRemains) { - throw new InvalidOperationException("No waiter is registered."); + _waiter = null; } - - _waiter.TrySetException(new TmuxTransportException( - "The waiting client failed.", - ["wait-for"])); } + + waiter.TrySetException(failure); } internal void ReleaseWithdrawal() => _releaseWithdrawal.TrySetResult(); @@ -211,5 +273,13 @@ private void Signal(IReadOnlyList arguments) ReadOnlyMemory.Empty, [], []); + + internal static TmuxCommandResult Failure(IReadOnlyList arguments) => new( + arguments, + 1, + ReadOnlyMemory.Empty, + ReadOnlyMemory.Empty, + [], + ["wait failed"]); } } From 4b9f639cd4a745ebe4cb38acc88b7ff49963e2bf Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 30 Aug 2026 07:58:37 -0500 Subject: [PATCH 122/129] Waiting(docs[comment]): Clarify expired attempts --- src/LibTmux/Waiting/TmuxWaitChannel.cs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/LibTmux/Waiting/TmuxWaitChannel.cs b/src/LibTmux/Waiting/TmuxWaitChannel.cs index 9c2ac63..382f896 100644 --- a/src/LibTmux/Waiting/TmuxWaitChannel.cs +++ b/src/LibTmux/Waiting/TmuxWaitChannel.cs @@ -14,10 +14,9 @@ namespace LibTmux; /// /// /// So this never abandons a live waiter. returning -/// false means the signal has not arrived yet, not that waiting stopped: the -/// registration still stands, and the next attempt sees a signal that landed in -/// between. Disposing withdraws the waiter deliberately, which is the only safe -/// way to stop. +/// false means that attempt expired without observing completion; the +/// registration remains and may already have received a racing signal. +/// Disposing withdraws the waiter deliberately. /// /// [UnsupportedOSPlatform("windows")] From b8077ff9632cf55907f07460cb95da5e10662844 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 30 Aug 2026 08:08:50 -0500 Subject: [PATCH 123/129] Waiting(docs[comment]): Distinguish wait ownership --- src/LibTmux/Waiting/TmuxWaitChannel.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/LibTmux/Waiting/TmuxWaitChannel.cs b/src/LibTmux/Waiting/TmuxWaitChannel.cs index 382f896..cc2ef03 100644 --- a/src/LibTmux/Waiting/TmuxWaitChannel.cs +++ b/src/LibTmux/Waiting/TmuxWaitChannel.cs @@ -14,9 +14,9 @@ namespace LibTmux; /// /// /// So this never abandons a live waiter. returning -/// false means that attempt expired without observing completion; the -/// registration remains and may already have received a racing signal. -/// Disposing withdraws the waiter deliberately. +/// false means that attempt expired without observing completion. The open +/// wait remains owned and may already have completed from a racing signal. +/// Disposing ends its lifetime deliberately. /// /// [UnsupportedOSPlatform("windows")] @@ -78,8 +78,8 @@ public async Task WaitAsync( // A cancelled caller wins over a waiter that happened to finish in the // same moment, so the outcome does not depend on which raced first. - // Nothing is lost by that: the waiter is still registered, and only - // disposal withdraws it. + // The open wait remains owned either way, and a later attempt observes + // any completion. Disposal alone ends its lifetime. cancellationToken.ThrowIfCancellationRequested(); if (first != _waiter) { From 1070d9349a352a426d3c222b839c74fd93b161c2 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 30 Aug 2026 08:57:25 -0500 Subject: [PATCH 124/129] Docs(fix[changelog]): Record user-visible changes --- CHANGELOG.md | 115 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bb7986..0e84869 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,120 @@ Versions follow [Semantic Versioning](https://semver.org). During alpha the public API can change in any release with no deprecation period — pin an exact version. +## [Unreleased] + +### Added + +- `TmuxChain.Then(IEnumerable)` accepts a sequence without + requiring callers to unroll multi-command requests. (#18) + +- `Server.OpenWaitChannel` returns a `TmuxWaitChannel` whose open wait + survives repeated timed attempts and is withdrawn on disposal. `WaitAsync` + returning `false` means the attempt expired without observing attributable + completion; a racing signal may already have completed the wait. Concurrent + disposal joins the same cleanup. (#18) + +- `WorkspaceBuilder` accepts a readiness timeout and `PaneReadiness.Auto`, + `Always`, or `Never`, so callers choose whether pane commands wait for a + prompt-like state. (#18) + +- tmux 3.7c joins the supported matrix on both target frameworks. Stable + releases inside supported intervals no longer lose capabilities solely + because their exact patch version is absent from the catalog. (#18) + +### Changed + +- **`IControlModeSession.SendAsync` now takes a `TmuxCommand` instead of a + command-line string.** Construct or use a typed command; its arguments are + copied and NUL is rejected. tmux refusals now throw + `ControlModeCommandException` with the command, output, and error lines. + (#18) + +- **`LibTmux.Query.Json` now reads and writes the canonical `libtmux-query` v1 + shape shipped in its schema.** Regenerate or migrate documents emitted by + earlier alpha builds before upgrading; unknown or duplicate members and + invalid fields or operators now fail at the boundary. (#18) + +- **The workspace model is immutable, and `WorkspacePane.ShellCommand` becomes + `ShellCommands`.** Construct `WorkspaceFile`, `WorkspaceWindow`, and + `WorkspacePane` instead of setting properties; supplied collections are + copied. `WorkspaceResult` freezes and compares collection contents. (#18) + +- **`WorkspaceFile.Parse` now rejects unknown or duplicate keys, unsupported + tmuxp hooks and plugins, wrong value shapes, multiple documents, and + oversized input instead of ignoring them.** Use the documented closed subset + or run tmuxp for its Python features. (#18) + +- **`NewWindowRequest.ToCommand` now takes the owning `Session` instead of a + target string.** Pass the session so the command carries its server + generation. (#18) + +- **`JobStore` no longer implements `IDisposable`.** Dispose it with + `await using` or `DisposeAsync`; containers that own one must also dispose + asynchronously. (#18) + +- **`ServerSnapshot`, the non-generic `CapturedRelation` factories, + `SnapshotLookup`, `SnapshotCollectionExtensions`, `EnumConstant`, + `InstantConstant`, and `LibTmux.Internal.TmuxCommandContext` are no longer + public.** Use relations on materialized handles and LINQ `ToDictionary`; the + removed query constants and internal context had no supported evaluation or + construction path. (#18) + +- `Pane.RefreshAsync`, `Window.RefreshAsync`, `Session.RefreshAsync`, + environment resolution, and server identifier lookups read only the + requested entity instead of listing its whole hierarchy level. Linked + windows and panes remain scoped to the session that produced them. (#18) + +- **`tmux_wait_for_channel` moves from `readonly` to `mutating` and is marked + destructive.** A readonly MCP server no longer advertises it because + consuming a pending signal changes shared tmux state. (#18) + +- **`StaleServerGenerationException.Actual` is nullable.** Null-check it; a + replacement generation cannot always be observed safely. (#18) + +### Fixed + +- Control-mode callers keep their own replies across concurrent commands, + aliases, hooks, cancellation, and disposal. Malformed or truncated streams + and pump failures now fault callers; request, output, pending-work, and + disposal bounds prevent unbounded retention. (#18) + +- Query evaluation preserves integer, null, typed-ID, relation-count, overload, + and invariant-regex semantics. Cancellable matching and fail-closed + structural limits apply before recursion, and trimmed or AOT consumers + retain required metadata. (#18) + +- Identifier and relation lookups return handles with their owning `Server`, so + their relations, options, and hooks work. A restarted endpoint is refused + rather than rebound to a reused object identifier. (#18) + +- Commands built from `Pane`, `Window`, `Session`, `TmuxOptions`, or + `TmuxHooks` retain their originating server generation and are refused after + a restart instead of acting on reused identifiers. (#18) + +- `tmux_wait_for_channel` and `tmux_run` withdraw timed-out registrations, so + retries no longer leave waiters that consume future signals. A signal racing + withdrawal remains pending and is reported as unattributable. Failures that + never registered preserve their original error without seeding a signal for + the next caller. (#18) + +- MCP shutdown asynchronously disposes factory-owned activity and job + resources, withdraws waits held by unfinished jobs, and leaves caller-owned + resources untouched. (#18) + +- MCP hierarchy subscriptions invalidate on active-window and client-session + changes, so existing subscribers receive the changed payload. (#18) + +- Workspace building preserves scalar and ordered pane commands, uses the first + pane's directory, lets the last focus flag win, reports rejected layouts, + and waits for readiness without injecting probe text into pane input. (#18) + +- `Pane.SendTextAsync` always sends literal text, so key-like words are not + interpreted as tmux key actions. (#18) + +- `Window.SelectLayoutAsync` rejects malformed custom layout prefixes before + dispatch instead of handing them to older tmux parsers. (#18) + ## [0.0.0-alpha.9] — 2026-08-22 ### Added @@ -324,6 +438,7 @@ it is: a published version can never be deleted from nuget.org, only unlisted. - `LibTmux.Workspace` — sessions from tmuxp workspace files. - `LibTmux.Mcp` — a Model Context Protocol server, installed as a .NET tool. +[Unreleased]: https://github.com/libtmux/libtmux-dotnet/compare/v0.0.0-alpha.9...HEAD [0.0.0-alpha.9]: https://github.com/libtmux/libtmux-dotnet/releases/tag/v0.0.0-alpha.9 [0.0.0-alpha.8]: https://github.com/libtmux/libtmux-dotnet/releases/tag/v0.0.0-alpha.8 [0.0.0-alpha.7]: https://github.com/libtmux/libtmux-dotnet/releases/tag/v0.0.0-alpha.7 From 911a93539706c7263a16b0bc70712583647028eb Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 30 Aug 2026 12:12:04 -0500 Subject: [PATCH 125/129] Client(fix[control-mode]): Use tmux field name why: The public control-client flag and query wire contract read a key that snapshot hydration never populated. what: - Use client_control_mode from hydration through query serialization. - Cover a real attached control client and the translated wire name. --- README.md | 4 ++-- src/LibTmux.Query.Json/README.md | 2 +- .../libtmux-query-v1.schema.json | 6 ++--- src/LibTmux/Client.Administration.cs | 2 +- src/LibTmux/Query/QueryFieldCatalog.cs | 2 +- src/LibTmux/Query/QueryTranslator.cs | 6 ++--- src/LibTmux/README.md | 4 ++-- .../Clients/ClientAdministrationTests.cs | 22 +++++++++++++++++++ .../Query/QuerySemanticsTests.cs | 2 +- 9 files changed, 35 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 787e955..0fdfd7d 100644 --- a/README.md +++ b/README.md @@ -185,8 +185,8 @@ Console.WriteLine(document.Target); // Session ``` The document uses stable wire names: `Session.Name` becomes `session_name`, -and `Client.IsControlClient` becomes `client_control`. The catalog carries that -pair for all twelve queryable fields and rejects anything outside it. Typed +and `Client.IsControlClient` becomes `client_control_mode`. The catalog carries +that pair for all twelve queryable fields and rejects anything outside it. Typed queries evaluate locally over captured objects; they are never assembled into tmux's executable format language. [LibTmux.Query.Json](src/LibTmux.Query.Json/README.md) puts the document on an application-controlled wire. `UnsafeTmuxFilter` is the diff --git a/src/LibTmux.Query.Json/README.md b/src/LibTmux.Query.Json/README.md index e840498..c482aad 100644 --- a/src/LibTmux.Query.Json/README.md +++ b/src/LibTmux.Query.Json/README.md @@ -110,7 +110,7 @@ Console.WriteLine($"depth {QueryJsonLimits.V1.MaximumDepth}, nodes {QueryJsonLim `session_name`, `session_attached`, `session_id`, `session_windows`, `window_name`, `window_id`, `window_panes`, `pane_id`, `pane_command`, -`client_id`, `client_name`, `client_control`. +`client_id`, `client_name`, `client_control_mode`. You write these as the properties they are — `Session.Name`, `Client.IsControlClient` — and the wire carries the tmux spelling. diff --git a/src/LibTmux.Query.Json/libtmux-query-v1.schema.json b/src/LibTmux.Query.Json/libtmux-query-v1.schema.json index a769162..aff35dd 100644 --- a/src/LibTmux.Query.Json/libtmux-query-v1.schema.json +++ b/src/LibTmux.Query.Json/libtmux-query-v1.schema.json @@ -50,7 +50,7 @@ "target": { "$ref": "#/$defs/target" }, "wireName": { "enum": [ - "client_control", + "client_control_mode", "client_id", "client_name", "pane_command", @@ -99,7 +99,7 @@ "properties": { "target": { "const": "client" }, "wireName": { - "enum": ["client_control", "client_id", "client_name"] + "enum": ["client_control_mode", "client_id", "client_name"] } } } @@ -112,7 +112,7 @@ { "$ref": "#/$defs/field" }, { "properties": { - "wireName": { "enum": ["client_control", "session_attached"] } + "wireName": { "enum": ["client_control_mode", "session_attached"] } } } ] diff --git a/src/LibTmux/Client.Administration.cs b/src/LibTmux/Client.Administration.cs index 58b669e..4589295 100644 --- a/src/LibTmux/Client.Administration.cs +++ b/src/LibTmux/Client.Administration.cs @@ -41,7 +41,7 @@ internal Client( public string? Tty => ReadSnapshot("client_tty"); /// Gets whether the client speaks tmux's control protocol. - public bool IsControlClient => ReadSnapshot("client_control") == "1"; + public bool IsControlClient => ReadSnapshot("client_control_mode") == "1"; /// Gets the session the client was attached to when it was read. /// diff --git a/src/LibTmux/Query/QueryFieldCatalog.cs b/src/LibTmux/Query/QueryFieldCatalog.cs index 03abe33..2528515 100644 --- a/src/LibTmux/Query/QueryFieldCatalog.cs +++ b/src/LibTmux/Query/QueryFieldCatalog.cs @@ -8,7 +8,7 @@ internal static class QueryFieldCatalog private static readonly FieldDefinition[] Fields = [ new( - "client_control", + "client_control_mode", QueryTarget.Client, QueryValueKind.Boolean, typeof(Client), diff --git a/src/LibTmux/Query/QueryTranslator.cs b/src/LibTmux/Query/QueryTranslator.cs index 753343a..ed87791 100644 --- a/src/LibTmux/Query/QueryTranslator.cs +++ b/src/LibTmux/Query/QueryTranslator.cs @@ -229,10 +229,8 @@ private static QueryNode TranslateOperand( private static FieldNode FieldFor(MemberInfo member) { - // What tmux calls a field is not a transformation of what C# calls - // it -- Client.IsControlClient is client_control, not - // is_control_client. The catalog carries that pairing; an unknown - // type is a caller's own row, whose property names are wire names already. + // Entity properties use cataloged tmux names. Caller-defined + // projections already name their wire fields. string wireName = member.DeclaringType is { } owner && QueryFieldCatalog.TryGetWireName(owner, member.Name, out string mapped) diff --git a/src/LibTmux/README.md b/src/LibTmux/README.md index 9915145..f4bd1d0 100644 --- a/src/LibTmux/README.md +++ b/src/LibTmux/README.md @@ -217,8 +217,8 @@ QueryDocument document = QueryExtensions.Translate( ``` The document carries stable wire names: `Session.Name` is `session_name` and -`Client.IsControlClient` is `client_control`. The catalog is closed over twelve -queryable fields: +`Client.IsControlClient` is `client_control_mode`. The catalog is closed over +twelve queryable fields: | Session | Window | Pane | Client | |---|---|---|---| diff --git a/tests/LibTmux.IntegrationTests/Clients/ClientAdministrationTests.cs b/tests/LibTmux.IntegrationTests/Clients/ClientAdministrationTests.cs index 3116481..5e39992 100644 --- a/tests/LibTmux.IntegrationTests/Clients/ClientAdministrationTests.cs +++ b/tests/LibTmux.IntegrationTests/Clients/ClientAdministrationTests.cs @@ -9,6 +9,28 @@ namespace LibTmux.IntegrationTests.Clients; [UnsupportedOSPlatform("windows")] public sealed class ClientAdministrationTests { + [Fact( + Skip = "Requires a Unix process environment.", + SkipType = typeof(UnixTestEnvironment), + SkipUnless = nameof(UnixTestEnvironment.IsUnix))] + public async Task Control_client_preserves_tmux_control_mode_field() + { + await using RawTmuxTestContext raw = await RawTmuxTestContext.StartAsync( + TestContext.Current.CancellationToken); + CancellationToken token = TestContext.Current.CancellationToken; + Server server = await ConnectAsync(raw, token); + await using ControlModeClientScope attached = await ControlModeClientScope.StartAsync( + raw, + token); + + Client client = Assert.Single( + await server.GetClientsAsync(token), + candidate => candidate.Name == attached.ClientName); + + Assert.Equal("1", client.RawFormatFields["client_control_mode"]); + Assert.True(client.IsControlClient); + } + [Fact( Skip = "Requires a Unix process environment.", SkipType = typeof(UnixTestEnvironment), diff --git a/tests/LibTmux.UnitTests/Query/QuerySemanticsTests.cs b/tests/LibTmux.UnitTests/Query/QuerySemanticsTests.cs index 3da692c..d57a4a4 100644 --- a/tests/LibTmux.UnitTests/Query/QuerySemanticsTests.cs +++ b/tests/LibTmux.UnitTests/Query/QuerySemanticsTests.cs @@ -82,7 +82,7 @@ public void An_entity_translates_through_the_name_tmux_uses_for_the_field() // The one that a naming rule would never produce. Assert.Equal( - "client_control", + "client_control_mode", Field(QueryExtensions.Translate(client => client.IsControlClient))); } From 75620831661d20ff713cabba57718a59c178bc63 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 30 Aug 2026 12:22:35 -0500 Subject: [PATCH 126/129] Workspace(fix[failure]): Report partial state why: Nontransactional workspace failures left callers without exact handles for the tmux state already created. what: - Wrap application failures with a typed partial result. - Preserve the original failure and dispatch certainty. - Record windows before later configuration can fail. --- src/LibTmux.Workspace/PublicAPI.Unshipped.txt | 3 + src/LibTmux.Workspace/README.md | 8 +- .../WorkspaceBuildException.cs | 28 ++++++ src/LibTmux.Workspace/WorkspaceBuilder.cs | 90 ++++++++++++------- src/LibTmux.Workspace/WorkspaceResult.cs | 6 +- .../Workspace/WorkspaceBuilderTests.cs | 9 +- .../Workspace/WorkspaceResultTests.cs | 14 +++ 7 files changed, 118 insertions(+), 40 deletions(-) create mode 100644 src/LibTmux.Workspace/WorkspaceBuildException.cs diff --git a/src/LibTmux.Workspace/PublicAPI.Unshipped.txt b/src/LibTmux.Workspace/PublicAPI.Unshipped.txt index 37c0923..0457050 100644 --- a/src/LibTmux.Workspace/PublicAPI.Unshipped.txt +++ b/src/LibTmux.Workspace/PublicAPI.Unshipped.txt @@ -3,6 +3,9 @@ LibTmux.Workspace.PaneReadiness LibTmux.Workspace.PaneReadiness.Always = 1 -> LibTmux.Workspace.PaneReadiness LibTmux.Workspace.PaneReadiness.Auto = 0 -> LibTmux.Workspace.PaneReadiness LibTmux.Workspace.PaneReadiness.Never = 2 -> LibTmux.Workspace.PaneReadiness +LibTmux.Workspace.WorkspaceBuildException +LibTmux.Workspace.WorkspaceBuildException.PartialResult.get -> LibTmux.Workspace.WorkspaceResult? +LibTmux.Workspace.WorkspaceBuildException.WorkspaceBuildException(LibTmux.Workspace.WorkspaceResult? partialResult, System.Exception! failure) -> void LibTmux.Workspace.WorkspaceBuilder LibTmux.Workspace.WorkspaceBuilder.BuildAsync(LibTmux.Workspace.WorkspaceFile! workspace, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! LibTmux.Workspace.WorkspaceBuilder.WorkspaceBuilder(LibTmux.Server! server, System.TimeSpan? readinessTimeout = null, LibTmux.Workspace.PaneReadiness paneReadiness = LibTmux.Workspace.PaneReadiness.Auto) -> void diff --git a/src/LibTmux.Workspace/README.md b/src/LibTmux.Workspace/README.md index 59951c6..477e5bc 100644 --- a/src/LibTmux.Workspace/README.md +++ b/src/LibTmux.Workspace/README.md @@ -67,9 +67,11 @@ rebased to the directory containing `session.yaml`. `BuildAsync` returns the session and windows it created. Its `Unsupported` list contains only layouts that tmux rejected; those windows remain usable. -Other tmux failures throw and can leave a partially built session. The builder -is not transactional. Before sending workspace commands, `PaneReadiness.Auto`, -the default, waits only for panes using a zsh session `default-shell`. +Other tmux failures throw `WorkspaceBuildException`. Its `PartialResult` +contains the session and windows materialized before failure, or is null when +none could be read. The builder is not transactional. Before sending workspace +commands, `PaneReadiness.Auto`, the default, waits only for panes using a zsh +session `default-shell`. `PaneReadiness.Always` waits before commands sent to every default-shell pane; `PaneReadiness.Never` sends them immediately. A nonempty session `default-command` skips the wait under every policy because that command is not diff --git a/src/LibTmux.Workspace/WorkspaceBuildException.cs b/src/LibTmux.Workspace/WorkspaceBuildException.cs new file mode 100644 index 0000000..15b1cbf --- /dev/null +++ b/src/LibTmux.Workspace/WorkspaceBuildException.cs @@ -0,0 +1,28 @@ +namespace LibTmux.Workspace; + +/// Reports a workspace failure and the tmux state created before it. +public sealed class WorkspaceBuildException : LibTmuxException +{ + /// Initializes a workspace build exception. + /// The materialized state, or null when none was read. + /// The operation that failed. + public WorkspaceBuildException(WorkspaceResult? partialResult, Exception failure) + : base( + partialResult is null + ? "Workspace construction failed before tmux state was materialized." + : "Workspace construction failed; PartialResult reports materialized tmux state.", + DispatchFor(failure), + failure) + => PartialResult = partialResult; + + /// Gets the session and windows materialized before failure, when known. + public WorkspaceResult? PartialResult { get; } + + private static TmuxDispatchState DispatchFor(Exception failure) + { + ArgumentNullException.ThrowIfNull(failure); + return failure is LibTmuxException tmuxFailure + ? tmuxFailure.Dispatch + : TmuxDispatchState.Unknown; + } +} diff --git a/src/LibTmux.Workspace/WorkspaceBuilder.cs b/src/LibTmux.Workspace/WorkspaceBuilder.cs index 8ed050f..d6a5eb4 100644 --- a/src/LibTmux.Workspace/WorkspaceBuilder.cs +++ b/src/LibTmux.Workspace/WorkspaceBuilder.cs @@ -50,8 +50,8 @@ public WorkspaceBuilder( /// Cancels the tmux commands. /// What was built, and what could not be. /// The workspace describes no session. - /// - /// A pane did not reach a prompt-like state before its readiness timeout. + /// + /// tmux failed after application began. The exception reports any materialized state. /// /// /// Readiness is inferred from the pane's current command and cursor position. @@ -73,20 +73,47 @@ public async Task BuildAsync( throw new WorkspaceFormatException("The workspace describes no windows."); } + Session? session = null; + List windows = []; List unsupported = []; + try + { + WorkspaceWindow first = workspace.Windows[0]; + // tmux starts the first pane before session options exist. A + // bootstrap keeps the session alive until the real window exists. + session = await _server.CreateSessionAsync( + new NewSessionRequest( + name: workspace.SessionName, + windowName: BootstrapWindowName, + startDirectory: StartDirectoryFor(first, workspace), + command: "/bin/sh"), + cancellationToken) + .ConfigureAwait(false); + return await CompleteAsync( + session, + workspace, + windows, + unsupported, + cancellationToken) + .ConfigureAwait(false); + } + catch (Exception failure) + { + WorkspaceResult? partial = session is null + ? null + : new WorkspaceResult(session, windows, unsupported); + throw new WorkspaceBuildException(partial, failure); + } + } - // tmux creates the first pane before session options exist. This - // bootstrap keeps the session alive until the described first window - // can be spawned under those options. + private async Task CompleteAsync( + Session session, + WorkspaceFile workspace, + List windows, + List unsupported, + CancellationToken cancellationToken) + { WorkspaceWindow first = workspace.Windows[0]; - Session session = await _server.CreateSessionAsync( - new NewSessionRequest( - name: workspace.SessionName, - windowName: BootstrapWindowName, - startDirectory: StartDirectoryFor(first, workspace), - command: "/bin/sh"), - cancellationToken) - .ConfigureAwait(false); await ApplyOptionsAsync(session.Options, workspace.Options, cancellationToken) .ConfigureAwait(false); @@ -120,16 +147,15 @@ await ApplyOptionsAsync(session.Options, workspace.Options, cancellationToken) await bootstrap.KillAsync(cancellationToken: cancellationToken).ConfigureAwait(false); } - List windows = []; - windows.Add( - await FillAsync( - firstWindow, - first, - workspace, - unsupported, - expectedShellCommand, - cancellationToken) - .ConfigureAwait(false)); + windows.Add(firstWindow); + windows[0] = await FillAsync( + firstWindow, + first, + workspace, + unsupported, + expectedShellCommand, + cancellationToken) + .ConfigureAwait(false); foreach (WorkspaceWindow described in workspace.Windows.Skip(1)) { @@ -139,15 +165,15 @@ await FillAsync( startDirectory: StartDirectoryFor(described, workspace)), cancellationToken) .ConfigureAwait(false); - windows.Add( - await FillAsync( - window, - described, - workspace, - unsupported, - expectedShellCommand, - cancellationToken) - .ConfigureAwait(false)); + windows.Add(window); + windows[^1] = await FillAsync( + window, + described, + workspace, + unsupported, + expectedShellCommand, + cancellationToken) + .ConfigureAwait(false); } // Selecting last means the file's focus wins over the side effects of diff --git a/src/LibTmux.Workspace/WorkspaceResult.cs b/src/LibTmux.Workspace/WorkspaceResult.cs index ddfc5d3..38e965d 100644 --- a/src/LibTmux.Workspace/WorkspaceResult.cs +++ b/src/LibTmux.Workspace/WorkspaceResult.cs @@ -2,7 +2,7 @@ namespace LibTmux.Workspace; -/// Describes a built workspace and any layout tmux rejected. +/// Describes workspace state materialized by a build. public sealed record WorkspaceResult { private Session _session = null!; @@ -23,7 +23,7 @@ public WorkspaceResult( this.Unsupported = Unsupported; } - /// Gets the session that was built. + /// Gets the session materialized by the build. public Session Session { get => _session; @@ -34,7 +34,7 @@ public Session Session } } - /// Gets the windows, in the order the file listed them. + /// Gets materialized windows in workspace order. public IReadOnlyList Windows { get => _windows; diff --git a/tests/LibTmux.IntegrationTests/Workspace/WorkspaceBuilderTests.cs b/tests/LibTmux.IntegrationTests/Workspace/WorkspaceBuilderTests.cs index 142b1b2..ae3b117 100644 --- a/tests/LibTmux.IntegrationTests/Workspace/WorkspaceBuilderTests.cs +++ b/tests/LibTmux.IntegrationTests/Workspace/WorkspaceBuilderTests.cs @@ -207,19 +207,24 @@ public async Task Readiness_timeout_writes_nothing_to_the_pane() - shell_command: echo WORKSPACE_USER_COMMAND """); - TmuxWaitTimeoutException failure = await Assert.ThrowsAsync( + WorkspaceBuildException failure = await Assert.ThrowsAsync( () => new WorkspaceBuilder( scope.Server, TimeSpan.FromMilliseconds(250), PaneReadiness.Always) .BuildAsync(workspace, token)); - Assert.Equal(TimeSpan.FromMilliseconds(250), failure.Timeout); + TmuxWaitTimeoutException timeout = Assert.IsType( + failure.InnerException); + Assert.Equal(TimeSpan.FromMilliseconds(250), timeout.Timeout); Assert.False(File.Exists(received)); Server server = await scope.Server.ConnectAsync(token); Session session = Assert.Single(await server.GetSessionsAsync(token)); Window window = Assert.Single(await session.GetWindowsAsync(token)); Pane pane = Assert.Single(await window.GetPanesAsync(token)); + WorkspaceResult partial = Assert.IsType(failure.PartialResult); + Assert.Equal(session.Id, partial.Session.Id); + Assert.Equal(window.Id, Assert.Single(partial.Windows).Id); string captured = string.Join( '\n', await pane.CaptureAsync(cancellationToken: token)); diff --git a/tests/LibTmux.IntegrationTests/Workspace/WorkspaceResultTests.cs b/tests/LibTmux.IntegrationTests/Workspace/WorkspaceResultTests.cs index 77baa70..469e301 100644 --- a/tests/LibTmux.IntegrationTests/Workspace/WorkspaceResultTests.cs +++ b/tests/LibTmux.IntegrationTests/Workspace/WorkspaceResultTests.cs @@ -5,6 +5,20 @@ namespace LibTmux.IntegrationTests; public sealed class WorkspaceResultTests { + [Fact] + public void Build_failure_preserves_the_operation_and_dispatch_state() + { + var operation = new LibTmuxException( + "tmux refused the operation", + TmuxDispatchState.NotDispatched); + + var failure = new WorkspaceBuildException(null, operation); + + Assert.Same(operation, failure.InnerException); + Assert.Equal(TmuxDispatchState.NotDispatched, failure.Dispatch); + Assert.Null(failure.PartialResult); + } + [Fact] public void Collection_initializers_snapshot_their_inputs() { From c5555c40e09ec015206b671b6d19e57890c10ee3 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 30 Aug 2026 12:25:45 -0500 Subject: [PATCH 127/129] Docs(fix[examples]): Avoid duplicate Enter why: SendTextAsync submits its text by default, so a following EnterAsync teaches and tests an extra blank command. what: - Remove redundant EnterAsync calls from examples and package smoke code. - Resynchronize the published snippets. --- README.md | 2 -- examples/LibTmux.Examples/Snippets/OneShot.cs | 1 - examples/LibTmux.Examples/Snippets/Tour.cs | 1 - src/LibTmux/README.md | 2 -- tests/LibTmux.PackageConsumer/Program.cs | 1 - 5 files changed, 7 deletions(-) diff --git a/README.md b/README.md index 0fdfd7d..044c3a9 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,6 @@ Window window = await session.CreateWindowAsync(new NewWindowRequest(name: "test Pane pane = (await window.GetPanesAsync())[0]; await pane.SendTextAsync("dotnet test"); -await pane.EnterAsync(); ``` @@ -244,7 +243,6 @@ TmuxTestFactory factory = new(); await using TemporaryHierarchyScope scope = await factory.CreateHierarchyAsync(); await scope.Pane.SendTextAsync("echo hello"); -await scope.Pane.EnterAsync(); ``` Disposing kills the server, so a test that fails part way through leaves diff --git a/examples/LibTmux.Examples/Snippets/OneShot.cs b/examples/LibTmux.Examples/Snippets/OneShot.cs index f1b8d74..da06ccd 100644 --- a/examples/LibTmux.Examples/Snippets/OneShot.cs +++ b/examples/LibTmux.Examples/Snippets/OneShot.cs @@ -17,7 +17,6 @@ public static async Task ConnectAndBuild() Pane pane = (await window.GetPanesAsync())[0]; await pane.SendTextAsync("dotnet test"); - await pane.EnterAsync(); #endregion } diff --git a/examples/LibTmux.Examples/Snippets/Tour.cs b/examples/LibTmux.Examples/Snippets/Tour.cs index 8a9c8a0..dd62d79 100644 --- a/examples/LibTmux.Examples/Snippets/Tour.cs +++ b/examples/LibTmux.Examples/Snippets/Tour.cs @@ -31,7 +31,6 @@ public static async Task ShowHierarchy(Server server, Session session) public static async Task RunACommand(Pane pane) { await pane.SendTextAsync("echo the-pane-ran-this"); - await pane.EnterAsync(); // tmux answers a command once it has accepted it, not once the shell // has finished, so the result is waited for rather than assumed. diff --git a/src/LibTmux/README.md b/src/LibTmux/README.md index f4bd1d0..bdd35dc 100644 --- a/src/LibTmux/README.md +++ b/src/LibTmux/README.md @@ -27,7 +27,6 @@ Window window = await session.CreateWindowAsync(new NewWindowRequest(name: "test Pane pane = (await window.GetPanesAsync())[0]; await pane.SendTextAsync("dotnet test"); -await pane.EnterAsync(); ``` To reach one server in particular: @@ -265,7 +264,6 @@ TmuxTestFactory factory = new(); await using TemporaryHierarchyScope scope = await factory.CreateHierarchyAsync(); await scope.Pane.SendTextAsync("echo hello"); -await scope.Pane.EnterAsync(); ``` Disposing kills the server, so a test that fails part way through leaves diff --git a/tests/LibTmux.PackageConsumer/Program.cs b/tests/LibTmux.PackageConsumer/Program.cs index ce80644..3fd8672 100644 --- a/tests/LibTmux.PackageConsumer/Program.cs +++ b/tests/LibTmux.PackageConsumer/Program.cs @@ -109,7 +109,6 @@ private static async Task RunTmuxAsync() await using TemporaryHierarchyScope scope = await factory.CreateHierarchyAsync(options); await scope.Pane.SendTextAsync("echo consumed-from-the-package"); - await scope.Pane.EnterAsync(); string text = await TmuxWait.UntilAsync( async token => string.Join( '\n', From 7da43a560afa7d4356cf3f2d213eccaeb2a37557 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 30 Aug 2026 12:33:20 -0500 Subject: [PATCH 128/129] Tests(fix[wrapper]): Publish atomically why: Publishing an executable at its final path while it is still writable can make a concurrent exec fail with ETXTBSY. what: - Write and permission wrapper candidates before an atomic rename. - Make an immediate execution failure visible instead of retrying it. --- .../ControlMode/ControlModeSessionTests.cs | 53 +++++++++---------- 1 file changed, 26 insertions(+), 27 deletions(-) diff --git a/tests/LibTmux.IntegrationTests/ControlMode/ControlModeSessionTests.cs b/tests/LibTmux.IntegrationTests/ControlMode/ControlModeSessionTests.cs index 4b0e3bd..2e9fb07 100644 --- a/tests/LibTmux.IntegrationTests/ControlMode/ControlModeSessionTests.cs +++ b/tests/LibTmux.IntegrationTests/ControlMode/ControlModeSessionTests.cs @@ -398,11 +398,8 @@ exit 0 fi exec {{ShellQuote(raw.TmuxBinaryPath)}} "$@" """; - await File.WriteAllTextAsync(wrapper, script, token); - File.SetUnixFileMode( - wrapper, - UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); - await WaitUntilAsync(() => CanExecute(wrapper), token); + await WriteExecutableAsync(wrapper, script, token); + Assert.True(CanExecute(wrapper)); Server server = Server.Open(new ServerConnectionOptions( tmuxBinaryPath: wrapper, @@ -452,24 +449,11 @@ exit 0 done exec {ShellQuote(raw.TmuxBinaryPath)} "$@" """; - await File.WriteAllTextAsync( + await WriteExecutableAsync( wrapper, script, TestContext.Current.CancellationToken); - File.SetUnixFileMode( - wrapper, - UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); - - // Linux refuses to exec a file while any process holds a write - // descriptor for it. Process.Start forks, and the child keeps every - // inherited descriptor until it execs, so a sibling test starting a - // process while this wrapper is being written makes the first exec - // fail with ETXTBSY though the wrapper itself is correct. That - // descriptor goes when the child execs, so wait for the wrapper to - // run rather than racing it. - await WaitUntilAsync( - () => CanExecute(wrapper), - TestContext.Current.CancellationToken); + Assert.True(CanExecute(wrapper)); Server server = await Server.ConnectAsync( new ServerConnectionOptions( @@ -529,16 +513,11 @@ public async Task Startup_drains_standard_error_before_waiting_for_attach() done exec {ShellQuote(raw.TmuxBinaryPath)} "$@" """; - await File.WriteAllTextAsync( + await WriteExecutableAsync( wrapper, script, TestContext.Current.CancellationToken); - File.SetUnixFileMode( - wrapper, - UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); - await WaitUntilAsync( - () => CanExecute(wrapper), - TestContext.Current.CancellationToken); + Assert.True(CanExecute(wrapper)); Server server = await Server.ConnectAsync( new ServerConnectionOptions( @@ -570,6 +549,26 @@ private static Task ConnectAsync( configurationFile: "/dev/null"), token); + private static async Task WriteExecutableAsync( + string path, + string contents, + CancellationToken cancellationToken) + { + string candidate = $"{path}.{Guid.NewGuid():N}.tmp"; + try + { + await File.WriteAllTextAsync(candidate, contents, cancellationToken); + File.SetUnixFileMode( + candidate, + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + File.Move(candidate, path); + } + finally + { + File.Delete(candidate); + } + } + // errno 26. Process.Start surfaces it as the native error code on Linux. private const int TextFileBusy = 26; From 6c444e28a8524c4404a532d886b206dd3899b29a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 30 Aug 2026 14:45:49 -0500 Subject: [PATCH 129/129] Docs(docs[changelog]): Record review fixes --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e84869..ac85f66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,11 @@ version. `WorkspacePane` instead of setting properties; supplied collections are copied. `WorkspaceResult` freezes and compares collection contents. (#18) +- **`WorkspaceBuildException.PartialResult` exposes the session, windows, and + panes created before workspace application failed.** Workspace builds are + nontransactional; inspect the partial result for targeted cleanup or retry. + (#18) + - **`WorkspaceFile.Parse` now rejects unknown or duplicate keys, unsupported tmuxp hooks and plugins, wrong value shapes, multiple documents, and oversized input instead of ignoring them.** Use the documented closed subset @@ -81,6 +86,10 @@ version. ### Fixed +- **`Client.IsControlClient` and the `client.isControlClient` query field use + tmux's `client_control_mode` value.** Control-mode clients are no longer + reported as ordinary clients. (#18) + - Control-mode callers keep their own replies across concurrent commands, aliases, hooks, cancellation, and disposal. Malformed or truncated streams and pump failures now fault callers; request, output, pending-work, and