diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 94b21f7..98517b9 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -372,6 +372,25 @@ target frameworks, generates an SBOM and a provenance attestation, and pushes to NuGet through trusted publishing. Renaming that workflow file breaks the trusted-publishing policy registered on nuget.org, so change the policy first. +### The psmux preview gates + +Publishing the preview needs the accepted Windows x64 executable, which is a +maintainer validation build rather than a published psmux release asset. +[`docs/psmux.md`](../docs/psmux.md) states the trust boundary it has to meet. + +`release.yml` reads the repository variables `PSMUX_ARTIFACT_URL`, +`PSMUX_SOURCE_PROVENANCE_URL`, `PSMUX_LICENSE_URL`, `PSMUX_WSL_DISTRIBUTION` +and `PSMUX_WSL_DOTNET_PATH`, and needs a self-hosted `Windows`, `X64`, `psmux` +runner for the native and WSL gates. `PSMUX_WSL_DOTNET_PATH` is the absolute +Linux `dotnet` path for that checkout, which the runner reports: + +```console +$ mise exec -- which dotnet +``` + +Those inputs make the gates runnable; they do not by themselves complete the +artifact or the runtime evidence. + ### Recorded evidence is a release artifact A capability row is `pending` until a matrix run records evidence for it, and diff --git a/.github/workflows/dotnet-tmux.yml b/.github/workflows/dotnet-tmux.yml index ff83d7c..efa3c50 100644 --- a/.github/workflows/dotnet-tmux.yml +++ b/.github/workflows/dotnet-tmux.yml @@ -4,6 +4,7 @@ name: dotnet-tmux on: + workflow_call: push: branches: [master] pull_request: diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 0540a56..f7e0369 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -4,6 +4,7 @@ name: dotnet on: + workflow_call: push: branches: [master] pull_request: @@ -109,8 +110,8 @@ jobs: done - name: Examples - # An example that stopped working should fail the build, not the - # reader who copied it. + # An ordinary-tmux example that stopped working should fail here, + # before a reader copies it. run: > dotnet run --project examples/LibTmux.Examples/LibTmux.Examples.csproj @@ -119,10 +120,8 @@ jobs: --no-build - name: Example tests - # The same examples again, one test each, so a broken example is named - # in a report rather than being an exit code. This reaches them through - # the examples project and never through a project reference of its - # own, which is what makes it a reading of the surface a caller has. + # The ordinary-tmux examples run again, one test each, through the + # same compiled surface a caller reads. run: > dotnet test --project tests/LibTmux.ExampleTests/LibTmux.ExampleTests.csproj @@ -153,8 +152,10 @@ jobs: # code that runs, and this is what says so out loud. run: | uv run python eng/parity/verify_public_api.py + 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/docs/render_api_reference.py --check uv run python eng/docs/sync_snippets.py --check uv run eng/mcp/dump_tools.py --check @@ -181,13 +182,8 @@ jobs: path: artifacts/packages if-no-files-found: warn - # The compatibility claim names macOS, and until now only Linux was ever run. - # The first run of this lane failed 15 of 854 integration tests, which is the - # answer it was added to get: the macOS support was asserted, not proven. - # - # It is advisory until those are fixed. Requiring it would block every commit - # on a platform difference nobody has diagnosed yet, and deleting it would go - # back to not knowing. Reporting it is the honest middle. + # macOS exercises the advertised platform but stays advisory; failures remain + # visible without making the primary Linux gate depend on hosted macOS. macos: name: macos arm64 (advisory) runs-on: macos-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0afe122..f84d0e5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,7 +1,5 @@ -# Publishing. A version on nuget.org can never be deleted, only unlisted, so -# everything that can be proven about a package is proven here before the push -# rather than after it: the gate's build and tests, the package inspection, and -# a consumer that restores the built package from a folder feed and runs it. +# Publishing waits for same-commit gates and native/WSL psmux proof before +# nuget.org grants a token for its immutable feed. # # The name of this file is part of the trusted publishing policy on nuget.org. # Renaming it means editing that policy, and until then no publish is possible. @@ -25,97 +23,203 @@ env: DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 'true' jobs: - publish: - name: publish to nuget.org + validate: + name: validate release ref runs-on: ubuntu-latest - - # The policy on nuget.org names this environment, so a workflow that ran - # outside it cannot exchange a token. It is also where a required reviewer - # and a tag restriction belong. - environment: nuget - - permissions: - contents: read - # Lets the job ask GitHub for the short-lived token nuget.org trades for - # a one-hour API key. Nothing else here needs a credential. - id-token: write - attestations: write - steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - # SourceLink points a debugger at the commit that produced the - # assembly, which a shallow checkout does not have. fetch-depth: 0 - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: global-json-file: global.json - - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 - - - name: Install tmux - # The consumer proof drives a real server rather than a stub. - run: | - sudo apt-get update - sudo apt-get install --yes tmux - - name: Check the tag matches the version - # A tag that disagrees with the version publishes something permanently - # mislabelled, and the mislabelling is what nobody can take back. - if: github.event_name == 'push' env: + REF_TYPE: ${{ github.ref_type }} TAG: ${{ github.ref_name }} run: | + set -euo pipefail + if [ "${REF_TYPE}" != tag ] || [[ "${TAG}" != v* ]]; then + echo "release must run at a v* tag, not ${REF_TYPE} ${TAG}" >&2 + exit 1 + fi version="$(dotnet msbuild src/LibTmux/LibTmux.csproj \ -getProperty:Version -verbosity:quiet | tr -d '[:space:]')" if [ "${TAG}" != "v${version}" ]; then echo "tag ${TAG} does not match version ${version}" >&2 exit 1 fi - echo "publishing ${version} as ${TAG}" + echo "validated ${TAG} at ${GITHUB_SHA}" - - name: Restore - run: dotnet restore LibTmux.slnx --locked-mode + dotnet: + name: full dotnet gate + needs: validate + uses: ./.github/workflows/dotnet.yml - - name: Build - run: dotnet build LibTmux.slnx --configuration Release --no-restore --warnaserror + compatibility: + name: supported tmux matrix + needs: validate + uses: ./.github/workflows/dotnet-tmux.yml - - name: Unit tests - run: > - dotnet test - --project tests/LibTmux.UnitTests/LibTmux.UnitTests.csproj - --configuration Release - --framework net10.0 - --no-build - --minimum-expected-tests 1 + psmux-metadata: + name: published psmux artifact + needs: validate + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Verify the published artifact and provenance links + env: + ARTIFACT_URL: ${{ vars.PSMUX_ARTIFACT_URL }} + LICENSE_URL: ${{ vars.PSMUX_LICENSE_URL }} + SOURCE_URL: ${{ vars.PSMUX_SOURCE_PROVENANCE_URL }} + run: | + set -euo pipefail + for variable in ARTIFACT_URL LICENSE_URL SOURCE_URL; do + value="${!variable:-}" + if [[ "${value}" != https://* ]]; then + echo "${variable} must name a published HTTPS resource" >&2 + exit 1 + fi + done + if [[ "${SOURCE_URL}" != *aa26cd39edcfab03e718f94ea21bb47e8c5b85e8* ]]; then + echo "SOURCE_URL must attest the exact audited psmux commit" >&2 + exit 1 + fi + curl --fail --location --proto '=https' --tlsv1.2 \ + --connect-timeout 15 --max-time 120 \ + "${ARTIFACT_URL}" --output "${RUNNER_TEMP}/psmux.exe" + echo "1abd0eaa3de1ed5491a4f744c8b3db492ae9ac94e9e9a8fea9da217c744ba94e ${RUNNER_TEMP}/psmux.exe" \ + | sha256sum --check --strict + curl --fail --location --proto '=https' --tlsv1.2 \ + --connect-timeout 15 --max-time 120 \ + "${SOURCE_URL}" --output "${RUNNER_TEMP}/psmux-source-provenance" + curl --fail --location --proto '=https' --tlsv1.2 \ + --connect-timeout 15 --max-time 120 \ + "${LICENSE_URL}" --output "${RUNNER_TEMP}/psmux-license" + test -s "${RUNNER_TEMP}/psmux-source-provenance" + test -s "${RUNNER_TEMP}/psmux-license" - - name: Pack - run: dotnet pack LibTmux.slnx --configuration Release --no-build --output artifacts/packages + psmux: + name: psmux native Windows and WSL + needs: [validate, psmux-metadata] + runs-on: [self-hosted, Windows, X64, psmux] + timeout-minutes: 45 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 - - name: Inspect the packages - # Metadata, assemblies, dependencies and symbols, per package. What - # this catches is only fixable before the version exists. - run: uv run python eng/parity/inspect_packages.py + - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + global-json-file: global.json - - name: Prove the package installs and runs - # Reaching the library through the built package rather than a project - # reference is the last thing that can fail while it is still private. + - name: Download the audited psmux artifact + shell: pwsh + env: + ARTIFACT_URL: ${{ vars.PSMUX_ARTIFACT_URL }} + run: | + $ErrorActionPreference = 'Stop' + Invoke-WebRequest ` + -Uri $env:ARTIFACT_URL ` + -OutFile (Join-Path $env:RUNNER_TEMP 'psmux.exe') + + - name: Build the native and packed consumers + shell: pwsh run: | + $ErrorActionPreference = 'Stop' + $env:NUGET_PACKAGES = Join-Path ` + $env:RUNNER_TEMP ` + "libtmux-psmux-nuget-$([Guid]::NewGuid().ToString('N'))" + dotnet restore LibTmux.slnx --locked-mode + dotnet build LibTmux.slnx ` + --configuration Release ` + --no-restore ` + --warnaserror + dotnet pack LibTmux.slnx ` + --configuration Release ` + --no-build ` + --output artifacts/packages dotnet restore tests/LibTmux.PackageConsumer/LibTmux.PackageConsumer.csproj - for framework in net8.0 net10.0; do - dotnet run \ - --project tests/LibTmux.PackageConsumer/LibTmux.PackageConsumer.csproj \ - --configuration Release \ - --framework "${framework}" \ - --no-restore - done + foreach ($framework in @('net8.0', 'net10.0')) { + dotnet build tests/LibTmux.PackageConsumer/LibTmux.PackageConsumer.csproj ` + --configuration Release ` + --framework $framework ` + --no-restore ` + --warnaserror + } + + - name: Run the native and WSL psmux harness + shell: pwsh + env: + WSL_DISTRIBUTION: ${{ vars.PSMUX_WSL_DISTRIBUTION }} + WSL_DOTNET_PATH: ${{ vars.PSMUX_WSL_DOTNET_PATH }} + run: | + $ErrorActionPreference = 'Stop' + if ([string]::IsNullOrWhiteSpace($env:WSL_DISTRIBUTION)) { + throw 'PSMUX_WSL_DISTRIBUTION must name the release runner distribution.' + } + if ([string]::IsNullOrWhiteSpace($env:WSL_DOTNET_PATH)) { + throw 'PSMUX_WSL_DOTNET_PATH must name the WSL dotnet executable.' + } + $dotnet = (Get-Command dotnet.exe -ErrorAction Stop).Source + foreach ($framework in @('net8.0', 'net10.0')) { + $nonce = [Guid]::NewGuid().ToString('N') + & .\eng\psmux\Invoke-PsmuxSmoke.ps1 ` + -PsmuxPath (Join-Path $env:RUNNER_TEMP 'psmux.exe') ` + -ExpectedSha256 '1abd0eaa3de1ed5491a4f744c8b3db492ae9ac94e9e9a8fea9da217c744ba94e' ` + -DataDirectory (Join-Path $env:RUNNER_TEMP "libtmux-psmux-$nonce") ` + -NamespaceName "libtmux_smoke_$($nonce.Substring(0, 16))" ` + -DotnetPath $dotnet ` + -TestAssembly (Join-Path $env:GITHUB_WORKSPACE "tests\LibTmux.UnitTests\bin\Release\$framework\LibTmux.UnitTests.dll") ` + -ExampleAssembly (Join-Path $env:GITHUB_WORKSPACE "examples\LibTmux.Examples\bin\Release\$framework\LibTmux.Examples.dll") ` + -PackageConsumerAssembly (Join-Path $env:GITHUB_WORKSPACE "tests\LibTmux.PackageConsumer\bin\Release\$framework\LibTmux.PackageConsumer.dll") ` + -TargetFramework $framework ` + -RunWslSmoke ` + -WslDistribution $env:WSL_DISTRIBUTION ` + -WslRepository $env:GITHUB_WORKSPACE ` + -WslDotnetPath $env:WSL_DOTNET_PATH + } + + publish: + name: publish to nuget.org + needs: [dotnet, compatibility, psmux] + runs-on: ubuntu-latest + + # The policy on nuget.org names this environment, so a workflow that ran + # outside it cannot exchange a token. It is also where a required reviewer + # and a tag restriction belong. + environment: nuget - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + permissions: + contents: read + # Lets the job ask GitHub for the short-lived token nuget.org trades for + # a one-hour API key. Nothing else here needs a credential. + id-token: write + attestations: write + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # SourceLink points a debugger at the commit that produced the + # assembly, which a shallow checkout does not have. + fetch-depth: 0 + + - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + global-json-file: global.json + + - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + + - name: Download the packages proved by the gate + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: packages path: artifacts/packages - if-no-files-found: error + + - name: Inspect the packages + run: uv run python eng/parity/inspect_packages.py - name: NuGet login # The key this returns lasts an hour, so it is asked for immediately @@ -145,12 +249,9 @@ jobs: subject-path: 'artifacts/packages/*.nupkg' - name: Push - # The matching .snupkg is pushed alongside each package automatically, - # which is what puts the symbols on the NuGet symbol server. env: NUGET_API_KEY: ${{ steps.login.outputs.NUGET_API_KEY }} run: > dotnet nuget push "artifacts/packages/*.nupkg" --api-key "${NUGET_API_KEY}" --source https://api.nuget.org/v3/index.json - --skip-duplicate diff --git a/CHANGELOG.md b/CHANGELOG.md index edc0a56..6789ab4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,59 @@ 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 + +- **An experimental query-only psmux preview for native Windows and WSL + interop.** A separate + analyzer-clean API reads one isolated session, its windows and panes, and pane + text without exposing tmux lifecycle, mutation, chaining, control mode, or + MCP behavior that psmux cannot preserve. The client commit, clean banner, and + executable SHA-256 are pinned; publishing that exact artifact and completing + native/WSL runtime verification remain release prerequisites. The PowerShell + harness owns a fresh data directory and refuses ambiguous cleanup. See [the + exact trust and compatibility boundary](docs/psmux.md). +- **Explicit control-stream loss reporting.** A bounded event reader now gets a + `TmuxEventsDroppedEvent` with per-report and cumulative counts instead of + silently missing notifications when it falls behind. + +### Changed + +- Generated API reference pages now contain only the public LibTmux surface, + use each partial type's canonical summary, and are checked in CI alongside + the public API, capability, snippet, and MCP catalogs. +- MCP tail cursors are authenticated, bounded in size, and tied to the exact + endpoint, server generation, and pane. Search and every serialized tool + result now obey hard global line and UTF-8 byte ceilings. +- MCP Tasks admit at most eight active executions, retain a bounded result set, + and apply only to waits and job collection. +- **The `LibTmux` package README states tmux, framework and platform support + in one table**, including what the Windows psmux preview reads and refuses. +- **The `LibTmux.Mcp` package README is an onboarding path.** How the server + behaves — what to wait on, what bounds a result, which tools a tier + registers, what a subscription holds open — moved to + [`docs/mcp/`](docs/mcp/README.md), beside the generated tool reference. +- **`tmux_run` no longer answers as an MCP task.** + + - Previous behaviour: a task-capable client received a task handle and + collected the result later. + - New behaviour: the call blocks until the command exits, for every client. + - Reason: an SDK task carries no durable tmux handle, so a client that + disconnected lost work it believed was parked. + - Recommended action: use `tmux_start_job` and `tmux_job`. tmux owns that + handle, so it survives a disconnect. + +### Fixed + +- Control clients clean up a failed or cancelled attach without replacing its + primary error. MCP hierarchy and pane-activity streams keep independent + subscribers, recover after a stream ends, and preserve nullable structured + fields required by their advertised schemas. +- MCP background jobs retain their originating endpoint and generation, cap + concurrent state, clean up failed starts, serialize collection, and drain + watcher tasks during disposal instead of leaking or cross-routing work. + ## [0.0.0-alpha.8] — 2026-08-22 No behaviour change. `git diff v0.0.0-alpha.7..v0.0.0-alpha.8 -- src/` touches @@ -60,12 +113,13 @@ IntelliSense text, and the page on nuget.org. ### Changed - **`LibTmux.Mcp` is a different server.** It offered five tools; it now offers - 42, across three safety tiers, with six `tmux://` resources and four workflow - prompts. Every tool answers a typed record with a JSON output schema rather - than prose, so a client destructures a result instead of parsing one. The - tool names all changed — `list_tmux` and friends are gone in favour of - `tmux_hierarchy`, `tmux_run` and the rest. [The reference](docs/mcp/tools.md) - is generated from the server, so it cannot describe a surface that is absent. + 42, across three safety tiers, with four fixed `tmux://` resources, two + resource templates and four workflow prompts. Every shaped tool answers a + typed record with a JSON output schema; `tmux_display_message` returns the raw + expanded format text it was asked for. The tool names all changed — + `list_tmux` and friends are gone in favour of `tmux_hierarchy`, `tmux_run` and + the rest. [The reference](docs/mcp/tools.md) is generated from the server, so + it cannot describe a surface that is absent. ### Added @@ -268,6 +322,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.8...HEAD [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 [0.0.0-alpha.6]: https://github.com/libtmux/libtmux-dotnet/releases/tag/v0.0.0-alpha.6 diff --git a/Directory.Packages.props b/Directory.Packages.props index 0d173b2..7298ed0 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -18,14 +18,10 @@ - + - + diff --git a/README.md b/README.md index 78aff57..baecfa5 100644 --- a/README.md +++ b/README.md @@ -35,13 +35,14 @@ environment, script a workspace, harness a TUI in tests, or give an assistant hands on a terminal. **Look elsewhere if you want** a terminal emulator (this drives tmux, it does -not draw), a Windows-native solution (tmux is Unix only), or a process -launcher — `Process.Start` is right there. +not draw), unrestricted Windows parity, or a process launcher — `Process.Start` +is right there. Native Windows has a bounded, query-only +[`Psmux*` preview](docs/psmux.md). **What you get that a shell wrapper does not:** typed entities with real IDs, one dispatch model per workload (below), a version model that tells you when a flag does not exist on the tmux you are on rather than failing oddly, and -documented examples that are executed against live tmux in CI. +documented ordinary-tmux examples that are executed against live tmux in CI. ## Packages @@ -261,23 +262,26 @@ $ dotnet tool install --global LibTmux.Mcp --prerelease { "mcpServers": { "tmux": { "command": "libtmux-mcp" } } } ``` -It exposes 42 tools across three safety tiers, six `tmux://` resources and four -workflow prompts — [the full reference](docs/mcp/tools.md) is generated from the -server itself. Pass a socket name as its first argument to drive a server other -than the ambient one, which is what a sandbox wants. +It exposes 42 tools across three safety tiers, four fixed `tmux://` resources, +two resource templates and four workflow prompts — [the full +reference](docs/mcp/tools.md) is generated from the server itself. Pass a socket +name as its first argument to drive a server other than the ambient one, which +is what a sandbox wants. What it is built around is that an assistant should never get stuck and never -waste context. Nothing polls: `tmux_run` returns the shell's real exit status, +waste context. `tmux_run` returns the shell's real exit status, `tmux_start_job` hands back a handle for work that takes minutes, and -`tmux_wait_for_text` sleeps on tmux's own control-mode stream until the pane -prints. Nothing returns unbounded output: every capture keeps the newest lines -and reports what it dropped. `LIBTMUX_SAFETY` decides which tier is registered, -and a tool above it never reaches the model's list. +`tmux_wait_for_text` normally wakes from tmux's control-mode stream, with a +bounded polling fallback when that stream cannot start. Nothing returns +unbounded output: every capture keeps the newest lines and reports what it +dropped. `LIBTMUX_SAFETY` decides which tier is registered, and a tool above it +never reaches the model's list. [Full instructions](src/LibTmux.Mcp/README.md). ## Documentation - [Choosing a mode](docs/modes/matrix.md) — the three dispatch modes, measured +- [Windows psmux preview](docs/psmux.md) — what it reads, and what it refuses - [API reference](docs/api/README.md) — rendered from the doc comments - [tmux MCP tools](docs/mcp/tools.md) — every tool, tier and resource, generated - [Public API](docs/public-api.md) — the reviewed, approved surface @@ -292,8 +296,8 @@ and a tool above it never reaches the model's list. |---|---| | tmux | 3.2a, 3.3a, 3.4, 3.5, 3.6, 3.7a, 3.7b | | .NET | net8.0, net10.0 | -| OS | Linux, macOS. Windows is unsupported — tmux does not run there | -| Trimming | trim- and AOT-safe, proven by publishing and running it | +| 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 | ## License diff --git a/benchmarks/LibTmux.Benchmarks/packages.lock.json b/benchmarks/LibTmux.Benchmarks/packages.lock.json index fbf83d8..9bb250d 100644 --- a/benchmarks/LibTmux.Benchmarks/packages.lock.json +++ b/benchmarks/LibTmux.Benchmarks/packages.lock.json @@ -280,8 +280,8 @@ }, "Microsoft.Extensions.DependencyInjection.Abstractions": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "/a1aJz4m7ylhEDf25ugQChLQoN5XwoGjWw/BoR/ZWWKsO1v4DdJElS1uyngahz4B/eOzjFk1KNTkarRLE5wsIg==" + "resolved": "8.0.0", + "contentHash": "cjWrLkJXK0rs4zofsK4bSdg+jhDLTaxrkXu4gS6Y7MAlCvRyNNgwY/lJi5RDlQOnSZweHqoyvgvbdvQsRIW+hg==" }, "Microsoft.Extensions.Logging": { "type": "Transitive", @@ -296,11 +296,10 @@ }, "Microsoft.Extensions.Logging.Abstractions": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "Ljd0Uxoq5XpScD2Bg0nM/r3mwx7Ao5Uq24eo2ARxbGvqJ7Zht6rt2cJtwVRH4Cv+1ZVMdXz6TB43KbpmsxRrvQ==", + "resolved": "8.0.0", + "contentHash": "arDBqTgFCyS0EvRV7O3MZturChstm50OJ0y9bDJvAcmEPJm0FFpFyjU/JLYyStNGGey081DvnQYlncNX5SJJGA==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", - "System.Diagnostics.DiagnosticSource": "10.0.11" + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0" } }, "Microsoft.Extensions.Options": { @@ -340,11 +339,6 @@ "resolved": "9.0.0", "contentHash": "QhkXUl2gNrQtvPmtBTQHb0YsUrDiDQ2QS09YbtTTiSjGcf7NBqtYbrG/BE06zcBPCKEwQGzIv13IVdXNOSub2w==" }, - "System.Diagnostics.DiagnosticSource": { - "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "QoXcAdDhBudxorzYF1F5Uo7FY0CSWgmTjqVRJjcQaYAkbO3dCPXDJajJwyApTGHCJ+T5PfVyKZqXEKZ8PH+UWQ==" - }, "System.Management": { "type": "Transitive", "resolved": "9.0.5", @@ -364,7 +358,7 @@ "libtmux": { "type": "Project", "dependencies": { - "Microsoft.Extensions.Logging.Abstractions": "[10.0.11, )" + "Microsoft.Extensions.Logging.Abstractions": "[8.0.0, )" } } } diff --git a/docs/README.md b/docs/README.md index 4a65ed4..17918be 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,11 +1,12 @@ # LibTmux -> **Alpha.** The behaviour here is proven against tmux 3.2a through 3.7b on -> every commit; the shape of the API is what is not settled yet. +> **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. -A .NET class library for tmux. Every call reaches a real tmux server, and -which of the three execution modes you are in is visible where the call -starts. +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 +reads one isolated session on Windows. ## Start here @@ -16,6 +17,7 @@ machine. [Benchmarks](benchmarks/README.md) holds the recorded runs behind it. - [One-shot](modes/one-shot.md) — one command, one materialized object - [Control mode](modes/control-mode.md) — one client, streamed events - [Chaining](modes/chaining.md) — many commands, one invocation +- [psmux preview](psmux.md) — one isolated session, query only ## Reference @@ -23,8 +25,11 @@ machine. [Benchmarks](benchmarks/README.md) holds the recorded runs behind it. compiler emits, so nothing can be documented there and absent from the library. -[tmux MCP tools](mcp/tools.md) is generated by asking the server what it -advertises, so it cannot describe a surface that is not there. +[tmux MCP server](mcp/README.md) covers what to wait on rather than poll, what +bounds every result, which tools each safety tier registers, and what a +subscription holds open. [The tool reference](mcp/tools.md) beside it is +generated by asking the server what it advertises, so it cannot describe a +surface that is not there. ## Packages @@ -52,8 +57,8 @@ and each record has a validator that fails when the code disagrees. symbol went - [Decisions](decisions/) — why the transport, object model, query catalog, and public API are shaped the way they are -- [Quality bar](quality-bar.md) — what "good" is claimed to mean here, with - the evidence for each claim and a script that re-measures it +- [Quality-bar snapshot](quality-bar.md) — an archived scored tree and the + script that prints raw measures for a current one Decisions 0001 to 0003 quote the commands that produced their evidence, and those ran while this project was a directory inside another repository. Their diff --git a/docs/api/README.md b/docs/api/README.md index 6904ec2..82bfc1a 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -1,7 +1,7 @@ # API reference -Generated from the XML documentation the compiler emits, so every entry -here is the doc comment on the member itself. Regenerate with +Generated from compiler XML summaries and gated by the approved public +contract, so documented internal helpers never render. Regenerate with `uv run python eng/docs/render_api_reference.py`. See [choosing a mode](../modes/matrix.md) for how the three execution @@ -15,15 +15,13 @@ modes differ. | `LibTmux.BindKeyRequest` | Describes one bind-key invocation. | | `LibTmux.CapturePanePosition` | Names one end of a capture range. | | `LibTmux.CapturePaneRequest` | Describes one capture-pane invocation. | -| `LibTmux.CapturedRelation` | Creates captured and uncaptured relations with inferred types. | -| `LibTmux.CapturedRelation`1` | Holds the children a snapshot captured for one relation. | +| ``LibTmux.CapturedRelation`1`` | Holds the children a snapshot captured for one relation. | | `LibTmux.ChooseTreeRequest` | Describes one choose-tree invocation. | | `LibTmux.ChooseTreeSort` | Names how a chooser orders its rows. | | `LibTmux.Client` | Identifies a client and resolves what it is looking at. | | `LibTmux.ClientAttachment` | What one client is looking at. | | `LibTmux.CommandPromptRequest` | Describes one command-prompt invocation. | | `LibTmux.ConfirmBeforeRequest` | Describes one confirm-before invocation. | -| `LibTmux.ControlModeSession` | Reads one tmux control client and correlates what it says. | | `LibTmux.CopyModeRequest` | Describes one copy-mode invocation. | | `LibTmux.DisplayMenuRequest` | Describes one display-menu invocation. | | `LibTmux.DisplayMessageRequest` | Describes one display-message invocation. | @@ -49,7 +47,7 @@ modes differ. | `LibTmux.OwnedServerScope` | Owns a server and stops it when disposed. | | `LibTmux.OwnedSessionScope` | Owns a session and stops it when disposed. | | `LibTmux.OwnedWindowScope` | Owns a window and stops it when disposed. | -| `LibTmux.Pane` | Provides raw command execution for a tmux pane. | +| `LibTmux.Pane` | Represents an immutable pane handle and snapshot. | | `LibTmux.PaneDirection` | Defines pane placement directions. | | `LibTmux.PaneId` | Represents a generation-independent tmux pane identifier. | | `LibTmux.PaneInputMode` | Names whether a pane accepts input. | @@ -59,6 +57,12 @@ modes differ. | `LibTmux.PipePaneRequest` | Describes one pipe-pane invocation. | | `LibTmux.PopupCloseMode` | Names when a popup closes on its own. | | `LibTmux.PromptType` | What a command prompt is asking for. | +| `LibTmux.PsmuxCaptureOptions` | Chooses the psmux pane text that can be captured safely. | +| `LibTmux.PsmuxConnectionOptions` | Configures the bounded psmux query preview. | +| `LibTmux.PsmuxPane` | An immutable observation of one psmux pane. | +| `LibTmux.PsmuxServer` | Reads one isolated, single-session psmux namespace. | +| `LibTmux.PsmuxSession` | An immutable observation of the sole psmux session. | +| `LibTmux.PsmuxWindow` | An immutable observation of one psmux window. | | `LibTmux.Query.AndNode` | The conjunction of ordered operands. | | `LibTmux.Query.BooleanConstant` | A boolean literal. | | `LibTmux.Query.ComparisonNode` | An ordering or equality comparison. | @@ -76,17 +80,14 @@ modes differ. | `LibTmux.Query.QueryDocument` | One translated query predicate and its wire schema. | | `LibTmux.Query.QueryEdgeParser` | Parses the one legacy lookup spelling this port still carries. | | `LibTmux.Query.QueryExtensions` | Translates, compiles, and applies declarative query predicates. | -| `LibTmux.Query.QueryInterpreter` | Evaluates a query document against in-memory elements. | | `LibTmux.Query.QueryNode` | One node of a translated query predicate. | | `LibTmux.Query.QueryQuantifier` | Names how a quantifier folds a relation. | | `LibTmux.Query.QueryStringOperation` | Names a string comparison, always ordinal. | | `LibTmux.Query.QueryTarget` | Names the tmux object a field or quantifier reads. | -| `LibTmux.Query.QueryTranslator` | Translates a supported expression into a query document. | | `LibTmux.Query.RegexNode` | A constant-pattern regular expression match. | | `LibTmux.Query.StringConstant` | A string literal. | | `LibTmux.Query.StringNode` | An ordinal string comparison. | | `LibTmux.Query.TypedIdConstant` | A typed tmux identifier literal. | -| `LibTmux.RelationReader` | Reads one live relation and rebuilds owned entity handles. | | `LibTmux.ResizeDirection` | Defines pane resize directions. | | `LibTmux.ResizePaneRequest` | Describes one resize-pane invocation. | | `LibTmux.ResizeWindowRequest` | Describes one resize-window invocation. | @@ -96,28 +97,25 @@ modes differ. | `LibTmux.SelectLayoutRequest` | Describes one select-layout invocation. | | `LibTmux.SelectPaneRequest` | Describes one select-pane invocation. | | `LibTmux.SendKeysRequest` | Describes one send-keys invocation. | -| `LibTmux.Server` | Runs tmux-side filters and returns the surviving objects. | +| `LibTmux.Server` | Represents an immutable server handle and snapshot. | | `LibTmux.ServerAccessRequest` | Describes one server-access invocation. | | `LibTmux.ServerConnectionOptions` | Configures a tmux server connection without mutating process-wide state. | | `LibTmux.ServerGeneration` | Identifies one tmux daemon generation. | -| `LibTmux.ServerSnapshot` | Holds one point-in-time read of a tmux server's hierarchy. | -| `LibTmux.Session` | Provides raw command execution for a tmux session. | +| `LibTmux.Session` | Represents an immutable session handle and snapshot. | | `LibTmux.SessionId` | Represents a generation-independent tmux session identifier. | | `LibTmux.SessionWindowEdge` | Places one window at one index inside one session. | | `LibTmux.SetHookRequest` | Describes one set-hook invocation. | | `LibTmux.SetHooksRequest` | Describes setting several entries of one hook at once. | | `LibTmux.SetOptionRequest` | Describes one set-option invocation. | | `LibTmux.ShowMessagesMode` | What show-messages should list. | -| `LibTmux.SnapshotCollectionExtensions` | Indexes captured collections without leaving memory. | | `LibTmux.SnapshotDepth` | Names how far down the tmux hierarchy a snapshot captured. | -| `LibTmux.SnapshotLookup`2` | Indexes a captured collection by a stable key. | | `LibTmux.SplitPaneRequest` | Describes one split-window invocation. | | `LibTmux.StaleServerGenerationException` | Reports a stale server generation. | | `LibTmux.SwapPaneRequest` | Describes one swap-pane invocation. | | `LibTmux.Testing.TemporaryHierarchyScope` | A server, session, window, and pane a test owns together. | | `LibTmux.Testing.TemporaryServerScope` | Creates a throwaway server for a test and stops it afterwards. | -| `LibTmux.Testing.TemporarySessionScope` | Creates a throwaway session for a test and stops it afterwards. | -| `LibTmux.Testing.TemporaryWindowScope` | Creates a throwaway window for a test and stops it afterwards. | +| `LibTmux.Testing.TemporarySessionScope` | Owns a throwaway session and any private server created with it. | +| `LibTmux.Testing.TemporaryWindowScope` | Owns a throwaway window and any private session and server created with it. | | `LibTmux.Testing.TestEnvironment` | The directory and variables a test's tmux runs with. | | `LibTmux.Testing.TmuxNameGenerator` | Makes names no other test is using. | | `LibTmux.Testing.TmuxTestContext` | A tmux server a test owns, and the environment it runs in. | @@ -133,9 +131,11 @@ modes differ. | `LibTmux.TmuxCommandException` | Reports a command-policy failure. | | `LibTmux.TmuxCommandNotFoundException` | Reports a missing tmux executable. | | `LibTmux.TmuxCommandResult` | Contains the inspectable result of one raw tmux command. | +| `LibTmux.TmuxDispatchState` | Says whether a failed command reached tmux, which is what decides if retrying is safe. | | `LibTmux.TmuxEnvironment` | The environment tmux gives to the processes it spawns. | | `LibTmux.TmuxEnvironmentEntry` | One variable in a tmux environment. | | `LibTmux.TmuxEvent` | One thing a tmux control client reported without being asked. | +| `LibTmux.TmuxEventsDroppedEvent` | Reports notifications discarded because the bounded event buffer was full. | | `LibTmux.TmuxExitEvent` | The control client ended. | | `LibTmux.TmuxHook` | One hook and every command it runs. | | `LibTmux.TmuxHookEntry` | One command a hook runs, and where it sits in the order. | @@ -163,16 +163,12 @@ modes differ. | `LibTmux.UnsetOptionRequest` | Describes one set-option -u invocation. | | `LibTmux.UnsupportedQueryExpressionException` | Thrown when an expression cannot be translated to a query. | | `LibTmux.WaitForRequest` | Describes one wait-for invocation. | -| `LibTmux.Window` | Provides raw command execution for a tmux window. | +| `LibTmux.Window` | Represents an immutable window handle and snapshot. | | `LibTmux.WindowDirection` | Defines relative window placement. | | `LibTmux.WindowEntityKey` | Identifies one window as it appears inside one session. | | `LibTmux.WindowId` | Represents a generation-independent tmux window identifier. | | `LibTmux.WindowResizeMode` | Names how a window is resized against its clients. | | `LibTmux.WindowRotationDirection` | Names which way a window's panes rotate. | -| `System.Text.RegularExpressions.Generated.Utilities` | Helper methods used by generated -derived implementations. | -| `System.Text.RegularExpressions.Generated.VersionRegex_0` | Custom -derived type for the VersionRegex method. | -| `System.Text.RegularExpressions.Generated.VersionRegex_0.RunnerFactory` | Provides a factory for creating instances to be used by methods on . | -| `System.Text.RegularExpressions.Generated.VersionRegex_0.RunnerFactory.Runner` | Provides the runner that contains the custom logic implementing the specified regular expression. | ## Methods @@ -182,9 +178,7 @@ modes differ. | `LibTmux.BindKeyRequest.#ctor(System.String,System.Collections.Generic.IReadOnlyList{System.String},System.String,System.String,System.Boolean)` | Initializes a key binding. | | `LibTmux.CapturePanePosition.#ctor(System.Int32)` | Initializes a position at one line. | | `LibTmux.CapturePaneRequest.#ctor(System.Nullable{LibTmux.CapturePanePosition},System.Nullable{LibTmux.CapturePanePosition},System.Boolean,System.Boolean,System.Boolean,System.Boolean,System.Boolean,System.Boolean,System.Boolean,System.Boolean,System.Boolean,System.Boolean,System.Boolean,System.Boolean)` | Initializes a capture request. | -| `LibTmux.CapturedRelation.Capture``1(System.Collections.Generic.IEnumerable{``0},System.String,LibTmux.SnapshotDepth)` | Creates a captured relation over a read child sequence. | -| `LibTmux.CapturedRelation.Uncaptured``1(System.String,LibTmux.SnapshotDepth)` | Creates a relation the snapshot did not read. | -| `LibTmux.CapturedRelation`1.OrEmpty` | Returns the captured children, or an empty list when unread. | +| ``LibTmux.CapturedRelation`1.OrEmpty`` | Returns the captured children, or an empty list when unread. | | `LibTmux.ChooseTreeRequest.#ctor(System.Boolean,System.Boolean,System.String,LibTmux.UnsafeTmuxFilter,System.Nullable{LibTmux.ChooseTreeSort},System.Boolean,System.Boolean)` | Initializes a tree-chooser request. | | `LibTmux.Client.GetAsync(LibTmux.Server,System.String,System.Threading.CancellationToken)` | Reads one client by name. | | `LibTmux.Client.GetAttachedPaneAsync(System.Threading.CancellationToken)` | Reads the pane this client has active now. | @@ -195,7 +189,6 @@ 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.ControlModeSession.WaitForReadyAsync(System.Threading.CancellationToken)` | Waits until tmux has answered its own attach. | | `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. | @@ -207,7 +200,8 @@ modes differ. | `LibTmux.IControlModeSession.SendAsync(System.String,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,System.Exception)` | Initializes a LibTmux exception. | +| `LibTmux.LibTmuxException.#ctor(System.String,LibTmux.TmuxDispatchState,System.Exception)` | Initializes a LibTmux exception that knows whether tmux ran the command. | +| `LibTmux.LibTmuxException.#ctor(System.String,System.Exception)` | Initializes a LibTmux exception whose dispatch state is unknown. | | `LibTmux.LinkWindowRequest.#ctor(System.String,System.String,System.Nullable{LibTmux.WindowDirection},System.Boolean,System.Boolean)` | Initializes a window-link request. | | `LibTmux.ListBuffersRequest.#ctor(System.String,LibTmux.UnsafeTmuxFilter)` | Initializes a buffer listing. | | `LibTmux.ListHooksRequest.#ctor(System.Nullable{LibTmux.OptionScope},System.Boolean)` | Initializes a request for every hook in a scope. | @@ -216,14 +210,10 @@ modes differ. | `LibTmux.NewPaneRequest.#ctor(System.String,System.String,System.Boolean,System.String,System.Collections.Generic.IReadOnlyDictionary{System.String,System.String},System.Nullable{System.Int32},System.Nullable{System.Int32},System.Nullable{System.Int32},System.Nullable{System.Int32},System.Boolean,System.Boolean,System.String,System.String,System.String,System.String,System.Boolean)` | Initializes a pane-creation request. | | `LibTmux.NewSessionRequest.#ctor(System.String,System.Boolean,System.Boolean,System.String,System.String,System.String,System.String,System.String,System.Collections.Generic.IReadOnlyDictionary{System.String,System.String},System.Boolean,System.Boolean,System.String)` | Initializes a session-creation request. | | `LibTmux.NewWindowRequest.#ctor(System.String,System.String,System.Boolean,System.String,System.String,System.Collections.Generic.IReadOnlyDictionary{System.String,System.String},System.Nullable{LibTmux.WindowDirection},System.String,System.Boolean,System.Boolean)` | Initializes a window-creation request. | -| `LibTmux.NewWindowRequest.WithTargetWindow(System.String)` | Returns this request aimed at one window. | | `LibTmux.OwnedServerScope.DisposeAsync` | Stops the owned server. | | `LibTmux.OwnedSessionScope.DisposeAsync` | Stops the owned session. | | `LibTmux.OwnedWindowScope.DisposeAsync` | Stops the owned window. | | `LibTmux.Pane.BreakAsync(System.String,System.Boolean,System.Threading.CancellationToken)` | Moves this pane out into a window of its own. | -| `LibTmux.Pane.BuildDisplayPopupArguments(LibTmux.DisplayPopupRequest)` | Builds the arguments a paste request sends. | -| `LibTmux.Pane.BuildNewPaneArguments(LibTmux.NewPaneRequest)` | Builds the arguments a chooser request sends. | -| `LibTmux.Pane.BuildRespawnPaneArguments(LibTmux.RespawnRequest)` | Builds the arguments a copy-mode request sends. | | `LibTmux.Pane.CaptureAsync(LibTmux.CapturePaneRequest,System.Threading.CancellationToken)` | Reads the pane's contents. | | `LibTmux.Pane.CaptureToBufferAsync(System.String,LibTmux.CapturePaneRequest,System.Threading.CancellationToken)` | Captures the pane's contents into a tmux buffer. | | `LibTmux.Pane.ChooseBufferAsync(System.Threading.CancellationToken)` | Opens the buffer chooser in this pane. | @@ -266,6 +256,17 @@ modes differ. | `LibTmux.PaneId.TryParse(System.String,LibTmux.PaneId@)` | Tries to parse a prefixed pane identifier. | | `LibTmux.PasteBufferRequest.#ctor(System.String,System.Boolean,System.Boolean,System.Boolean,System.String,System.Boolean)` | Initializes a buffer-paste request. | | `LibTmux.PipePaneRequest.#ctor(System.String,System.Boolean,System.Boolean,System.Boolean)` | Initializes a pane-piping request. | +| `LibTmux.PsmuxCaptureOptions.#ctor(System.Nullable{LibTmux.CapturePanePosition},System.Nullable{LibTmux.CapturePanePosition},System.Boolean,System.Boolean)` | Initializes a bounded psmux capture. | +| `LibTmux.PsmuxConnectionOptions.#ctor(System.String,System.String,System.String,System.String,Microsoft.Extensions.Logging.ILogger)` | Initializes one explicit psmux endpoint. | +| `LibTmux.PsmuxPane.CaptureAsync(LibTmux.PsmuxCaptureOptions,System.Threading.CancellationToken)` | Reads this pane's text through the audited capture subset. | +| `LibTmux.PsmuxServer.ConnectAsync(LibTmux.PsmuxConnectionOptions,System.Threading.CancellationToken)` | Connects to a separately provisioned psmux namespace. | +| `LibTmux.PsmuxServer.GetPanesAsync(System.Threading.CancellationToken)` | Reads every pane in the sole session. | +| `LibTmux.PsmuxServer.GetSessionAsync(System.Threading.CancellationToken)` | Reads the sole visible session. | +| `LibTmux.PsmuxServer.GetWindowsAsync(System.Threading.CancellationToken)` | Reads every window in the sole session. | +| `LibTmux.PsmuxServer.RefreshAsync(System.Threading.CancellationToken)` | Reconnects and returns a fresh server observation. | +| `LibTmux.PsmuxSession.GetPanesAsync(System.Threading.CancellationToken)` | Reads the session's current panes. | +| `LibTmux.PsmuxSession.GetWindowsAsync(System.Threading.CancellationToken)` | Reads the session's current windows. | +| `LibTmux.PsmuxWindow.GetPanesAsync(System.Threading.CancellationToken)` | Reads the window's current panes. | | `LibTmux.Query.AndNode.#ctor(System.Collections.Generic.IReadOnlyList{LibTmux.Query.QueryNode})` | Initializes a conjunction. | | `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. | @@ -279,10 +280,10 @@ modes differ. | `LibTmux.Query.QuantifierNode.#ctor(LibTmux.Query.QueryQuantifier,LibTmux.Query.FieldNode,LibTmux.Query.QueryNode)` | A quantifier over a relation field. | | `LibTmux.Query.QueryDocument.#ctor(System.String,System.Int32,LibTmux.Query.QueryTarget,LibTmux.Query.QueryNode)` | One translated query predicate and its wire schema. | | `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},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.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},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. | | `LibTmux.Query.StringConstant.#ctor(System.String)` | A string literal. | | `LibTmux.Query.StringNode.#ctor(LibTmux.Query.QueryStringOperation,LibTmux.Query.QueryNode,LibTmux.Query.QueryNode)` | An ordinal string comparison. | @@ -296,12 +297,6 @@ modes differ. | `LibTmux.SendKeysRequest.#ctor(System.String,System.Boolean,System.Boolean,System.Boolean,System.Boolean,System.String,System.Nullable{System.Int32},System.Boolean,System.Boolean,System.String,System.Boolean)` | Initializes a key-sending request. | | `LibTmux.Server.AttachSessionAsync(LibTmux.AttachSessionRequest,System.Threading.CancellationToken)` | Attaches a client to a session on this server. | | `LibTmux.Server.BindKeyAsync(LibTmux.BindKeyRequest,System.Threading.CancellationToken)` | Binds a key to a tmux command. | -| `LibTmux.Server.BuildCommandPromptArguments(LibTmux.CommandPromptRequest)` | Builds the arguments a prompt request sends. | -| `LibTmux.Server.BuildConfirmBeforeArguments(LibTmux.ConfirmBeforeRequest)` | Builds the arguments a confirmation request sends. | -| `LibTmux.Server.BuildDisplayMenuArguments(LibTmux.DisplayMenuRequest)` | Builds the arguments a menu request sends. | -| `LibTmux.Server.BuildDisplayMessageArguments(LibTmux.DisplayMessageRequest)` | Builds the arguments a message request sends. | -| `LibTmux.Server.BuildRunShellArguments(LibTmux.RunShellRequest)` | Builds the arguments a shell request sends. | -| `LibTmux.Server.BuildServerAccessArguments(LibTmux.ServerAccessRequest)` | Builds the arguments an access request sends. | | `LibTmux.Server.CaptureSnapshotAsync(LibTmux.SnapshotDepth,System.Threading.CancellationToken)` | Reads the server and answers a handle carrying what it found. | | `LibTmux.Server.Chain` | Begins a chain that runs its commands in one tmux invocation. | | `LibTmux.Server.ClearPromptHistoryAsync(System.Nullable{LibTmux.PromptType},System.Threading.CancellationToken)` | Forgets what has been typed at command prompts. | @@ -362,7 +357,6 @@ modes differ. | `LibTmux.ServerAccessRequest.#ctor(System.String,System.String,System.Boolean,System.Boolean,System.Boolean)` | Initializes an access change. | | `LibTmux.ServerConnectionOptions.#ctor(System.String,System.String,System.String,System.Func{System.String},System.String,LibTmux.TmuxColorMode,System.Func{LibTmux.Server,System.Threading.CancellationToken,System.Threading.Tasks.ValueTask},System.Collections.Generic.IReadOnlyDictionary{System.String,System.String},Microsoft.Extensions.Logging.ILogger)` | Initializes connection options. | | `LibTmux.ServerGeneration.#ctor(System.Int32,System.Int64)` | Initializes a server generation. | -| `LibTmux.ServerSnapshot.CaptureAsync(LibTmux.Server,LibTmux.SnapshotDepth,System.Threading.CancellationToken)` | Reads one server hierarchy to the requested depth. | | `LibTmux.Session.AttachAsync(LibTmux.AttachSessionRequest,System.Threading.CancellationToken)` | Attaches a client to this session. | | `LibTmux.Session.CreateOwnedWindowAsync(LibTmux.NewWindowRequest,System.Threading.CancellationToken)` | Creates a window in this session and takes ownership of it. | | `LibTmux.Session.CreateWindowAsync(LibTmux.NewWindowRequest,System.Threading.CancellationToken)` | Creates a window in this session. | @@ -391,14 +385,9 @@ modes differ. | `LibTmux.SetHookRequest.#ctor(System.String,System.String,System.Nullable{LibTmux.OptionScope},System.Boolean,System.Boolean,System.Boolean,System.Boolean)` | Initializes a request to set one hook. | | `LibTmux.SetHooksRequest.#ctor(System.String,System.Collections.Generic.IReadOnlyDictionary{System.Int32,System.String},System.Nullable{LibTmux.OptionScope},System.Boolean,System.Boolean)` | Initializes a request to set several entries of one hook. | | `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.SnapshotCollectionExtensions.ToLookupByKey``2(System.Collections.Generic.IEnumerable{``1},System.Func{``1,``0})` | Indexes a captured collection by a required key. | -| `LibTmux.SnapshotLookup`2.TryGetValue(`0,`1@)` | Tries to get the element with the given key. | | `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.SwapPaneRequest.#ctor(System.String,System.Nullable{LibTmux.PaneSwapDirection},System.Boolean,System.Boolean)` | Initializes a pane-swap request. | -| `LibTmux.Testing.TemporaryServerScope.StartAsync(LibTmux.ServerConnectionOptions,System.Threading.CancellationToken)` | Starts a temporary server on its own socket. | -| `LibTmux.Testing.TemporarySessionScope.StartAsync(LibTmux.Server,LibTmux.NewSessionRequest,System.Threading.CancellationToken)` | Creates a temporary session on a running server. | -| `LibTmux.Testing.TemporaryWindowScope.StartAsync(LibTmux.Session,LibTmux.NewWindowRequest,System.Threading.CancellationToken)` | Creates a temporary window in a session. | | `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. | | `LibTmux.Testing.TestEnvironment.WithoutVariable(System.String)` | Answers a copy that removes one variable. | @@ -417,7 +406,7 @@ modes differ. | `LibTmux.Testing.TmuxTestFactory.CreateWindowAsync(LibTmux.Testing.TmuxTestOptions,System.Threading.CancellationToken)` | Starts a server, a session, and a window, all owned by this test. | | `LibTmux.Testing.TmuxTestOptions.#ctor(LibTmux.ServerConnectionOptions,System.Nullable{System.TimeSpan},System.Nullable{System.TimeSpan},System.String)` | Initializes test options. | | `LibTmux.Testing.TmuxWait.UntilAsync(System.Func{System.Threading.CancellationToken,System.Threading.Tasks.Task{System.Boolean}},System.TimeSpan,System.TimeSpan,System.Boolean,System.Threading.CancellationToken)` | Waits until a probe reports the state was reached. | -| `LibTmux.Testing.TmuxWait.UntilAsync``1(System.Func{System.Threading.CancellationToken,System.Threading.Tasks.Task{``0}},System.Func{``0,System.Boolean},System.TimeSpan,System.TimeSpan,System.Threading.CancellationToken)` | Waits until a reading satisfies a predicate, and answers it. | +| ```LibTmux.Testing.TmuxWait.UntilAsync``1(System.Func{System.Threading.CancellationToken,System.Threading.Tasks.Task{``0}},System.Func{``0,System.Boolean},System.TimeSpan,System.TimeSpan,System.Threading.CancellationToken)``` | Waits until a reading satisfies a predicate, and answers it. | | `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. | @@ -518,14 +507,10 @@ modes differ. | `LibTmux.TmuxEnvironment.SetAsync(System.String,System.String,System.Boolean,System.Boolean,System.Threading.CancellationToken)` | Sets one variable. | | `LibTmux.TmuxEnvironment.UnsetAsync(System.String,System.Threading.CancellationToken)` | Forgets a variable entirely. | | `LibTmux.TmuxEnvironmentEntry.#ctor(System.String,System.String,System.Boolean)` | Initializes one environment variable. | +| `LibTmux.TmuxEventsDroppedEvent.#ctor(System.Int64,System.Int64)` | Reports notifications discarded because the bounded event buffer was full. | | `LibTmux.TmuxExitEvent.#ctor(System.String)` | The control client ended. | | `LibTmux.TmuxHook.#ctor(System.String,System.Collections.Generic.IReadOnlyList{LibTmux.TmuxHookEntry})` | Initializes one hook. | | `LibTmux.TmuxHookEntry.#ctor(System.Int32,System.String)` | Initializes one hook entry. | -| `LibTmux.TmuxHooks.BuildListArguments(LibTmux.ListHooksRequest)` | Builds the arguments a hook listing sends. | -| `LibTmux.TmuxHooks.BuildRunArguments(LibTmux.HookRequest)` | Builds the arguments running a hook sends. | -| `LibTmux.TmuxHooks.BuildSetAllArguments(LibTmux.SetHooksRequest)` | Builds every command a multi-entry hook request sends. | -| `LibTmux.TmuxHooks.BuildSetArguments(LibTmux.SetHookRequest)` | Builds the arguments a hook request sends. | -| `LibTmux.TmuxHooks.BuildUnsetArguments(LibTmux.HookRequest)` | Builds the arguments removing a hook sends. | | `LibTmux.TmuxHooks.GetAllAsync(LibTmux.ListHooksRequest,System.Threading.CancellationToken)` | Reads every hook in the scope. | | `LibTmux.TmuxHooks.GetAsync(LibTmux.HookRequest,System.Threading.CancellationToken)` | Reads one hook. | | `LibTmux.TmuxHooks.RunAsync(LibTmux.HookRequest,System.Threading.CancellationToken)` | Runs a hook's commands now, without waiting for it to fire. | @@ -539,10 +524,6 @@ modes differ. | `LibTmux.TmuxOption.#ctor(System.String,LibTmux.TmuxOptionValue,System.Nullable{System.Int32})` | Initializes an option. | | `LibTmux.TmuxOptionException.#ctor(System.String,System.String,System.Exception)` | Initializes the exception for one rejected option. | | `LibTmux.TmuxOptionValue.#ctor(System.String,LibTmux.TmuxOptionState,System.Nullable{System.Boolean},System.Nullable{System.Int64})` | Initializes an option value. | -| `LibTmux.TmuxOptions.BuildGetAllArguments(LibTmux.GetOptionsRequest)` | Builds the arguments a whole-scope read sends. | -| `LibTmux.TmuxOptions.BuildGetArguments(LibTmux.GetOptionRequest)` | Builds the arguments a named read sends. | -| `LibTmux.TmuxOptions.BuildSetArguments(LibTmux.SetOptionRequest)` | Builds the arguments a set request sends. | -| `LibTmux.TmuxOptions.BuildUnsetArguments(LibTmux.UnsetOptionRequest)` | Builds the arguments an unset request sends. | | `LibTmux.TmuxOptions.GetAllAsync(LibTmux.GetOptionsRequest,System.Threading.CancellationToken)` | Reads every option in the scope. | | `LibTmux.TmuxOptions.GetAsync(LibTmux.GetOptionRequest,System.Threading.CancellationToken)` | Reads one option. | | `LibTmux.TmuxOptions.SetAsync(LibTmux.SetOptionRequest,System.Threading.CancellationToken)` | Sets one option. | @@ -550,7 +531,8 @@ modes differ. | `LibTmux.TmuxOutputEvent.#ctor(System.String,System.String)` | Bytes a pane wrote. | | `LibTmux.TmuxPaneException.#ctor(System.String,LibTmux.PaneId,System.Exception)` | Initializes the exception for one pane. | | `LibTmux.TmuxSessionExistsException.#ctor(System.String,System.String,System.Exception)` | Initializes the exception for one taken session name. | -| `LibTmux.TmuxTransportException.#ctor(System.String,System.Collections.Generic.IReadOnlyList{System.String},System.Exception)` | Initializes a transport exception. | +| `LibTmux.TmuxTransportException.#ctor(System.String,System.Collections.Generic.IReadOnlyList{System.String},LibTmux.TmuxDispatchState,System.Exception)` | Initializes a transport exception that knows whether tmux was started. | +| `LibTmux.TmuxTransportException.#ctor(System.String,System.Collections.Generic.IReadOnlyList{System.String},System.Exception)` | Initializes a transport exception whose dispatch state is unknown. | | `LibTmux.TmuxVersion.#ctor(System.String)` | Initializes a tmux version. | | `LibTmux.TmuxVersion.CheckMinimumSupportedVersionAsync(System.Boolean,System.String,System.Threading.CancellationToken)` | Checks the package minimum and optionally throws. | | `LibTmux.TmuxVersion.CompareTo(LibTmux.TmuxVersion)` | Compares parsed tmux versions. | @@ -580,9 +562,6 @@ modes differ. | `LibTmux.UnsupportedQueryExpressionException.#ctor(System.String)` | Initializes the exception for one untranslatable expression. | | `LibTmux.UnsupportedQueryExpressionException.#ctor(System.String,System.String,System.Exception)` | Initializes the exception naming the expression it refused. | | `LibTmux.WaitForRequest.#ctor(System.String,LibTmux.TmuxWaitMode)` | Initializes a channel request. | -| `LibTmux.Window.BuildLinkWindowArguments(LibTmux.LinkWindowRequest)` | Builds the arguments a link request sends. | -| `LibTmux.Window.BuildMoveWindowArguments(LibTmux.MoveWindowRequest)` | Builds the arguments a move request sends. | -| `LibTmux.Window.BuildResizeWindowArguments(LibTmux.ResizeWindowRequest)` | Builds the arguments a layout request sends. | | `LibTmux.Window.CreatePaneAsync(LibTmux.NewPaneRequest,System.Threading.CancellationToken)` | Creates a floating pane in this window. | | `LibTmux.Window.CreateWindowAsync(LibTmux.NewWindowRequest,System.Threading.CancellationToken)` | Creates a window next to this one. | | `LibTmux.Window.DisplayMessageAsync(LibTmux.DisplayMessageRequest,System.Threading.CancellationToken)` | Shows a message on the client viewing this window. | @@ -614,15 +593,6 @@ modes differ. | `LibTmux.WindowId.Parse(System.String)` | Parses a prefixed window identifier. | | `LibTmux.WindowId.ToString` | Returns the canonical prefixed identifier. | | `LibTmux.WindowId.TryParse(System.String,LibTmux.WindowId@)` | Tries to parse a prefixed window identifier. | -| `System.Text.RegularExpressions.Generated.Utilities.StackPop(System.Int32[],System.Int32@,System.Int32@,System.Int32@)` | Pops 2 values from the backtracking stack. | -| `System.Text.RegularExpressions.Generated.Utilities.StackPush(System.Int32[]@,System.Int32@,System.Int32)` | Pushes 1 value onto the backtracking stack. | -| `System.Text.RegularExpressions.Generated.Utilities.StackPush(System.Int32[]@,System.Int32@,System.Int32,System.Int32)` | Pushes 2 values onto the backtracking stack. | -| `System.Text.RegularExpressions.Generated.Utilities.StackPush(System.Int32[]@,System.Int32@,System.Int32,System.Int32,System.Int32)` | Pushes 3 values onto the backtracking stack. | -| `System.Text.RegularExpressions.Generated.VersionRegex_0.#ctor` | Initializes the instance. | -| `System.Text.RegularExpressions.Generated.VersionRegex_0.RunnerFactory.CreateInstance` | Creates an instance of a used by methods on . | -| `System.Text.RegularExpressions.Generated.VersionRegex_0.RunnerFactory.Runner.Scan(System.ReadOnlySpan{System.Char})` | Scan the starting from base.runtextstart for the next match. | -| `System.Text.RegularExpressions.Generated.VersionRegex_0.RunnerFactory.Runner.TryFindNextPossibleStartingPosition(System.ReadOnlySpan{System.Char})` | Search starting from base.runtextpos for the next location a match could possibly start. | -| `System.Text.RegularExpressions.Generated.VersionRegex_0.RunnerFactory.Runner.TryMatchAtCurrentPosition(System.ReadOnlySpan{System.Char})` | Determine whether at base.runtextpos is a match for the regular expression. | ## Properties @@ -655,9 +625,7 @@ modes differ. | `LibTmux.CapturePaneRequest.Quiet` | Gets whether a missing alternate screen is not an error. | | `LibTmux.CapturePaneRequest.StartLine` | Gets the first line to capture. | | `LibTmux.CapturePaneRequest.TrimTrailingSpaces` | Gets whether trailing spaces are removed. | -| `LibTmux.CapturedRelation`1.CapturedDepth` | Gets the depth the owning snapshot reached. | -| `LibTmux.CapturedRelation`1.IsCaptured` | Gets whether the snapshot read this relation. | -| `LibTmux.CapturedRelation`1.Relation` | Gets the relation name this instance carries. | +| ``LibTmux.CapturedRelation`1.IsCaptured`` | Gets whether the snapshot read this relation. | | `LibTmux.ChooseTreeRequest.Format` | Gets the format each row renders with. | | `LibTmux.ChooseTreeRequest.NativeFilter` | Gets the raw tmux filter limiting the rows. | | `LibTmux.ChooseTreeRequest.Reverse` | Gets whether the order is reversed. | @@ -768,6 +736,7 @@ modes differ. | `LibTmux.IfShellRequest.ThenCommand` | Gets the tmux command run when it succeeds. | | `LibTmux.IncompleteSnapshotException.CapturedDepth` | Gets the depth the snapshot actually reached. | | `LibTmux.IncompleteSnapshotException.Relation` | Gets the relation the caller asked for. | +| `LibTmux.LibTmuxException.Dispatch` | Gets whether the command reached tmux, and so whether a retry is safe. | | `LibTmux.LibTmuxInfo.MaximumTestedTmuxVersion` | Gets the highest required tested tmux version. | | `LibTmux.LibTmuxInfo.MinimumTmuxVersion` | Gets the minimum supported tmux version. | | `LibTmux.LibTmuxInfo.Version` | Gets the library assembly version. | @@ -846,7 +815,6 @@ modes differ. | `LibTmux.Pane.RawFormatFields` | Gets the tmux fields captured when this handle materialized. | | `LibTmux.Pane.Server` | Gets the server that owns this pane. | | `LibTmux.Pane.Session` | Gets the session containing this pane. | -| `LibTmux.Pane.Snapshot` | Gets the tmux fields captured when this handle materialized. | | `LibTmux.Pane.Title` | Gets the pane title captured with this handle. | | `LibTmux.Pane.Width` | Gets the pane width captured with this handle. | | `LibTmux.Pane.Window` | Gets the window containing this pane. | @@ -861,6 +829,36 @@ modes differ. | `LibTmux.PipePaneRequest.InputOnly` | Gets whether only pane input is piped. | | `LibTmux.PipePaneRequest.OutputOnly` | Gets whether only pane output is piped. | | `LibTmux.PipePaneRequest.Toggle` | Gets whether an identical existing pipe is stopped instead. | +| `LibTmux.PsmuxCaptureOptions.EndLine` | Gets the last line to capture. | +| `LibTmux.PsmuxCaptureOptions.EscapeSequences` | Gets whether terminal escape sequences are preserved. | +| `LibTmux.PsmuxCaptureOptions.JoinWrappedLines` | Gets whether wrapped screen rows are joined. | +| `LibTmux.PsmuxCaptureOptions.StartLine` | Gets the first line to capture. | +| `LibTmux.PsmuxConnectionOptions.DataDirectory` | Gets the canonical isolated data-directory path on a fixed local Windows drive. | +| `LibTmux.PsmuxConnectionOptions.ExecutablePath` | Gets the local absolute psmux client executable path. | +| `LibTmux.PsmuxConnectionOptions.ExpectedBinarySha256` | Gets the expected executable SHA-256 in lowercase hexadecimal. | +| `LibTmux.PsmuxConnectionOptions.Logger` | Gets the optional connection logger. | +| `LibTmux.PsmuxConnectionOptions.NamespaceName` | Gets the explicit non-default psmux namespace. | +| `LibTmux.PsmuxPane.Height` | Gets the captured height in rows. | +| `LibTmux.PsmuxPane.Id` | Gets the captured pane identifier. | +| `LibTmux.PsmuxPane.Index` | Gets the captured pane index. | +| `LibTmux.PsmuxPane.Server` | Gets the psmux endpoint that produced this observation. | +| `LibTmux.PsmuxPane.SessionId` | Gets the captured parent session identifier. | +| `LibTmux.PsmuxPane.Title` | Gets the captured pane title. | +| `LibTmux.PsmuxPane.Width` | Gets the captured width in columns. | +| `LibTmux.PsmuxPane.WindowId` | Gets the captured parent window identifier. | +| `LibTmux.PsmuxServer.ConnectionOptions` | Gets the connection settings used for this observation. | +| `LibTmux.PsmuxServer.Version` | Gets the psmux compatibility version reported at connection time. | +| `LibTmux.PsmuxSession.Attached` | Gets whether a client was attached when the session was read. | +| `LibTmux.PsmuxSession.Id` | Gets the captured session identifier. | +| `LibTmux.PsmuxSession.Name` | Gets the captured session name. | +| `LibTmux.PsmuxSession.Server` | Gets the psmux endpoint that produced this observation. | +| `LibTmux.PsmuxWindow.Height` | Gets the captured height in rows. | +| `LibTmux.PsmuxWindow.Id` | Gets the captured window identifier. | +| `LibTmux.PsmuxWindow.Index` | Gets the captured window index. | +| `LibTmux.PsmuxWindow.Name` | Gets the captured window name. | +| `LibTmux.PsmuxWindow.Server` | Gets the psmux endpoint that produced this observation. | +| `LibTmux.PsmuxWindow.SessionId` | Gets the captured parent session identifier. | +| `LibTmux.PsmuxWindow.Width` | Gets the captured width in columns. | | `LibTmux.Query.AndNode.Operands` | Gets the ordered operands. | | `LibTmux.Query.BooleanConstant.Value` | The literal value. | | `LibTmux.Query.ComparisonNode.Left` | The left operand. | @@ -963,13 +961,6 @@ modes differ. | `LibTmux.ServerConnectionOptions.TmuxBinaryPath` | Gets the tmux executable path. | | `LibTmux.ServerGeneration.ProcessId` | Gets the tmux daemon process identifier. | | `LibTmux.ServerGeneration.StartTime` | Gets the tmux daemon start time. | -| `LibTmux.ServerSnapshot.Depth` | Gets how far down the hierarchy the capture reached. | -| `LibTmux.ServerSnapshot.Generation` | Gets the generation observed during capture. | -| `LibTmux.ServerSnapshot.Panes` | Gets the captured panes, across every window. | -| `LibTmux.ServerSnapshot.Server` | Gets the server this snapshot was read from. | -| `LibTmux.ServerSnapshot.Sessions` | Gets the captured sessions. | -| `LibTmux.ServerSnapshot.WindowEdges` | Gets every session-to-window edge the capture observed. | -| `LibTmux.ServerSnapshot.Windows` | Gets the captured windows, once per session they are linked into. | | `LibTmux.Session.ActivePane` | Gets the active pane recorded when this session was read. | | `LibTmux.Session.ActiveWindow` | Gets the active window recorded when this session was read. | | `LibTmux.Session.Attached` | Gets whether a client was attached when this session was read. | @@ -982,7 +973,6 @@ modes differ. | `LibTmux.Session.Panes` | Gets the panes the capture found in this session. | | `LibTmux.Session.RawFormatFields` | Gets the tmux fields captured when this handle materialized. | | `LibTmux.Session.Server` | Gets the server that owns this session. | -| `LibTmux.Session.Snapshot` | Gets the tmux fields captured when this handle materialized. | | `LibTmux.Session.Windows` | Gets the windows the capture found in this session. | | `LibTmux.SessionId.Value` | Gets the nonnegative numeric value. | | `LibTmux.SessionWindowEdge.Key` | Gets the session and window this edge joins. | @@ -1010,8 +1000,6 @@ modes differ. | `LibTmux.SetOptionRequest.Quiet` | Gets whether a rejected option is answered with nothing instead of an error. | | `LibTmux.SetOptionRequest.Scope` | Gets the scope to set in, or null for the owner's own. | | `LibTmux.SetOptionRequest.Value` | Gets the value to store. | -| `LibTmux.SnapshotLookup`2.Count` | Gets the number of indexed elements. | -| `LibTmux.SnapshotLookup`2.Item(`0)` | Gets the element with the given key. | | `LibTmux.SplitPaneRequest.ActiveBorderStyle` | Gets the border style while the pane is active. | | `LibTmux.SplitPaneRequest.Attach` | Gets whether the new pane becomes active. | | `LibTmux.SplitPaneRequest.Command` | Gets the command the new pane runs. | @@ -1070,6 +1058,8 @@ modes differ. | `LibTmux.TmuxEnvironmentEntry.IsRemoved` | Gets whether tmux strips this variable from new panes. | | `LibTmux.TmuxEnvironmentEntry.Name` | Gets the variable name. | | `LibTmux.TmuxEnvironmentEntry.Value` | Gets the value, or null when the variable is marked removed. | +| `LibTmux.TmuxEventsDroppedEvent.Count` | The events discarded since the previous loss report. | +| `LibTmux.TmuxEventsDroppedEvent.TotalDropped` | The events discarded over this control client's lifetime. | | `LibTmux.TmuxExitEvent.Reason` | Why tmux said it ended, when it said anything. It is silent for an ordinary exit and names a reason when the server went away underneath the client. | | `LibTmux.TmuxHook.Name` | Gets the hook name, without an index. | | `LibTmux.TmuxHook.Values` | Gets the commands it runs, in the order tmux reported. | @@ -1135,7 +1125,6 @@ modes differ. | `LibTmux.Window.RawFormatFields` | Gets the tmux fields captured when this handle materialized. | | `LibTmux.Window.Server` | Gets the server that owns this window. | | `LibTmux.Window.Session` | Gets the session this window was read through. | -| `LibTmux.Window.Snapshot` | Gets the tmux fields captured when this handle materialized. | | `LibTmux.Window.Width` | Gets the window width captured with this handle. | | `LibTmux.WindowEntityKey.SessionId` | The session the window is linked into. | | `LibTmux.WindowEntityKey.WindowId` | The linked window. | @@ -1172,14 +1161,15 @@ modes differ. | `LibTmux.PromptType.Search` | Text to search for. | | `LibTmux.PromptType.Target` | A target to act on. | | `LibTmux.PromptType.WindowTarget` | A window to act on. | +| `LibTmux.PsmuxServer.SupportedBinarySha256` | Gets the exact psmux client executable SHA-256 accepted by this preview. | +| `LibTmux.PsmuxServer.SupportedCommit` | Gets the exact psmux source commit accepted by this preview. | +| `LibTmux.PsmuxServer.SupportedImplementationBanner` | Gets the exact clean implementation banner accepted by this preview. | | `LibTmux.Query.QueryComparison.Equal` | Operands are equal. | | `LibTmux.Query.QueryComparison.GreaterThan` | The left operand is larger. | | `LibTmux.Query.QueryComparison.GreaterThanOrEqual` | The left operand is not smaller. | | `LibTmux.Query.QueryComparison.LessThan` | The left operand is smaller. | | `LibTmux.Query.QueryComparison.LessThanOrEqual` | The left operand is not larger. | | `LibTmux.Query.QueryComparison.NotEqual` | Operands differ. | -| `LibTmux.Query.QueryDocument.CurrentSchema` | The current wire schema identifier. | -| `LibTmux.Query.QueryDocument.CurrentVersion` | The current wire schema version. | | `LibTmux.Query.QueryQuantifier.All` | True when every child matches; true when empty. | | `LibTmux.Query.QueryQuantifier.Any` | True when at least one child matches; false when empty. | | `LibTmux.Query.QueryStringOperation.ContainsOrdinal` | Ordinal substring match. | @@ -1208,6 +1198,9 @@ modes differ. | `LibTmux.TmuxColorMode.Colors256` | Requests 256-color mode. | | `LibTmux.TmuxColorMode.Default` | Uses tmux's default color behavior. | | `LibTmux.TmuxColorMode.TrueColor` | Requests RGB true-color mode. | +| `LibTmux.TmuxDispatchState.Dispatched` | tmux ran the command and answered. The failure is tmux refusing or reporting an error, not the command going missing, so any side effect it had before failing has already happened. | +| `LibTmux.TmuxDispatchState.NotDispatched` | The command never reached tmux, so nothing was done and a retry repeats nothing. This is the only state in which retrying is unconditionally safe. | +| `LibTmux.TmuxDispatchState.Unknown` | Whether tmux acted on the command cannot be determined. Treat a retry as capable of repeating whatever the command does. | | `LibTmux.TmuxOptionState.Absent` | tmux named the option but gave it no value. | | `LibTmux.TmuxOptionState.Off` | tmux reported the flag value off. | | `LibTmux.TmuxOptionState.On` | tmux reported the flag value on. | @@ -1222,6 +1215,3 @@ modes differ. | `LibTmux.WindowResizeMode.Shrink` | Size the window to its smallest client. | | `LibTmux.WindowRotationDirection.Down` | Rotate panes towards the bottom of the window. | | `LibTmux.WindowRotationDirection.Up` | Rotate panes towards the top of the window. | -| `System.Text.RegularExpressions.Generated.Utilities.s_defaultTimeout` | Default timeout value set in , or if none was set. | -| `System.Text.RegularExpressions.Generated.Utilities.s_hasTimeout` | Whether is non-infinite. | -| `System.Text.RegularExpressions.Generated.VersionRegex_0.Instance` | Cached, thread-safe singleton instance. | diff --git a/docs/decisions/0004-public-api-approval.md b/docs/decisions/0004-public-api-approval.md index 7133e62..20323a1 100644 --- a/docs/decisions/0004-public-api-approval.md +++ b/docs/decisions/0004-public-api-approval.md @@ -58,10 +58,11 @@ Raw execution returns an immutable `TmuxCommandResult` with logical arguments, exit code, raw standard-output and standard-error bytes, and normalized line views. Nonzero tmux exit is inspectable data at the raw boundary. -Process-backed entry points carry -`[UnsupportedOSPlatform("windows")]` and perform a runtime platform guard. -Portable IDs, snapshots, query translation and interpretation, and JSON remain -available on Windows. +Process-backed entry points carry `[UnsupportedOSPlatform("windows")]`. +The separate `Psmux*` query facade is analyzer-clean because it exposes only +the pinned-client, bounded preview; it does not make the ordinary tmux entity +surface Windows-compatible. Portable IDs, snapshots, query translation and +interpretation, and JSON remain available on Windows. ### Hierarchy, identity, and ownership diff --git a/docs/mcp/README.md b/docs/mcp/README.md new file mode 100644 index 0000000..ff322fd --- /dev/null +++ b/docs/mcp/README.md @@ -0,0 +1,234 @@ +# tmux MCP server + +How the server behaves: what to wait on, what bounds a result, which tools a +tier registers, and what a subscription holds open. + +[The tool reference](tools.md) is generated by asking the server what it +advertises, so it cannot describe a surface that is not there. [The package +README](../../src/LibTmux.Mcp/README.md) covers installing the server, pointing +a client at it, and the environment variables that configure it. + +## Waiting, not polling + +The tool an assistant reaches for first is usually the wrong one. These four +cover the cases, and the server's instructions steer between them: + +| You want | Use | Why | +|---|---|---| +| Run a command, know if it worked | `tmux_run` | Waits, returns the shell's **real exit status** | +| The same, but it takes minutes | `tmux_start_job` → `tmux_job` | Returns a handle at once; collect later | +| Output you did **not** start | `tmux_wait_for_text` | Normally wakes from pane output; bounded polling is the fallback | +| Watch a pane across turns | `tmux_tail_pane` | Answers only what is **new** since its cursor | + +A job handle stays bound to the exact socket, tmux process, and pane that +started it. Collection and cancellation use that recorded endpoint; command +text is not retained or returned because shell commands often contain secrets. +Collection advances only after its complete MCP response fits the byte ceiling, +so retrying a rejected call does not lose output. A cancelled job retains its +capacity slot until the tmux watcher ends. Starting refuses before sending the +command if its durable handle cannot fit the response ceiling. + +A client that speaks the [Tasks extension](https://modelcontextprotocol.io) can +start `tmux_wait_for_text`, `tmux_wait_for_channel` or `tmux_job` as a task and +collect the result later. It is offered, never required, so a client without it +keeps the blocking call it had. Use `tmux_start_job` for a command that must be +recoverable across calls: `tmux_run` stays synchronous because an SDK task has +no durable tmux job handle if its client disconnects. Listings also stay plain +calls; making one a task would add a round trip to an answer already available. +The in-memory task store admits at most 8 active executions and retains at most +256 results for 15 minutes. Cancellation keeps its active slot until the +background execution actually stops. + +Normally a wait does not sleep in a loop. It subscribes to tmux's own +[control mode](https://github.com/tmux/tmux/wiki/Control-Mode), so tmux reports +pane output as it happens and the wait is released the moment there is +something to look at. + +Two details make that safe. The control client attaches with `ignore-size` +(tmux 3.2+), so it never drags the window down to its own size; and it is +reference counted per session, so it exists only while a wait is running. What +arrives on that stream is the pane's raw terminal bytes, so it is used as a +signal and never as content — the text you get always comes from a capture, +which is what tmux has already rendered. + +If control mode cannot start, waits fall back to polling. Cost changes; +answers do not. + +The tools are ordinary classes, so an application that already hosts an +assistant can run one directly instead of launching a second process: + + +```csharp +using LibTmux; +using LibTmux.Mcp; + +WriteTools tools = McpTools.Writing(server); + +RunResult result = await tools.RunAsync( + "test -f /etc/hostname && echo present", + pane.Id.ToString(), + timeoutSeconds: 20, + cancellationToken: ct); + +// The status comes from the shell, not from reading the screen, so a +// command that prints nothing still says what it did. +Console.WriteLine($"exit {result.ExitStatus}, timed out: {result.TimedOut}"); +``` + + +If `TimedOut` is true, the shell command may still be running. Inspect the pane +and do not call `tmux_run` again; use `tmux_start_job` for work that needs a +durable handle beyond one wait. + +## Nothing returns unbounded output + +Every serialized tool result has a hard byte ceiling. Terminal-text results +keep the **newest** lines and say what they dropped: + +```json +{ + "lines": ["...", "make: *** [build] Error 1"], + "truncated": true, + "droppedLines": 407, + "droppedBytes": 2034 +} +``` + +A reader that cannot see lines are missing concludes the pane never printed +them: + + +```csharp +using LibTmux; +using LibTmux.Mcp; + +ReadTools reading = McpTools.Reading(server); + +CaptureResult captured = await reading.CapturePaneAsync( + pane.Id.ToString(), + includeHistory: true, + maxLines: 5, + cancellationToken: ct); + +// The newest line says what happened, so the budget keeps the end and +// reports what was dropped — silence would look like nothing printed. +Console.WriteLine(captured.Content.ToDisplayString()); +Console.WriteLine($"dropped {captured.Content.DroppedLines} earlier lines"); +``` + + +`tmux_tail_pane` avoids the problem instead of managing it. Pass its cursor +back and the tenth read of a busy pane costs what the first did: + + +```csharp +using LibTmux; +using LibTmux.Mcp; + +ReadTools reading = McpTools.Reading(server); +string paneId = pane.Id.ToString(); + +// A first call establishes a position and returns nothing, so watching +// a pane never starts by paying for a screenful nobody asked for. +TailResult first = await reading.TailPaneAsync(paneId, cancellationToken: ct); + +await reading.WaitForTextAsync( + paneId, + patterns: null, + timeoutSeconds: 5, + cancellationToken: ct); + +TailResult next = await reading.TailPaneAsync(paneId, first.Cursor, cancellationToken: ct); +Console.WriteLine($"{next.Content.Lines.Count} new lines"); +``` + + +A cursor is opaque, authenticated, and bound to the exact socket, tmux server +generation, and pane that issued it. It cannot be moved between panes or +servers, and restarting the MCP server expires it; omit an expired cursor to +establish a new position. + +The byte ceiling covers serialized tool and resource results, including text, +structured content, and metadata. Content tools truncate within it and report +the loss; a result that still cannot fit is replaced by a small error that says +how to narrow the call or raise the ceiling. Oversized resource reads fail with +the same guidance. `tmux_search_panes` also caps its pattern at 4096 UTF-8 bytes +before compiling it or contacting tmux. Pane waits accept at most 32 patterns, +4096 bytes each and 16384 bytes together; channel names share the 4096-byte +input bound. + +To offer these beside your own tools rather than as a separate process: + + +```csharp +using LibTmux; +using LibTmux.Mcp; +using Microsoft.Extensions.DependencyInjection; + +ServiceCollection services = new(); +services.AddLogging(); + +// Registers the tools, resources and prompts, and gates them on the +// tier. Choose the transport yourself — this returns the builder. +McpServerComposition.Add( + services, + new ServerPolicy { Tier = SafetyTier.ReadOnly }, + server.ConnectionOptions, + callerPaneId: null); +``` + + +## Three tiers, and a tool you do not have cannot be called + +`LIBTMUX_SAFETY` picks how much of tmux is exposed. Tools above the tier are +**not registered**, so they never reach the model's list: + +| `LIBTMUX_SAFETY` | Offers | Example | +|---|---|---| +| `readonly` | Reading only | `tmux_capture_pane`, `tmux_search_panes` | +| `mutating` *(default)* | Reading, creating, changing | `tmux_run`, `tmux_split_pane` | +| `destructive` | Everything, including removal | `tmux_kill_session` | + +A tier bounds the tools, not the intent: an assistant denied `tmux_kill_session` +can still type `exit` into a pane with `tmux_send_keys`. Use `readonly` when +that distinction matters. + +## Resources and prompts + +Four fixed resources expose the hierarchy without a tool call: +`tmux://hierarchy`, `tmux://sessions`, `tmux://self`, and `tmux://servers`. +Two resource templates address `tmux://sessions/{id}/panes` and +`tmux://panes/{id}/content`. A client can pin or refresh one on its own +initiative; one nobody reads costs nothing. + +A client that subscribes to `tmux://hierarchy`, `tmux://sessions` or +`tmux://servers` is told when they change, from tmux's own notifications rather +than from a timer — so a view goes stale only when something actually moved. +That watcher holds a second control client, started on the first subscription +and stopped with the last, attached with `no-output` because it wants the +hierarchy and not every byte a pane prints. + +Both subscription shapes are served: `resources/subscribe`, and the +`subscriptions/listen` stream that replaced it in the 2026-07-28 revision. +Duplicate resource URIs are one subscription, and one server admits at most +eight concurrent listen streams; cancel an existing stream before opening a +ninth. Legacy duplicate subscribe requests are likewise one subscription and +one unsubscribe removes it. +The newer one is answered by this server rather than by the SDK's built-in +handling, because that grants the subscription without telling the application +— which would leave a client subscribed to a watcher nobody started, waiting +for events that never come. + +Long calls report progress while they run, so a wait shows as running rather +than hung. It costs nothing when the client asks for none. + +Four prompts package workflows that are easy to get wrong: +`tmux_run_and_report`, `tmux_diagnose_pane`, `tmux_build_workspace`, +`tmux_interrupt_pane`. + +## Standard output belongs to the protocol + +Every log line goes to standard error, and the default level is `Warning` so a +working server is quiet. A message written to the wrong stream does not produce +a stray log line — it corrupts the protocol and the client disconnects. That is +worth knowing if you wrap this in something of your own. diff --git a/docs/modes/control-mode.md b/docs/modes/control-mode.md index 7ec2ff8..9e11244 100644 --- a/docs/modes/control-mode.md +++ b/docs/modes/control-mode.md @@ -45,6 +45,40 @@ and the stream looks mysteriously quiet. The stream ends with `TmuxExitEvent` and then completes, so an `await foreach` is released rather than hanging when the server goes away. +Notifications use a bounded, non-blocking buffer so a slow observer cannot +stall command replies or the control reader. If the buffer fills, the oldest +events are discarded and a `TmuxEventsDroppedEvent` appears immediately before +the next retained event. `Count` is the loss since the previous marker and +`TotalDropped` is the lifetime total. Treat the marker as cache invalidation: +re-read any state that depends on notifications. Command replies travel through +a separate queue and are not dropped by this buffer. + +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 foreach (TmuxEvent observed in control.Events.WithCancellation(ct)) +{ + if (observed is TmuxEventsDroppedEvent dropped) + { + // Anything cached from this stream is now a guess, so the + // marker is a signal to re-read rather than to log. + Console.WriteLine($"missed {dropped.Count}, {dropped.TotalDropped} in total"); + continue; + } + + if (observed is TmuxNotificationEvent { Name: "window-add" }) + { + break; + } +} +``` + + `SendAsync` is safe to call concurrently: tmux answers in the order it was asked, and each caller gets its own answer. diff --git a/docs/psmux.md b/docs/psmux.md new file mode 100644 index 0000000..b86f28b --- /dev/null +++ b/docs/psmux.md @@ -0,0 +1,277 @@ +# psmux query preview on Windows + +LibTmux contains an experimental facade for querying one isolated +[psmux](https://github.com/psmux/psmux) namespace from native Windows .NET or +from a WSL .NET process launching a Windows executable. The release workflow +requires both paths to pass on net8.0 and net10.0; this is a deliberately narrow +preview, not general Windows or tmux parity. + +The public preview is a separate, analyzer-clean API. It exposes only the +behavior audited for the pinned psmux build: + +| Surface | Preview state | +| --- | --- | +| Connect and read the sole session | `PsmuxServer`, `PsmuxSession` | +| Enumerate windows and panes | `PsmuxWindow`, `PsmuxPane` | +| Capture pane text | `PsmuxPane.CaptureAsync(PsmuxCaptureOptions)` | +| Accepted client artifact | One exact Windows x64 executable; release requires a published URL with pinned source and license provenance | +| Native Windows .NET | Required by the release harness on net8.0 and net10.0 | +| WSL-to-Windows process interop | Required by the release harness on net8.0 and net10.0 | +| Session/server lifecycle or mutations | Rejected | +| Raw commands, chaining, control mode, waits, streaming, MCP | Rejected or unavailable | +| Multiple sessions, default namespace, socket paths | Rejected | + +The ordinary `Server`, `Session`, `Window`, and `Pane` surface keeps its Windows +unsupported annotations because its lifecycle, mutation, grouping, control-mode, +and atomic stale-handle contracts still require real tmux. Preview callers do +not suppress `CA1416`; they use the `Psmux*` types instead. + +## Pinned build and trust boundary + +The only accepted client is one Windows x64 executable built from clean psmux +source at commit +`aa26cd39edcfab03e718f94ea21bb47e8c5b85e8`, with the exact banner +`psmux 3.3.7 (aa26cd3 2026-08-17)` and executable SHA-256 +`1abd0eaa3de1ed5491a4f744c8b3db492ae9ac94e9e9a8fea9da217c744ba94e`. +`PsmuxConnectionOptions` requires: + +- an absolute `.exe` path on a fixed local Windows drive rather than `PATH` or + a network share; +- that exact SHA-256, also exposed as `PsmuxServer.SupportedBinarySha256`; +- an absolute local-drive `PSMUX_DATA_DIR` dedicated to this integration; and +- an explicit, non-default, 16–64 character namespace containing one session. + +The namespace uses lowercase ASCII letters, digits, `-`, and `_`; `__` is +rejected. It should be high entropy because psmux discovers `-L name` +registries with a prefix scan. Session names may also use uppercase ASCII. +The data-directory drive letter is uppercased and its segments lowercased +before endpoint identity is constructed. Native Windows rejects mapped, +removable, and network drives. WSL cannot query the Windows drive type, so WSL +callers must ensure both paths are backed by a fixed local Windows drive. +Network shares, filesystem roots, and reserved Windows segments are rejected +because psmux treats this directory as machine-local registry and +process-ownership state. Case-sensitive Windows directories are outside this +preview. + +LibTmux hashes the client and checks its audited build markers before every +launch, then requires the exact two-line version banner. Those checks attest +the client file only. They do not attest the already-running server executable, +its configuration, or replacement of the path between verification and +`Process.Start`. Provision the session with the same verified clean binary at a +caller-controlled, immutable, non-symlink path and an alias-free configuration +with warm helpers disabled. + +The psmux 3.3.7 release build at `05cc5d4` is unsafe for this integration. Its +startup reaper can terminate psmux, tmux, or pmux listeners owned by another +data directory or Windows profile, and that reaper runs before `-V` is parsed. +Never point LibTmux or the smoke harness at that installed build; rejecting its +banner would already be too late. + +### Artifact availability + +The accepted Windows x64 executable is a maintainer validation artifact, not a +published psmux release asset. Commit `aa26cd3` is not tagged. At that commit, +the upstream release workflow requires an existing tag but does not pin its +checkout to the tag input. It also builds with the moving `windows-latest` +image and `stable` Rust toolchain. Rebuilding the same source therefore does +not promise the exact bytes required by this preview. A matching source banner +is not a substitute for the pinned SHA-256. + +Publication of the exact accepted artifact, or selection and review of a +published replacement with a new pinned hash, is a prerequisite to shipping +this preview. Without that published artifact URL, the native/WSL smoke is +available only to a tester who already has the exact artifact. Do not install +or rebuild psmux and assume the result is accepted. + +What a release needs on top of this contract — the repository variables and +the self-hosted runner — is in +[`CONTRIBUTING.md`](../.github/CONTRIBUTING.md#the-psmux-preview-gates). + +## Query from C# + +The example below is compiled as part of the examples project and published +from that source. It takes all endpoint trust values explicitly and cannot +reach mutations, lifecycle, raw commands, chains, or control mode. + + +```csharp +using LibTmux; + +using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(30)); +CancellationToken cancellationToken = cancellation.Token; + +string executable = Environment.GetEnvironmentVariable("LIBTMUX_PSMUX_BINARY") + ?? throw new InvalidOperationException("LIBTMUX_PSMUX_BINARY is required."); +string dataDirectory = Environment.GetEnvironmentVariable("PSMUX_DATA_DIR") + ?? throw new InvalidOperationException("PSMUX_DATA_DIR is required."); +string namespaceName = Environment.GetEnvironmentVariable("LIBTMUX_PSMUX_NAMESPACE") + ?? throw new InvalidOperationException("LIBTMUX_PSMUX_NAMESPACE is required."); + +PsmuxServer server = await PsmuxServer.ConnectAsync( + new PsmuxConnectionOptions( + executablePath: executable, + expectedBinarySha256: PsmuxServer.SupportedBinarySha256, + dataDirectory: dataDirectory, + namespaceName: namespaceName), + cancellationToken); +PsmuxSession session = await server.GetSessionAsync(cancellationToken); + +Console.WriteLine($"{session.Id} {session.Name}"); +foreach (PsmuxWindow window in await session.GetWindowsAsync(cancellationToken)) +{ + Console.WriteLine($" {window.Id} {window.Index}: {window.Name}"); + foreach (PsmuxPane pane in await window.GetPanesAsync(cancellationToken)) + { + IReadOnlyList lines = await pane.CaptureAsync( + new PsmuxCaptureOptions(joinWrappedLines: true), + cancellationToken); + Console.WriteLine($" {pane.Id} {pane.Width}x{pane.Height}"); + foreach (string line in lines) + { + Console.WriteLine($" {line}"); + } + } +} +``` + + +`PsmuxSession`, `PsmuxWindow`, and `PsmuxPane` are immutable observations, not +tmux-style stable handles. Call the query methods again for a fresh observation. +Cancellation covers the streamed prelaunch hash and every client process; the +facade introduces no synchronous file read or process wait. + +Each `PsmuxServer` is bound to the session generation seen at connection time. +A later query throws `InvalidOperationException` if no live session remains and +`StaleServerGenerationException` if the sole session was replaced; the latter +derives from the former. Call `RefreshAsync` to obtain a replacement server +observation. More than one visible session throws `NotSupportedException`, and +a vanished window or pane can throw `TmuxObjectNotFoundException` during target +preflight. + +## Native Windows and WSL smoke + +Build the complete solution from WSL first. This produces both target +frameworks for every packable project and for the checked-in example: + +```console +$ mise exec -- dotnet build \ + LibTmux.slnx \ + --configuration Release \ + --warnaserror +``` + +Pack the final tree, then restore and build the downstream package consumer +through a newly allocated package cache. A fresh cache prevents an older +package with the same prerelease version from satisfying the test: + +```console +$ mise exec -- dotnet pack \ + LibTmux.slnx \ + --configuration Release \ + --no-build \ + --output artifacts/packages +``` + +```console +$ package_cache="$(mktemp -d /tmp/libtmux-dotnet-psmux-nuget.XXXXXX)" && \ + NUGET_PACKAGES="$package_cache" mise exec -- dotnet restore \ + LibTmux.slnx \ + --locked-mode && \ + NUGET_PACKAGES="$package_cache" mise exec -- dotnet restore \ + tests/LibTmux.PackageConsumer/LibTmux.PackageConsumer.csproj && \ + NUGET_PACKAGES="$package_cache" mise exec -- dotnet build \ + tests/LibTmux.PackageConsumer/LibTmux.PackageConsumer.csproj \ + --configuration Release \ + --framework net10.0 \ + --no-restore \ + --warnaserror +``` + +Then run the checked-in harness from native Windows PowerShell. Replace the +example paths with paths on the test machine, but retain the exact SHA shown +below: the harness rejects every other artifact. `DataDirectory` must be a +fresh, nonexistent, high-entropy directory for each run; the harness refuses an +existing directory rather than overwriting anything in it. It disables psmux's +warm helper, removes only the exact session identity it created, and deletes the +owned directory only after the server process and every live registry entry are +gone. The optional WSL arguments make the same native PowerShell process keep +the server alive while both native .NET and WSL .NET query it: + +```console +$ & '\\wsl.localhost\Ubuntu-24.04\home\d\work\libtmux\libtmux-dotnet\eng\psmux\Invoke-PsmuxSmoke.ps1' ` + -PsmuxPath 'C:\Tools\psmux-aa26cd3\psmux.exe' ` + -ExpectedSha256 '1abd0eaa3de1ed5491a4f744c8b3db492ae9ac94e9e9a8fea9da217c744ba94e' ` + -DataDirectory 'C:\Users\me\AppData\Local\Temp\libtmux-psmux-smoke-01a00fd3' ` + -NamespaceName 'libtmux_smoke_01a00fd3' ` + -DotnetPath 'C:\Program Files\dotnet\dotnet.exe' ` + -TestAssembly '\\wsl.localhost\Ubuntu-24.04\home\d\work\libtmux\libtmux-dotnet\tests\LibTmux.UnitTests\bin\Release\net10.0\LibTmux.UnitTests.dll' ` + -ExampleAssembly '\\wsl.localhost\Ubuntu-24.04\home\d\work\libtmux\libtmux-dotnet\examples\LibTmux.Examples\bin\Release\net10.0\LibTmux.Examples.dll' ` + -PackageConsumerAssembly '\\wsl.localhost\Ubuntu-24.04\home\d\work\libtmux\libtmux-dotnet\tests\LibTmux.PackageConsumer\bin\Release\net10.0\LibTmux.PackageConsumer.dll' ` + -TargetFramework 'net10.0' ` + -RunWslSmoke ` + -WslDistribution 'Ubuntu-24.04' ` + -WslRepository '/home/d/work/libtmux/libtmux-dotnet' ` + -WslDotnetPath '/home/d/.config/mise/dotnet-root/dotnet' +``` + +Before its first psmux launch, the harness verifies the SHA and embedded audited +build markers, creates the isolated directory, and sets `PSMUX_DATA_DIR`. It +then requires the exact clean banner, creates an alias-free configuration with +warm helpers disabled, starts +exactly one `powershell.exe` session, writes `héllo-雪-😀`, and waits for that +text to be capturable. Each native and optional WSL leg must pass the focused +public-facade test, run the checked-in example, and query through the packed +NuGet consumer. Repeat the build and harness with `net8.0` paths to cover both +target frameworks. Finally, the harness re-resolves the session ID and +generation it recorded after creation, refuses to kill a changed or unknown +identity, removes only its fresh owned directory, and restores the process +environment and console encoding. It never calls `kill-server` or touches the +default namespace. A failing cleanup is a failing harness run. + +The WSL leg uses the `/mnt/c/...` executable path while retaining a +Windows-absolute `PSMUX_DATA_DIR`. LibTmux owns and canonicalizes `WSLENV`, +removes inherited tmux routing and every `PSMUX_*` entry case-insensitively, +and forwards only `PSMUX_DATA_DIR/w` without path translation. WSL is a client +in this workflow; native PowerShell owns the psmux server lifecycle. A bounded +translation accepts either a Linux or Windows-absolute `WslRepository`, and a +bounded preflight canonicalizes `WslDotnetPath` inside the selected +distribution, requires the result to be an executable regular file, and +confirms that it supplies the `Microsoft.NETCore.App` runtime matching the +selected target framework. Every WSL leg then invokes that exact path; none +depends on a login profile or the non-login `wsl.exe --exec` search path. + +## Exact compatibility limits + +- Exactly one pre-existing session must be visible. psmux window and pane IDs + repeat between session processes, and pid/start generation is per session. +- The client-side allowlist admits only the precise list/display/capture command + shapes emitted by the typed facade. It rejects empty arguments, NUL, CR, LF, + quotes, unsafe backslashes, every semicolon, multiple target options, compact + or unsupported flags, `#(` shell formats, recursive `#{E...}`/`#{T...}` + formats, noncanonical capture ranges, and relative or symbolic targets. +- This allowlist prevents accidental unsupported use; it is not a security + boundary. psmux expands configured canonical command aliases server-side, + and every client invocation performs owned registry maintenance. A trusted, + alias-free server configuration is required. +- Registry enumeration can omit entries after timeout or authentication + failures. LibTmux exact-targets and validates each visible row, but cannot + prove enumeration completeness or eliminate namespace-prefix collisions. +- Session, generation, and object checks are separate client processes. An + external process can mutate the namespace between a preflight and query. + psmux may then return active-object data or an `ERROR:` line with exit code + zero. Target and result parity under external mutation is not claimed. +- Grouped commands and chains are rejected because psmux can silently execute + only the first argv-level command. Control mode is rejected because its `-C` + readiness framing is not tmux-compatible. +- Socket paths, default namespaces, forced color modes, per-client config + files, session creation, lifecycle, mutations, environment discovery, and + 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.7` has no tmux capability profile. Optional tmux flags + remain disabled rather than being inferred from a nearby release. + +This is a core one-shot query preview. `LibTmux.Workspace` and the creation +helpers in `LibTmux.Testing` are unavailable. `LibTmux.Mcp` is also unavailable +for psmux because its writes and waits depend on mutation and control mode. +`LibTmux.Query.Json` remains portable but does not expand this command surface. diff --git a/docs/public-api.json b/docs/public-api.json index 3e52ed3..6305d9c 100644 --- a/docs/public-api.json +++ b/docs/public-api.json @@ -1170,6 +1170,102 @@ "state": [], "summary": "Defines PromptType values." }, + { + "id": "T:LibTmux.PsmuxCaptureOptions", + "namespace": "LibTmux", + "name": "PsmuxCaptureOptions", + "kind": "class", + "package": "LibTmux", + "modifiers": [ + "public", + "sealed" + ], + "baseType": "object", + "interfaces": [], + "ownership": "value", + "state": [], + "summary": "Typed capture choices audited for the psmux query preview." + }, + { + "id": "T:LibTmux.PsmuxConnectionOptions", + "namespace": "LibTmux", + "name": "PsmuxConnectionOptions", + "kind": "class", + "package": "LibTmux", + "modifiers": [ + "public", + "sealed" + ], + "baseType": "object", + "interfaces": [], + "ownership": "value", + "state": [], + "summary": "A pinned psmux client file and one isolated namespace." + }, + { + "id": "T:LibTmux.PsmuxPane", + "namespace": "LibTmux", + "name": "PsmuxPane", + "kind": "class", + "package": "LibTmux", + "modifiers": [ + "public", + "sealed" + ], + "baseType": "object", + "interfaces": [], + "ownership": "value", + "state": [], + "summary": "An immutable pane observation from the psmux query preview." + }, + { + "id": "T:LibTmux.PsmuxServer", + "namespace": "LibTmux", + "name": "PsmuxServer", + "kind": "class", + "package": "LibTmux", + "modifiers": [ + "public", + "sealed" + ], + "baseType": "object", + "interfaces": [], + "ownership": "reference", + "state": [], + "summary": "A query-only connection to one isolated psmux namespace." + }, + { + "id": "T:LibTmux.PsmuxSession", + "namespace": "LibTmux", + "name": "PsmuxSession", + "kind": "class", + "package": "LibTmux", + "modifiers": [ + "public", + "sealed" + ], + "baseType": "object", + "interfaces": [], + "ownership": "value", + "state": [], + "summary": "An immutable observation of the sole psmux session." + }, + { + "id": "T:LibTmux.PsmuxWindow", + "namespace": "LibTmux", + "name": "PsmuxWindow", + "kind": "class", + "package": "LibTmux", + "modifiers": [ + "public", + "sealed" + ], + "baseType": "object", + "interfaces": [], + "ownership": "value", + "state": [], + "summary": "An immutable window observation from the psmux query preview." + }, { "id": "T:LibTmux.Query.AndNode", "namespace": "LibTmux.Query", @@ -2595,9 +2691,10 @@ }, "versionContract": { "grammar": [ - "version = next / release / prerelease", + "version = next / release / micro / prerelease", "next = \"next-\" core", "release = core [patch] [\"-openbsd\"]", + "micro = core \".\" uint", "prerelease = core (\"-rc\" posint / \"-dev\" [\".\" uint])", "core = uint \".\" uint", "patch = 1*LOWER", @@ -2609,6 +2706,7 @@ "majorMinor": "the two invariant-culture decimal core components", "suffixExamples": { "3.7": null, + "3.3.7": "7", "3.7b": "b", "3.0-rc3": "rc3", "3.3a-openbsd": "a-openbsd", @@ -2632,7 +2730,7 @@ "03.7", "3.07", "3.7B", - "3.7.1", + "3.7.01", "3.7-", "+3.7", "integer component overflow" @@ -2640,15 +2738,17 @@ }, "ordering": { "core": "major then minor, numerically ascending", - "sameCore": "next < dev < rcN < final < letter patch", + "sameCore": "next < dev < rcN < final < vendor final < numeric micro < letter patch", "development": "a missing dev number precedes numeric dev numbers", "releaseCandidate": "N compares numerically", + "micro": "N compares numerically", "patch": "bijective base-26 lowercase ordinal: a=1, z=26, aa=27", "vendor": "-openbsd immediately follows its corresponding final or patch release", "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.3 < 3.3.1 < 3.3.10 < 3.3a", "3.7b < next-3.8 < 3.8" ], "invalidOperands": "CompareTo, <, <=, >, >=, IsAtLeast, and EnsureAtLeast throw InvalidOperationException if either operand is invalid", @@ -2980,6 +3080,22 @@ "state": [], "summary": "One thing a tmux control client reported without being asked." }, + { + "id": "T:LibTmux.TmuxEventsDroppedEvent", + "namespace": "LibTmux", + "name": "TmuxEventsDroppedEvent", + "kind": "record", + "package": "LibTmux", + "modifiers": [ + "public", + "sealed" + ], + "baseType": "LibTmux.TmuxEvent", + "interfaces": [], + "ownership": "value", + "state": [], + "summary": "A loss marker emitted when the bounded control-event buffer overflows." + }, { "id": "T:LibTmux.TmuxOutputEvent", "namespace": "LibTmux", @@ -3468,6 +3584,48 @@ "value": 3, "static": true }, + { + "id": "F:LibTmux.PsmuxServer.SupportedBinarySha256", + "declaringType": "T:LibTmux.PsmuxServer", + "name": "SupportedBinarySha256", + "kind": "field", + "visibility": "public", + "package": "LibTmux", + "signature": "const string LibTmux.PsmuxServer.SupportedBinarySha256", + "returnType": "string", + "portable": true, + "summary": "The exact psmux client executable SHA-256 accepted by this preview.", + "value": "1abd0eaa3de1ed5491a4f744c8b3db492ae9ac94e9e9a8fea9da217c744ba94e", + "static": true + }, + { + "id": "F:LibTmux.PsmuxServer.SupportedCommit", + "declaringType": "T:LibTmux.PsmuxServer", + "name": "SupportedCommit", + "kind": "field", + "visibility": "public", + "package": "LibTmux", + "signature": "const string LibTmux.PsmuxServer.SupportedCommit", + "returnType": "string", + "portable": true, + "summary": "The exact psmux source commit accepted by this preview.", + "value": "aa26cd39edcfab03e718f94ea21bb47e8c5b85e8", + "static": true + }, + { + "id": "F:LibTmux.PsmuxServer.SupportedImplementationBanner", + "declaringType": "T:LibTmux.PsmuxServer", + "name": "SupportedImplementationBanner", + "kind": "field", + "visibility": "public", + "package": "LibTmux", + "signature": "const string LibTmux.PsmuxServer.SupportedImplementationBanner", + "returnType": "string", + "portable": true, + "summary": "The exact clean implementation banner accepted by this preview.", + "value": "psmux 3.3.7 (aa26cd3 2026-08-17)", + "static": true + }, { "id": "F:LibTmux.Query.QueryComparison.Equal", "declaringType": "T:LibTmux.Query.QueryComparison", @@ -7868,150 +8026,446 @@ "summary": "Creates PipePaneRequest." }, { - "id": "M:LibTmux.Query.AndNode.#ctor(IReadOnlyList)", - "declaringType": "T:LibTmux.Query.AndNode", + "id": "M:LibTmux.PsmuxCaptureOptions.#ctor(CapturePanePosition?,CapturePanePosition?,bool,bool)", + "declaringType": "T:LibTmux.PsmuxCaptureOptions", "name": ".ctor", "kind": "constructor", "visibility": "public", "package": "LibTmux", "static": false, - "returnType": "AndNode", + "returnType": "PsmuxCaptureOptions", "parameters": [ { - "name": "operands", - "type": "IReadOnlyList" - } - ], - "signature": "AndNode(IReadOnlyList operands)", - "portable": true, - "summary": "Creates AndNode." - }, - { - "id": "M:LibTmux.Query.BooleanConstant.#ctor(bool)", - "declaringType": "T:LibTmux.Query.BooleanConstant", - "name": ".ctor", - "kind": "constructor", - "visibility": "public", - "package": "LibTmux", - "static": false, - "returnType": "BooleanConstant", - "parameters": [ + "name": "startLine", + "type": "CapturePanePosition?", + "default": "null" + }, { - "name": "value", - "type": "bool" + "name": "endLine", + "type": "CapturePanePosition?", + "default": "null" + }, + { + "name": "escapeSequences", + "type": "bool", + "default": "false" + }, + { + "name": "joinWrappedLines", + "type": "bool", + "default": "false" } ], - "signature": "BooleanConstant(bool value)", + "signature": "PsmuxCaptureOptions(CapturePanePosition? startLine = null, CapturePanePosition? endLine = null, bool escapeSequences = false, bool joinWrappedLines = false)", "portable": true, - "summary": "Creates BooleanConstant." + "summary": "Creates an audited psmux capture request." }, { - "id": "M:LibTmux.Query.ComparisonNode.#ctor(QueryComparison,QueryNode,QueryNode)", - "declaringType": "T:LibTmux.Query.ComparisonNode", + "id": "M:LibTmux.PsmuxConnectionOptions.#ctor(string,string,string,string,ILogger?)", + "declaringType": "T:LibTmux.PsmuxConnectionOptions", "name": ".ctor", "kind": "constructor", "visibility": "public", "package": "LibTmux", "static": false, - "returnType": "ComparisonNode", + "returnType": "PsmuxConnectionOptions", "parameters": [ { - "name": "comparison", - "type": "QueryComparison" + "name": "executablePath", + "type": "string" }, { - "name": "left", - "type": "QueryNode" + "name": "expectedBinarySha256", + "type": "string" }, { - "name": "right", - "type": "QueryNode" + "name": "dataDirectory", + "type": "string" + }, + { + "name": "namespaceName", + "type": "string" + }, + { + "name": "logger", + "type": "ILogger?", + "default": "null" } ], - "signature": "ComparisonNode(QueryComparison comparison, QueryNode left, QueryNode right)", + "signature": "PsmuxConnectionOptions(string executablePath, string expectedBinarySha256, string dataDirectory, string namespaceName, ILogger? logger = null)", "portable": true, - "summary": "Creates ComparisonNode." + "summary": "Creates one pinned client and isolated psmux endpoint." }, { - "id": "M:LibTmux.Query.ConstantNode.#ctor(QueryConstant)", - "declaringType": "T:LibTmux.Query.ConstantNode", - "name": ".ctor", - "kind": "constructor", + "id": "M:LibTmux.PsmuxPane.CaptureAsync(PsmuxCaptureOptions?,CancellationToken)", + "declaringType": "T:LibTmux.PsmuxPane", + "name": "CaptureAsync", + "kind": "method", "visibility": "public", "package": "LibTmux", "static": false, - "returnType": "ConstantNode", + "genericParameters": [], + "returnType": "Task>", "parameters": [ { - "name": "value", - "type": "QueryConstant" + "name": "options", + "type": "PsmuxCaptureOptions?", + "default": "null" + }, + { + "name": "cancellationToken", + "type": "CancellationToken", + "default": "default" } ], - "signature": "ConstantNode(QueryConstant value)", + "signature": "Task> LibTmux.PsmuxPane.CaptureAsync(PsmuxCaptureOptions? options = null, CancellationToken cancellationToken = default)", + "performsIO": true, + "processBacked": true, "portable": true, - "summary": "Creates ConstantNode." + "platformAnnotations": [], + "summary": "Captures pane text with best-effort target consistency." }, { - "id": "M:LibTmux.Query.EnumConstant.#ctor(string,string)", - "declaringType": "T:LibTmux.Query.EnumConstant", - "name": ".ctor", - "kind": "constructor", + "id": "M:LibTmux.PsmuxServer.ConnectAsync(PsmuxConnectionOptions,CancellationToken)", + "declaringType": "T:LibTmux.PsmuxServer", + "name": "ConnectAsync", + "kind": "method", "visibility": "public", "package": "LibTmux", - "static": false, - "returnType": "EnumConstant", + "static": true, + "genericParameters": [], + "returnType": "Task", "parameters": [ { - "name": "type", - "type": "string" + "name": "options", + "type": "PsmuxConnectionOptions" }, { - "name": "value", - "type": "string" + "name": "cancellationToken", + "type": "CancellationToken", + "default": "default" } ], - "signature": "EnumConstant(string type, string value)", + "signature": "Task LibTmux.PsmuxServer.ConnectAsync(PsmuxConnectionOptions options, CancellationToken cancellationToken = default)", + "performsIO": true, + "processBacked": true, "portable": true, - "summary": "Creates EnumConstant." + "platformAnnotations": [], + "summary": "Connects through the pinned client and validates one session." }, { - "id": "M:LibTmux.Query.FieldNode.#ctor(QueryTarget,string)", - "declaringType": "T:LibTmux.Query.FieldNode", - "name": ".ctor", - "kind": "constructor", + "id": "M:LibTmux.PsmuxServer.GetPanesAsync(CancellationToken)", + "declaringType": "T:LibTmux.PsmuxServer", + "name": "GetPanesAsync", + "kind": "method", "visibility": "public", "package": "LibTmux", "static": false, - "returnType": "FieldNode", + "genericParameters": [], + "returnType": "Task>", "parameters": [ { - "name": "target", - "type": "QueryTarget" - }, - { - "name": "wireName", - "type": "string" + "name": "cancellationToken", + "type": "CancellationToken", + "default": "default" } ], - "signature": "FieldNode(QueryTarget target, string wireName)", + "signature": "Task> LibTmux.PsmuxServer.GetPanesAsync(CancellationToken cancellationToken = default)", + "performsIO": true, + "processBacked": true, "portable": true, - "summary": "Creates FieldNode." + "platformAnnotations": [], + "summary": "Reads all current panes in the sole visible session." }, { - "id": "M:LibTmux.Query.InstantConstant.#ctor(long)", - "declaringType": "T:LibTmux.Query.InstantConstant", - "name": ".ctor", - "kind": "constructor", + "id": "M:LibTmux.PsmuxServer.GetSessionAsync(CancellationToken)", + "declaringType": "T:LibTmux.PsmuxServer", + "name": "GetSessionAsync", + "kind": "method", "visibility": "public", "package": "LibTmux", "static": false, - "returnType": "InstantConstant", + "genericParameters": [], + "returnType": "Task", "parameters": [ { - "name": "unixSeconds", - "type": "long" - } - ], + "name": "cancellationToken", + "type": "CancellationToken", + "default": "default" + } + ], + "signature": "Task LibTmux.PsmuxServer.GetSessionAsync(CancellationToken cancellationToken = default)", + "performsIO": true, + "processBacked": true, + "portable": true, + "platformAnnotations": [], + "summary": "Reads the sole visible session or fails closed." + }, + { + "id": "M:LibTmux.PsmuxServer.GetWindowsAsync(CancellationToken)", + "declaringType": "T:LibTmux.PsmuxServer", + "name": "GetWindowsAsync", + "kind": "method", + "visibility": "public", + "package": "LibTmux", + "static": false, + "genericParameters": [], + "returnType": "Task>", + "parameters": [ + { + "name": "cancellationToken", + "type": "CancellationToken", + "default": "default" + } + ], + "signature": "Task> LibTmux.PsmuxServer.GetWindowsAsync(CancellationToken cancellationToken = default)", + "performsIO": true, + "processBacked": true, + "portable": true, + "platformAnnotations": [], + "summary": "Reads all current windows in the sole visible session." + }, + { + "id": "M:LibTmux.PsmuxServer.RefreshAsync(CancellationToken)", + "declaringType": "T:LibTmux.PsmuxServer", + "name": "RefreshAsync", + "kind": "method", + "visibility": "public", + "package": "LibTmux", + "static": false, + "genericParameters": [], + "returnType": "Task", + "parameters": [ + { + "name": "cancellationToken", + "type": "CancellationToken", + "default": "default" + } + ], + "signature": "Task LibTmux.PsmuxServer.RefreshAsync(CancellationToken cancellationToken = default)", + "performsIO": true, + "processBacked": true, + "portable": true, + "platformAnnotations": [], + "summary": "Reconnects and returns a fresh endpoint observation." + }, + { + "id": "M:LibTmux.PsmuxSession.GetPanesAsync(CancellationToken)", + "declaringType": "T:LibTmux.PsmuxSession", + "name": "GetPanesAsync", + "kind": "method", + "visibility": "public", + "package": "LibTmux", + "static": false, + "genericParameters": [], + "returnType": "Task>", + "parameters": [ + { + "name": "cancellationToken", + "type": "CancellationToken", + "default": "default" + } + ], + "signature": "Task> LibTmux.PsmuxSession.GetPanesAsync(CancellationToken cancellationToken = default)", + "performsIO": true, + "processBacked": true, + "portable": true, + "platformAnnotations": [], + "summary": "Reads the session's current panes." + }, + { + "id": "M:LibTmux.PsmuxSession.GetWindowsAsync(CancellationToken)", + "declaringType": "T:LibTmux.PsmuxSession", + "name": "GetWindowsAsync", + "kind": "method", + "visibility": "public", + "package": "LibTmux", + "static": false, + "genericParameters": [], + "returnType": "Task>", + "parameters": [ + { + "name": "cancellationToken", + "type": "CancellationToken", + "default": "default" + } + ], + "signature": "Task> LibTmux.PsmuxSession.GetWindowsAsync(CancellationToken cancellationToken = default)", + "performsIO": true, + "processBacked": true, + "portable": true, + "platformAnnotations": [], + "summary": "Reads the session's current windows." + }, + { + "id": "M:LibTmux.PsmuxWindow.GetPanesAsync(CancellationToken)", + "declaringType": "T:LibTmux.PsmuxWindow", + "name": "GetPanesAsync", + "kind": "method", + "visibility": "public", + "package": "LibTmux", + "static": false, + "genericParameters": [], + "returnType": "Task>", + "parameters": [ + { + "name": "cancellationToken", + "type": "CancellationToken", + "default": "default" + } + ], + "signature": "Task> LibTmux.PsmuxWindow.GetPanesAsync(CancellationToken cancellationToken = default)", + "performsIO": true, + "processBacked": true, + "portable": true, + "platformAnnotations": [], + "summary": "Reads the window's current panes." + }, + { + "id": "M:LibTmux.Query.AndNode.#ctor(IReadOnlyList)", + "declaringType": "T:LibTmux.Query.AndNode", + "name": ".ctor", + "kind": "constructor", + "visibility": "public", + "package": "LibTmux", + "static": false, + "returnType": "AndNode", + "parameters": [ + { + "name": "operands", + "type": "IReadOnlyList" + } + ], + "signature": "AndNode(IReadOnlyList operands)", + "portable": true, + "summary": "Creates AndNode." + }, + { + "id": "M:LibTmux.Query.BooleanConstant.#ctor(bool)", + "declaringType": "T:LibTmux.Query.BooleanConstant", + "name": ".ctor", + "kind": "constructor", + "visibility": "public", + "package": "LibTmux", + "static": false, + "returnType": "BooleanConstant", + "parameters": [ + { + "name": "value", + "type": "bool" + } + ], + "signature": "BooleanConstant(bool value)", + "portable": true, + "summary": "Creates BooleanConstant." + }, + { + "id": "M:LibTmux.Query.ComparisonNode.#ctor(QueryComparison,QueryNode,QueryNode)", + "declaringType": "T:LibTmux.Query.ComparisonNode", + "name": ".ctor", + "kind": "constructor", + "visibility": "public", + "package": "LibTmux", + "static": false, + "returnType": "ComparisonNode", + "parameters": [ + { + "name": "comparison", + "type": "QueryComparison" + }, + { + "name": "left", + "type": "QueryNode" + }, + { + "name": "right", + "type": "QueryNode" + } + ], + "signature": "ComparisonNode(QueryComparison comparison, QueryNode left, QueryNode right)", + "portable": true, + "summary": "Creates ComparisonNode." + }, + { + "id": "M:LibTmux.Query.ConstantNode.#ctor(QueryConstant)", + "declaringType": "T:LibTmux.Query.ConstantNode", + "name": ".ctor", + "kind": "constructor", + "visibility": "public", + "package": "LibTmux", + "static": false, + "returnType": "ConstantNode", + "parameters": [ + { + "name": "value", + "type": "QueryConstant" + } + ], + "signature": "ConstantNode(QueryConstant value)", + "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", + "name": ".ctor", + "kind": "constructor", + "visibility": "public", + "package": "LibTmux", + "static": false, + "returnType": "FieldNode", + "parameters": [ + { + "name": "target", + "type": "QueryTarget" + }, + { + "name": "wireName", + "type": "string" + } + ], + "signature": "FieldNode(QueryTarget target, string wireName)", + "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." @@ -18652,14 +19106,434 @@ "summary": "Gets Toggle." }, { - "id": "P:LibTmux.Query.AndNode.Operands", - "declaringType": "T:LibTmux.Query.AndNode", - "name": "Operands", + "id": "P:LibTmux.PsmuxCaptureOptions.EndLine", + "declaringType": "T:LibTmux.PsmuxCaptureOptions", + "name": "EndLine", "kind": "property", "visibility": "public", "package": "LibTmux", "static": false, - "returnType": "IReadOnlyList", + "returnType": "CapturePanePosition?", + "parameters": [], + "signature": "CapturePanePosition? LibTmux.PsmuxCaptureOptions.EndLine { get; }", + "portable": true, + "summary": "Gets the last capture line." + }, + { + "id": "P:LibTmux.PsmuxCaptureOptions.EscapeSequences", + "declaringType": "T:LibTmux.PsmuxCaptureOptions", + "name": "EscapeSequences", + "kind": "property", + "visibility": "public", + "package": "LibTmux", + "static": false, + "returnType": "bool", + "parameters": [], + "signature": "bool LibTmux.PsmuxCaptureOptions.EscapeSequences { get; }", + "portable": true, + "summary": "Gets whether terminal escape sequences are preserved." + }, + { + "id": "P:LibTmux.PsmuxCaptureOptions.JoinWrappedLines", + "declaringType": "T:LibTmux.PsmuxCaptureOptions", + "name": "JoinWrappedLines", + "kind": "property", + "visibility": "public", + "package": "LibTmux", + "static": false, + "returnType": "bool", + "parameters": [], + "signature": "bool LibTmux.PsmuxCaptureOptions.JoinWrappedLines { get; }", + "portable": true, + "summary": "Gets whether wrapped screen rows are joined." + }, + { + "id": "P:LibTmux.PsmuxCaptureOptions.StartLine", + "declaringType": "T:LibTmux.PsmuxCaptureOptions", + "name": "StartLine", + "kind": "property", + "visibility": "public", + "package": "LibTmux", + "static": false, + "returnType": "CapturePanePosition?", + "parameters": [], + "signature": "CapturePanePosition? LibTmux.PsmuxCaptureOptions.StartLine { get; }", + "portable": true, + "summary": "Gets the first capture line." + }, + { + "id": "P:LibTmux.PsmuxConnectionOptions.DataDirectory", + "declaringType": "T:LibTmux.PsmuxConnectionOptions", + "name": "DataDirectory", + "kind": "property", + "visibility": "public", + "package": "LibTmux", + "static": false, + "returnType": "string", + "parameters": [], + "signature": "string LibTmux.PsmuxConnectionOptions.DataDirectory { get; }", + "portable": true, + "summary": "Gets the canonical isolated Windows data directory." + }, + { + "id": "P:LibTmux.PsmuxConnectionOptions.ExecutablePath", + "declaringType": "T:LibTmux.PsmuxConnectionOptions", + "name": "ExecutablePath", + "kind": "property", + "visibility": "public", + "package": "LibTmux", + "static": false, + "returnType": "string", + "parameters": [], + "signature": "string LibTmux.PsmuxConnectionOptions.ExecutablePath { get; }", + "portable": true, + "summary": "Gets the absolute psmux client executable path." + }, + { + "id": "P:LibTmux.PsmuxConnectionOptions.ExpectedBinarySha256", + "declaringType": "T:LibTmux.PsmuxConnectionOptions", + "name": "ExpectedBinarySha256", + "kind": "property", + "visibility": "public", + "package": "LibTmux", + "static": false, + "returnType": "string", + "parameters": [], + "signature": "string LibTmux.PsmuxConnectionOptions.ExpectedBinarySha256 { get; }", + "portable": true, + "summary": "Gets the expected executable SHA-256." + }, + { + "id": "P:LibTmux.PsmuxConnectionOptions.Logger", + "declaringType": "T:LibTmux.PsmuxConnectionOptions", + "name": "Logger", + "kind": "property", + "visibility": "public", + "package": "LibTmux", + "static": false, + "returnType": "ILogger?", + "parameters": [], + "signature": "ILogger? LibTmux.PsmuxConnectionOptions.Logger { get; }", + "portable": true, + "summary": "Gets the optional connection logger." + }, + { + "id": "P:LibTmux.PsmuxConnectionOptions.NamespaceName", + "declaringType": "T:LibTmux.PsmuxConnectionOptions", + "name": "NamespaceName", + "kind": "property", + "visibility": "public", + "package": "LibTmux", + "static": false, + "returnType": "string", + "parameters": [], + "signature": "string LibTmux.PsmuxConnectionOptions.NamespaceName { get; }", + "portable": true, + "summary": "Gets the explicit non-default psmux namespace." + }, + { + "id": "P:LibTmux.PsmuxPane.Height", + "declaringType": "T:LibTmux.PsmuxPane", + "name": "Height", + "kind": "property", + "visibility": "public", + "package": "LibTmux", + "static": false, + "returnType": "int", + "parameters": [], + "signature": "int LibTmux.PsmuxPane.Height { get; }", + "portable": true, + "summary": "Gets the captured pane height." + }, + { + "id": "P:LibTmux.PsmuxPane.Id", + "declaringType": "T:LibTmux.PsmuxPane", + "name": "Id", + "kind": "property", + "visibility": "public", + "package": "LibTmux", + "static": false, + "returnType": "PaneId", + "parameters": [], + "signature": "PaneId LibTmux.PsmuxPane.Id { get; }", + "portable": true, + "summary": "Gets the captured pane identifier." + }, + { + "id": "P:LibTmux.PsmuxPane.Index", + "declaringType": "T:LibTmux.PsmuxPane", + "name": "Index", + "kind": "property", + "visibility": "public", + "package": "LibTmux", + "static": false, + "returnType": "int", + "parameters": [], + "signature": "int LibTmux.PsmuxPane.Index { get; }", + "portable": true, + "summary": "Gets the captured pane index." + }, + { + "id": "P:LibTmux.PsmuxPane.Server", + "declaringType": "T:LibTmux.PsmuxPane", + "name": "Server", + "kind": "property", + "visibility": "public", + "package": "LibTmux", + "static": false, + "returnType": "PsmuxServer", + "parameters": [], + "signature": "PsmuxServer LibTmux.PsmuxPane.Server { get; }", + "portable": true, + "summary": "Gets the psmux endpoint that produced the observation." + }, + { + "id": "P:LibTmux.PsmuxPane.SessionId", + "declaringType": "T:LibTmux.PsmuxPane", + "name": "SessionId", + "kind": "property", + "visibility": "public", + "package": "LibTmux", + "static": false, + "returnType": "SessionId", + "parameters": [], + "signature": "SessionId LibTmux.PsmuxPane.SessionId { get; }", + "portable": true, + "summary": "Gets the captured parent session identifier." + }, + { + "id": "P:LibTmux.PsmuxPane.Title", + "declaringType": "T:LibTmux.PsmuxPane", + "name": "Title", + "kind": "property", + "visibility": "public", + "package": "LibTmux", + "static": false, + "returnType": "string?", + "parameters": [], + "signature": "string? LibTmux.PsmuxPane.Title { get; }", + "portable": true, + "summary": "Gets the captured pane title." + }, + { + "id": "P:LibTmux.PsmuxPane.Width", + "declaringType": "T:LibTmux.PsmuxPane", + "name": "Width", + "kind": "property", + "visibility": "public", + "package": "LibTmux", + "static": false, + "returnType": "int", + "parameters": [], + "signature": "int LibTmux.PsmuxPane.Width { get; }", + "portable": true, + "summary": "Gets the captured pane width." + }, + { + "id": "P:LibTmux.PsmuxPane.WindowId", + "declaringType": "T:LibTmux.PsmuxPane", + "name": "WindowId", + "kind": "property", + "visibility": "public", + "package": "LibTmux", + "static": false, + "returnType": "WindowId", + "parameters": [], + "signature": "WindowId LibTmux.PsmuxPane.WindowId { get; }", + "portable": true, + "summary": "Gets the captured parent window identifier." + }, + { + "id": "P:LibTmux.PsmuxServer.ConnectionOptions", + "declaringType": "T:LibTmux.PsmuxServer", + "name": "ConnectionOptions", + "kind": "property", + "visibility": "public", + "package": "LibTmux", + "static": false, + "returnType": "PsmuxConnectionOptions", + "parameters": [], + "signature": "PsmuxConnectionOptions LibTmux.PsmuxServer.ConnectionOptions { get; }", + "portable": true, + "summary": "Gets the endpoint trust and routing settings." + }, + { + "id": "P:LibTmux.PsmuxServer.Version", + "declaringType": "T:LibTmux.PsmuxServer", + "name": "Version", + "kind": "property", + "visibility": "public", + "package": "LibTmux", + "static": false, + "returnType": "TmuxVersion", + "parameters": [], + "signature": "TmuxVersion LibTmux.PsmuxServer.Version { get; }", + "portable": true, + "summary": "Gets the psmux compatibility version." + }, + { + "id": "P:LibTmux.PsmuxSession.Attached", + "declaringType": "T:LibTmux.PsmuxSession", + "name": "Attached", + "kind": "property", + "visibility": "public", + "package": "LibTmux", + "static": false, + "returnType": "bool", + "parameters": [], + "signature": "bool LibTmux.PsmuxSession.Attached { get; }", + "portable": true, + "summary": "Gets whether a client was attached when observed." + }, + { + "id": "P:LibTmux.PsmuxSession.Id", + "declaringType": "T:LibTmux.PsmuxSession", + "name": "Id", + "kind": "property", + "visibility": "public", + "package": "LibTmux", + "static": false, + "returnType": "SessionId", + "parameters": [], + "signature": "SessionId LibTmux.PsmuxSession.Id { get; }", + "portable": true, + "summary": "Gets the captured session identifier." + }, + { + "id": "P:LibTmux.PsmuxSession.Name", + "declaringType": "T:LibTmux.PsmuxSession", + "name": "Name", + "kind": "property", + "visibility": "public", + "package": "LibTmux", + "static": false, + "returnType": "string", + "parameters": [], + "signature": "string LibTmux.PsmuxSession.Name { get; }", + "portable": true, + "summary": "Gets the captured session name." + }, + { + "id": "P:LibTmux.PsmuxSession.Server", + "declaringType": "T:LibTmux.PsmuxSession", + "name": "Server", + "kind": "property", + "visibility": "public", + "package": "LibTmux", + "static": false, + "returnType": "PsmuxServer", + "parameters": [], + "signature": "PsmuxServer LibTmux.PsmuxSession.Server { get; }", + "portable": true, + "summary": "Gets the psmux endpoint that produced the observation." + }, + { + "id": "P:LibTmux.PsmuxWindow.Height", + "declaringType": "T:LibTmux.PsmuxWindow", + "name": "Height", + "kind": "property", + "visibility": "public", + "package": "LibTmux", + "static": false, + "returnType": "int", + "parameters": [], + "signature": "int LibTmux.PsmuxWindow.Height { get; }", + "portable": true, + "summary": "Gets the captured window height." + }, + { + "id": "P:LibTmux.PsmuxWindow.Id", + "declaringType": "T:LibTmux.PsmuxWindow", + "name": "Id", + "kind": "property", + "visibility": "public", + "package": "LibTmux", + "static": false, + "returnType": "WindowId", + "parameters": [], + "signature": "WindowId LibTmux.PsmuxWindow.Id { get; }", + "portable": true, + "summary": "Gets the captured window identifier." + }, + { + "id": "P:LibTmux.PsmuxWindow.Index", + "declaringType": "T:LibTmux.PsmuxWindow", + "name": "Index", + "kind": "property", + "visibility": "public", + "package": "LibTmux", + "static": false, + "returnType": "int", + "parameters": [], + "signature": "int LibTmux.PsmuxWindow.Index { get; }", + "portable": true, + "summary": "Gets the captured window index." + }, + { + "id": "P:LibTmux.PsmuxWindow.Name", + "declaringType": "T:LibTmux.PsmuxWindow", + "name": "Name", + "kind": "property", + "visibility": "public", + "package": "LibTmux", + "static": false, + "returnType": "string", + "parameters": [], + "signature": "string LibTmux.PsmuxWindow.Name { get; }", + "portable": true, + "summary": "Gets the captured window name." + }, + { + "id": "P:LibTmux.PsmuxWindow.Server", + "declaringType": "T:LibTmux.PsmuxWindow", + "name": "Server", + "kind": "property", + "visibility": "public", + "package": "LibTmux", + "static": false, + "returnType": "PsmuxServer", + "parameters": [], + "signature": "PsmuxServer LibTmux.PsmuxWindow.Server { get; }", + "portable": true, + "summary": "Gets the psmux endpoint that produced the observation." + }, + { + "id": "P:LibTmux.PsmuxWindow.SessionId", + "declaringType": "T:LibTmux.PsmuxWindow", + "name": "SessionId", + "kind": "property", + "visibility": "public", + "package": "LibTmux", + "static": false, + "returnType": "SessionId", + "parameters": [], + "signature": "SessionId LibTmux.PsmuxWindow.SessionId { get; }", + "portable": true, + "summary": "Gets the captured parent session identifier." + }, + { + "id": "P:LibTmux.PsmuxWindow.Width", + "declaringType": "T:LibTmux.PsmuxWindow", + "name": "Width", + "kind": "property", + "visibility": "public", + "package": "LibTmux", + "static": false, + "returnType": "int", + "parameters": [], + "signature": "int LibTmux.PsmuxWindow.Width { get; }", + "portable": true, + "summary": "Gets the captured window width." + }, + { + "id": "P:LibTmux.Query.AndNode.Operands", + "declaringType": "T:LibTmux.Query.AndNode", + "name": "Operands", + "kind": "property", + "visibility": "public", + "package": "LibTmux", + "static": false, + "returnType": "IReadOnlyList", "parameters": [], "signature": "IReadOnlyList LibTmux.Query.AndNode.Operands { get; }", "portable": true, @@ -23005,6 +23879,66 @@ "signature": "enum LibTmux.PromptType", "portable": true }, + { + "id": "T:LibTmux.PsmuxCaptureOptions", + "declaringType": "T:LibTmux.PsmuxCaptureOptions", + "name": "PsmuxCaptureOptions", + "kind": "type", + "visibility": "public", + "package": "LibTmux", + "signature": "class LibTmux.PsmuxCaptureOptions", + "portable": true + }, + { + "id": "T:LibTmux.PsmuxConnectionOptions", + "declaringType": "T:LibTmux.PsmuxConnectionOptions", + "name": "PsmuxConnectionOptions", + "kind": "type", + "visibility": "public", + "package": "LibTmux", + "signature": "class LibTmux.PsmuxConnectionOptions", + "portable": true + }, + { + "id": "T:LibTmux.PsmuxPane", + "declaringType": "T:LibTmux.PsmuxPane", + "name": "PsmuxPane", + "kind": "type", + "visibility": "public", + "package": "LibTmux", + "signature": "class LibTmux.PsmuxPane", + "portable": true + }, + { + "id": "T:LibTmux.PsmuxServer", + "declaringType": "T:LibTmux.PsmuxServer", + "name": "PsmuxServer", + "kind": "type", + "visibility": "public", + "package": "LibTmux", + "signature": "class LibTmux.PsmuxServer", + "portable": true + }, + { + "id": "T:LibTmux.PsmuxSession", + "declaringType": "T:LibTmux.PsmuxSession", + "name": "PsmuxSession", + "kind": "type", + "visibility": "public", + "package": "LibTmux", + "signature": "class LibTmux.PsmuxSession", + "portable": true + }, + { + "id": "T:LibTmux.PsmuxWindow", + "declaringType": "T:LibTmux.PsmuxWindow", + "name": "PsmuxWindow", + "kind": "type", + "visibility": "public", + "package": "LibTmux", + "signature": "class LibTmux.PsmuxWindow", + "portable": true + }, { "id": "T:LibTmux.Query.AndNode", "declaringType": "T:LibTmux.Query.AndNode", @@ -24015,6 +24949,16 @@ "signature": "abstract record LibTmux.TmuxEvent", "portable": true }, + { + "id": "T:LibTmux.TmuxEventsDroppedEvent", + "declaringType": "T:LibTmux.TmuxEventsDroppedEvent", + "name": "TmuxEventsDroppedEvent", + "kind": "type", + "visibility": "public", + "package": "LibTmux", + "signature": "record LibTmux.TmuxEventsDroppedEvent", + "portable": true + }, { "id": "T:LibTmux.TmuxOutputEvent", "declaringType": "T:LibTmux.TmuxOutputEvent", @@ -24057,15 +25001,22 @@ "parameters": [ { "name": "target", - "type": "string?" + "type": "string?", + "default": "null" }, { "name": "cancellationToken", - "type": "System.Threading.CancellationToken" + "type": "CancellationToken", + "default": "default" } ], "signature": "Task EnterControlModeAsync(string? target = null, CancellationToken cancellationToken = default)", - "portable": true, + "performsIO": true, + "processBacked": true, + "portable": false, + "platformAnnotations": [ + "UnsupportedOSPlatform(\"windows\")" + ], "summary": "Starts a tmux control client and keeps it running." }, { @@ -24221,6 +25172,57 @@ "portable": true, "summary": "Gets Arguments." }, + { + "id": "M:LibTmux.TmuxEventsDroppedEvent.#ctor(long,long)", + "declaringType": "T:LibTmux.TmuxEventsDroppedEvent", + "name": ".ctor", + "kind": "constructor", + "visibility": "public", + "package": "LibTmux", + "static": false, + "returnType": "TmuxEventsDroppedEvent", + "parameters": [ + { + "name": "Count", + "type": "long" + }, + { + "name": "TotalDropped", + "type": "long" + } + ], + "signature": "TmuxEventsDroppedEvent(long Count, long TotalDropped)", + "portable": true, + "summary": "Creates a bounded-event-buffer loss marker." + }, + { + "id": "P:LibTmux.TmuxEventsDroppedEvent.Count", + "declaringType": "T:LibTmux.TmuxEventsDroppedEvent", + "name": "Count", + "kind": "property", + "visibility": "public", + "package": "LibTmux", + "static": false, + "returnType": "long", + "parameters": [], + "signature": "long LibTmux.TmuxEventsDroppedEvent.Count { get; }", + "portable": true, + "summary": "Gets the events discarded since the previous loss report." + }, + { + "id": "P:LibTmux.TmuxEventsDroppedEvent.TotalDropped", + "declaringType": "T:LibTmux.TmuxEventsDroppedEvent", + "name": "TotalDropped", + "kind": "property", + "visibility": "public", + "package": "LibTmux", + "static": false, + "returnType": "long", + "parameters": [], + "signature": "long LibTmux.TmuxEventsDroppedEvent.TotalDropped { get; }", + "portable": true, + "summary": "Gets the events discarded over this control client's lifetime." + }, { "id": "M:LibTmux.TmuxExitEvent.#ctor(string?)", "declaringType": "T:LibTmux.TmuxExitEvent", diff --git a/docs/public-api.md b/docs/public-api.md index 7c4601a..8fc1d36 100644 --- a/docs/public-api.md +++ b/docs/public-api.md @@ -23,9 +23,10 @@ The complete parsing, ordering, detection, and support contract follows. ```json { "grammar": [ - "version = next / release / prerelease", + "version = next / release / micro / prerelease", "next = \"next-\" core", "release = core [patch] [\"-openbsd\"]", + "micro = core \".\" uint", "prerelease = core (\"-rc\" posint / \"-dev\" [\".\" uint])", "core = uint \".\" uint", "patch = 1*LOWER", @@ -37,6 +38,7 @@ The complete parsing, ordering, detection, and support contract follows. "majorMinor": "the two invariant-culture decimal core components", "suffixExamples": { "3.7": null, + "3.3.7": "7", "3.7b": "b", "3.0-rc3": "rc3", "3.3a-openbsd": "a-openbsd", @@ -60,7 +62,7 @@ The complete parsing, ordering, detection, and support contract follows. "03.7", "3.07", "3.7B", - "3.7.1", + "3.7.01", "3.7-", "+3.7", "integer component overflow" @@ -68,15 +70,17 @@ The complete parsing, ordering, detection, and support contract follows. }, "ordering": { "core": "major then minor, numerically ascending", - "sameCore": "next < dev < rcN < final < letter patch", + "sameCore": "next < dev < rcN < final < vendor final < numeric micro < letter patch", "development": "a missing dev number precedes numeric dev numbers", "releaseCandidate": "N compares numerically", + "micro": "N compares numerically", "patch": "bijective base-26 lowercase ordinal: a=1, z=26, aa=27", "vendor": "-openbsd immediately follows its corresponding final or patch release", "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.3 < 3.3.1 < 3.3.10 < 3.3a", "3.7b < next-3.8 < 3.8" ], "invalidOperands": "CompareTo, <, <=, >, >=, IsAtLeast, and EnsureAtLeast throw InvalidOperationException if either operand is invalid", @@ -326,6 +330,12 @@ internal static class Program | `T:LibTmux.PipePaneRequest` | record | `public, sealed` | None | `object` | value | Parameters for PipePane. | `LibTmux` | | `T:LibTmux.PopupCloseMode` | enum | `public` | None | `Enum` | value | Defines PopupCloseMode values. | `LibTmux` | | `T:LibTmux.PromptType` | enum | `public` | None | `Enum` | value | Defines PromptType values. | `LibTmux` | +| `T:LibTmux.PsmuxCaptureOptions` | class | `public, sealed` | None | `object` | value | Typed capture choices audited for the psmux query preview. | `LibTmux` | +| `T:LibTmux.PsmuxConnectionOptions` | class | `public, sealed` | None | `object` | value | A pinned psmux client file and one isolated namespace. | `LibTmux` | +| `T:LibTmux.PsmuxPane` | class | `public, sealed` | None | `object` | value | An immutable pane observation from the psmux query preview. | `LibTmux` | +| `T:LibTmux.PsmuxServer` | class | `public, sealed` | None | `object` | reference | A query-only connection to one isolated psmux namespace. | `LibTmux` | +| `T:LibTmux.PsmuxSession` | class | `public, sealed` | None | `object` | value | An immutable observation of the sole psmux session. | `LibTmux` | +| `T:LibTmux.PsmuxWindow` | class | `public, sealed` | None | `object` | value | An immutable window observation from the psmux query preview. | `LibTmux` | | `T:LibTmux.Query.AndNode` | record | `public, sealed` | None | `QueryNode` | value | A canonical and query node. Equality: structural ordered operand equality and hashing. | `LibTmux` | | `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` | @@ -428,6 +438,7 @@ internal static class Program | `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.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` | | `T:LibTmux.TmuxNotificationEvent` | record | `public, sealed` | None | `LibTmux.TmuxEvent` | value | A tmux notification carried by name with its words unparsed. | `LibTmux` | | `T:LibTmux.TmuxExitEvent` | record | `public, sealed` | None | `LibTmux.TmuxEvent` | value | The control client ended; always the last event in the stream. | `LibTmux` | @@ -724,6 +735,8 @@ internal static class Program | Member ID | Declaration | Visibility | Static | Platform | Notes | | --- | --- | --- | --- | --- | --- | | `M:LibTmux.LibTmuxException.#ctor(string,Exception?)` | `LibTmuxException(string message, Exception? innerException = null)` | Public | No | Portable | Creates LibTmuxException. | +| `M:LibTmux.LibTmuxException.#ctor(string,TmuxDispatchState,Exception?)` | `LibTmuxException(string message, TmuxDispatchState dispatch, Exception? innerException = null)` | Public | No | Portable | Creates LibTmuxException with a known dispatch state. | +| `P:LibTmux.LibTmuxException.Dispatch` | `TmuxDispatchState LibTmux.LibTmuxException.Dispatch { get; }` | Public | No | Portable | Gets whether the command reached tmux, and so whether a retry is safe. | ### `T:LibTmux.LibTmuxInfo` @@ -1009,6 +1022,80 @@ internal static class Program | `F:LibTmux.PromptType.Target` | `Target = 2` | Public | Implicit | Portable | The Target value. Value: `2`. | | `F:LibTmux.PromptType.WindowTarget` | `WindowTarget = 3` | Public | Implicit | Portable | The WindowTarget value. Value: `3`. | +### `T:LibTmux.PsmuxCaptureOptions` + +| Member ID | Declaration | Visibility | Static | Platform | Notes | +| --- | --- | --- | --- | --- | --- | +| `M:LibTmux.PsmuxCaptureOptions.#ctor(CapturePanePosition?,CapturePanePosition?,bool,bool)` | `PsmuxCaptureOptions(CapturePanePosition? startLine = null, CapturePanePosition? endLine = null, bool escapeSequences = false, bool joinWrappedLines = false)` | Public | No | Portable | Creates an audited psmux capture request. | +| `P:LibTmux.PsmuxCaptureOptions.EndLine` | `CapturePanePosition? LibTmux.PsmuxCaptureOptions.EndLine { get; }` | Public | No | Portable | Gets the last capture line. | +| `P:LibTmux.PsmuxCaptureOptions.EscapeSequences` | `bool LibTmux.PsmuxCaptureOptions.EscapeSequences { get; }` | Public | No | Portable | Gets whether terminal escape sequences are preserved. | +| `P:LibTmux.PsmuxCaptureOptions.JoinWrappedLines` | `bool LibTmux.PsmuxCaptureOptions.JoinWrappedLines { get; }` | Public | No | Portable | Gets whether wrapped screen rows are joined. | +| `P:LibTmux.PsmuxCaptureOptions.StartLine` | `CapturePanePosition? LibTmux.PsmuxCaptureOptions.StartLine { get; }` | Public | No | Portable | Gets the first capture line. | + +### `T:LibTmux.PsmuxConnectionOptions` + +| Member ID | Declaration | Visibility | Static | Platform | Notes | +| --- | --- | --- | --- | --- | --- | +| `M:LibTmux.PsmuxConnectionOptions.#ctor(string,string,string,string,ILogger?)` | `PsmuxConnectionOptions(string executablePath, string expectedBinarySha256, string dataDirectory, string namespaceName, ILogger? logger = null)` | Public | No | Portable | Creates one pinned client and isolated psmux endpoint. | +| `P:LibTmux.PsmuxConnectionOptions.DataDirectory` | `string LibTmux.PsmuxConnectionOptions.DataDirectory { get; }` | Public | No | Portable | Gets the canonical isolated Windows data directory. | +| `P:LibTmux.PsmuxConnectionOptions.ExecutablePath` | `string LibTmux.PsmuxConnectionOptions.ExecutablePath { get; }` | Public | No | Portable | Gets the absolute psmux client executable path. | +| `P:LibTmux.PsmuxConnectionOptions.ExpectedBinarySha256` | `string LibTmux.PsmuxConnectionOptions.ExpectedBinarySha256 { get; }` | Public | No | Portable | Gets the expected executable SHA-256. | +| `P:LibTmux.PsmuxConnectionOptions.Logger` | `ILogger? LibTmux.PsmuxConnectionOptions.Logger { get; }` | Public | No | Portable | Gets the optional connection logger. | +| `P:LibTmux.PsmuxConnectionOptions.NamespaceName` | `string LibTmux.PsmuxConnectionOptions.NamespaceName { get; }` | Public | No | Portable | Gets the explicit non-default psmux namespace. | + +### `T:LibTmux.PsmuxPane` + +| Member ID | Declaration | Visibility | Static | Platform | Notes | +| --- | --- | --- | --- | --- | --- | +| `M:LibTmux.PsmuxPane.CaptureAsync(PsmuxCaptureOptions?,CancellationToken)` | `Task> LibTmux.PsmuxPane.CaptureAsync(PsmuxCaptureOptions? options = null, CancellationToken cancellationToken = default)` | Public | No | Portable | Captures pane text with best-effort target consistency. | +| `P:LibTmux.PsmuxPane.Height` | `int LibTmux.PsmuxPane.Height { get; }` | Public | No | Portable | Gets the captured pane height. | +| `P:LibTmux.PsmuxPane.Id` | `PaneId LibTmux.PsmuxPane.Id { get; }` | Public | No | Portable | Gets the captured pane identifier. | +| `P:LibTmux.PsmuxPane.Index` | `int LibTmux.PsmuxPane.Index { get; }` | Public | No | Portable | Gets the captured pane index. | +| `P:LibTmux.PsmuxPane.Server` | `PsmuxServer LibTmux.PsmuxPane.Server { get; }` | Public | No | Portable | Gets the psmux endpoint that produced the observation. | +| `P:LibTmux.PsmuxPane.SessionId` | `SessionId LibTmux.PsmuxPane.SessionId { get; }` | Public | No | Portable | Gets the captured parent session identifier. | +| `P:LibTmux.PsmuxPane.Title` | `string? LibTmux.PsmuxPane.Title { get; }` | Public | No | Portable | Gets the captured pane title. | +| `P:LibTmux.PsmuxPane.Width` | `int LibTmux.PsmuxPane.Width { get; }` | Public | No | Portable | Gets the captured pane width. | +| `P:LibTmux.PsmuxPane.WindowId` | `WindowId LibTmux.PsmuxPane.WindowId { get; }` | Public | No | Portable | Gets the captured parent window identifier. | + +### `T:LibTmux.PsmuxServer` + +| Member ID | Declaration | Visibility | Static | Platform | Notes | +| --- | --- | --- | --- | --- | --- | +| `F:LibTmux.PsmuxServer.SupportedBinarySha256` | `static const string LibTmux.PsmuxServer.SupportedBinarySha256` | Public | Yes | Portable | The exact psmux client executable SHA-256 accepted by this preview. Value: `1abd0eaa3de1ed5491a4f744c8b3db492ae9ac94e9e9a8fea9da217c744ba94e`. | +| `F:LibTmux.PsmuxServer.SupportedCommit` | `static const string LibTmux.PsmuxServer.SupportedCommit` | Public | Yes | Portable | The exact psmux source commit accepted by this preview. Value: `aa26cd39edcfab03e718f94ea21bb47e8c5b85e8`. | +| `F:LibTmux.PsmuxServer.SupportedImplementationBanner` | `static const string LibTmux.PsmuxServer.SupportedImplementationBanner` | Public | Yes | Portable | The exact clean implementation banner accepted by this preview. Value: `psmux 3.3.7 (aa26cd3 2026-08-17)`. | +| `M:LibTmux.PsmuxServer.ConnectAsync(PsmuxConnectionOptions,CancellationToken)` | `static Task LibTmux.PsmuxServer.ConnectAsync(PsmuxConnectionOptions options, CancellationToken cancellationToken = default)` | Public | Yes | Portable | Connects through the pinned client and validates one session. | +| `M:LibTmux.PsmuxServer.GetPanesAsync(CancellationToken)` | `Task> LibTmux.PsmuxServer.GetPanesAsync(CancellationToken cancellationToken = default)` | Public | No | Portable | Reads all current panes in the sole visible session. | +| `M:LibTmux.PsmuxServer.GetSessionAsync(CancellationToken)` | `Task LibTmux.PsmuxServer.GetSessionAsync(CancellationToken cancellationToken = default)` | Public | No | Portable | Reads the sole visible session or fails closed. | +| `M:LibTmux.PsmuxServer.GetWindowsAsync(CancellationToken)` | `Task> LibTmux.PsmuxServer.GetWindowsAsync(CancellationToken cancellationToken = default)` | Public | No | Portable | Reads all current windows in the sole visible session. | +| `M:LibTmux.PsmuxServer.RefreshAsync(CancellationToken)` | `Task LibTmux.PsmuxServer.RefreshAsync(CancellationToken cancellationToken = default)` | Public | No | Portable | Reconnects and returns a fresh endpoint observation. | +| `P:LibTmux.PsmuxServer.ConnectionOptions` | `PsmuxConnectionOptions LibTmux.PsmuxServer.ConnectionOptions { get; }` | Public | No | Portable | Gets the endpoint trust and routing settings. | +| `P:LibTmux.PsmuxServer.Version` | `TmuxVersion LibTmux.PsmuxServer.Version { get; }` | Public | No | Portable | Gets the psmux compatibility version. | + +### `T:LibTmux.PsmuxSession` + +| Member ID | Declaration | Visibility | Static | Platform | Notes | +| --- | --- | --- | --- | --- | --- | +| `M:LibTmux.PsmuxSession.GetPanesAsync(CancellationToken)` | `Task> LibTmux.PsmuxSession.GetPanesAsync(CancellationToken cancellationToken = default)` | Public | No | Portable | Reads the session's current panes. | +| `M:LibTmux.PsmuxSession.GetWindowsAsync(CancellationToken)` | `Task> LibTmux.PsmuxSession.GetWindowsAsync(CancellationToken cancellationToken = default)` | Public | No | Portable | Reads the session's current windows. | +| `P:LibTmux.PsmuxSession.Attached` | `bool LibTmux.PsmuxSession.Attached { get; }` | Public | No | Portable | Gets whether a client was attached when observed. | +| `P:LibTmux.PsmuxSession.Id` | `SessionId LibTmux.PsmuxSession.Id { get; }` | Public | No | Portable | Gets the captured session identifier. | +| `P:LibTmux.PsmuxSession.Name` | `string LibTmux.PsmuxSession.Name { get; }` | Public | No | Portable | Gets the captured session name. | +| `P:LibTmux.PsmuxSession.Server` | `PsmuxServer LibTmux.PsmuxSession.Server { get; }` | Public | No | Portable | Gets the psmux endpoint that produced the observation. | + +### `T:LibTmux.PsmuxWindow` + +| Member ID | Declaration | Visibility | Static | Platform | Notes | +| --- | --- | --- | --- | --- | --- | +| `M:LibTmux.PsmuxWindow.GetPanesAsync(CancellationToken)` | `Task> LibTmux.PsmuxWindow.GetPanesAsync(CancellationToken cancellationToken = default)` | Public | No | Portable | Reads the window's current panes. | +| `P:LibTmux.PsmuxWindow.Height` | `int LibTmux.PsmuxWindow.Height { get; }` | Public | No | Portable | Gets the captured window height. | +| `P:LibTmux.PsmuxWindow.Id` | `WindowId LibTmux.PsmuxWindow.Id { get; }` | Public | No | Portable | Gets the captured window identifier. | +| `P:LibTmux.PsmuxWindow.Index` | `int LibTmux.PsmuxWindow.Index { get; }` | Public | No | Portable | Gets the captured window index. | +| `P:LibTmux.PsmuxWindow.Name` | `string LibTmux.PsmuxWindow.Name { get; }` | Public | No | Portable | Gets the captured window name. | +| `P:LibTmux.PsmuxWindow.Server` | `PsmuxServer LibTmux.PsmuxWindow.Server { get; }` | Public | No | Portable | Gets the psmux endpoint that produced the observation. | +| `P:LibTmux.PsmuxWindow.SessionId` | `SessionId LibTmux.PsmuxWindow.SessionId { get; }` | Public | No | Portable | Gets the captured parent session identifier. | +| `P:LibTmux.PsmuxWindow.Width` | `int LibTmux.PsmuxWindow.Width { get; }` | Public | No | Portable | Gets the captured window width. | + ### `T:LibTmux.Query.AndNode` | Member ID | Declaration | Visibility | Static | Platform | Notes | @@ -1334,7 +1421,7 @@ internal static class Program | `M:LibTmux.Server.DetachAllClientsAsync(string?,string?,CancellationToken)` | `Task LibTmux.Server.DetachAllClientsAsync(string? keepClient = null, string? shellCommand = null, CancellationToken cancellationToken = default)` | Public | No | `UnsupportedOSPlatform("windows")` | Performs DetachAllClients. | | `M:LibTmux.Server.DetachClientAsync(string?,string?,CancellationToken)` | `Task LibTmux.Server.DetachClientAsync(string? targetClient = null, string? shellCommand = null, CancellationToken cancellationToken = default)` | Public | No | `UnsupportedOSPlatform("windows")` | Performs DetachClient. | | `M:LibTmux.Server.DisplayMessageAsync(DisplayMessageRequest,CancellationToken)` | `Task?> LibTmux.Server.DisplayMessageAsync(DisplayMessageRequest request, CancellationToken cancellationToken = default)` | Public | No | `UnsupportedOSPlatform("windows")` | Performs DisplayMessage. | -| `M:LibTmux.Server.EnterControlModeAsync(string?,System.Threading.CancellationToken)` | `Task EnterControlModeAsync(string? target = null, CancellationToken cancellationToken = default)` | Public | No | Portable | Starts a tmux control client and keeps it running. | +| `M:LibTmux.Server.EnterControlModeAsync(string?,System.Threading.CancellationToken)` | `Task EnterControlModeAsync(string? target = null, CancellationToken cancellationToken = default)` | Public | No | `UnsupportedOSPlatform("windows")` | Starts a tmux control client and keeps it running. | | `M:LibTmux.Server.ExecuteCommandAsync(IReadOnlyList,CancellationToken)` | `Task LibTmux.Server.ExecuteCommandAsync(IReadOnlyList arguments, CancellationToken cancellationToken = default)` | Public | No | `UnsupportedOSPlatform("windows")` | Executes one raw tmux command and returns both byte streams. | | `M:LibTmux.Server.FromEnvironment(IReadOnlyDictionary?)` | `static Server LibTmux.Server.FromEnvironment(IReadOnlyDictionary? environment = null)` | Public | Yes | Portable | Parses a tmux endpoint from an environment snapshot without starting a process. | | `M:LibTmux.Server.GetAttachedSessionsAsync(CancellationToken)` | `Task> LibTmux.Server.GetAttachedSessionsAsync(CancellationToken cancellationToken = default)` | Public | No | `UnsupportedOSPlatform("windows")` | Returns attached sessions or captured empty on any list-command failure. List error policy: empty-on-any-list-command-failure. | @@ -1827,6 +1914,14 @@ internal static class Program | `P:LibTmux.TmuxCommandResult.StandardOutput` | `ReadOnlyMemory LibTmux.TmuxCommandResult.StandardOutput { get; }` | Public | No | Portable | Gets StandardOutput. | | `P:LibTmux.TmuxCommandResult.StandardOutputLines` | `IReadOnlyList LibTmux.TmuxCommandResult.StandardOutputLines { get; }` | Public | No | Portable | Gets StandardOutputLines. | +### `T:LibTmux.TmuxDispatchState` + +| Member ID | Declaration | Visibility | Static | Platform | Notes | +| --- | --- | --- | --- | --- | --- | +| `F:LibTmux.TmuxDispatchState.Dispatched` | `Dispatched = 2` | Public | Implicit | Portable | tmux ran the command and answered, so any side effect has already happened. Value: `2`. | +| `F:LibTmux.TmuxDispatchState.NotDispatched` | `NotDispatched = 1` | Public | Implicit | Portable | The command never reached tmux, so a retry repeats nothing. Value: `1`. | +| `F:LibTmux.TmuxDispatchState.Unknown` | `Unknown = 0` | Public | Implicit | Portable | Whether tmux acted on the command cannot be determined; treat a retry as able to repeat it. Value: `0`. | + ### `T:LibTmux.TmuxEnvironment` | Member ID | Declaration | Visibility | Static | Platform | Notes | @@ -1846,6 +1941,14 @@ internal static class Program | `P:LibTmux.TmuxEnvironmentEntry.Name` | `string LibTmux.TmuxEnvironmentEntry.Name { get; }` | Public | No | Portable | Gets Name. | | `P:LibTmux.TmuxEnvironmentEntry.Value` | `string? LibTmux.TmuxEnvironmentEntry.Value { get; }` | Public | No | Portable | Gets Value. | +### `T:LibTmux.TmuxEventsDroppedEvent` + +| Member ID | Declaration | Visibility | Static | Platform | Notes | +| --- | --- | --- | --- | --- | --- | +| `M:LibTmux.TmuxEventsDroppedEvent.#ctor(long,long)` | `TmuxEventsDroppedEvent(long Count, long TotalDropped)` | Public | No | Portable | Creates a bounded-event-buffer loss marker. | +| `P:LibTmux.TmuxEventsDroppedEvent.Count` | `long LibTmux.TmuxEventsDroppedEvent.Count { get; }` | Public | No | Portable | Gets the events discarded since the previous loss report. | +| `P:LibTmux.TmuxEventsDroppedEvent.TotalDropped` | `long LibTmux.TmuxEventsDroppedEvent.TotalDropped { get; }` | Public | No | Portable | Gets the events discarded over this control client's lifetime. | + ### `T:LibTmux.TmuxExitEvent` | Member ID | Declaration | Visibility | Static | Platform | Notes | @@ -1985,6 +2088,7 @@ internal static class Program | Member ID | Declaration | Visibility | Static | Platform | Notes | | --- | --- | --- | --- | --- | --- | | `M:LibTmux.TmuxTransportException.#ctor(string,IReadOnlyList,Exception?)` | `TmuxTransportException(string message, IReadOnlyList arguments, Exception? innerException = null)` | Public | No | Portable | Creates TmuxTransportException. | +| `M:LibTmux.TmuxTransportException.#ctor(string,IReadOnlyList,TmuxDispatchState,Exception?)` | `TmuxTransportException(string message, IReadOnlyList arguments, TmuxDispatchState dispatch, Exception? innerException = null)` | Public | No | Portable | Creates TmuxTransportException with a known dispatch state. | | `P:LibTmux.TmuxTransportException.Arguments` | `IReadOnlyList LibTmux.TmuxTransportException.Arguments { get; }` | Public | No | Portable | Gets Arguments. | ### `T:LibTmux.TmuxVersion` diff --git a/docs/quality-bar.md b/docs/quality-bar.md index 4409d51..26fc171 100644 --- a/docs/quality-bar.md +++ b/docs/quality-bar.md @@ -1,11 +1,15 @@ -# Quality bar +# Quality bar (archived snapshot) + +> **Archived evidence — not a current release claim.** This page records one +> older tree. Its API counts, macOS results, and Linux NativeAOT observations +> must not be cited for the alpha.8 tree; use fresh CI and release evidence. A rating is worthless as an assertion, so this is the rubric and the evidence behind each score. Every row names something a reader can check, and the check is a command or a file rather than a claim. -Assessed at `acd37f8` + the documentation work that follows it, against tmux -3.2a–3.7b on net8.0 and net10.0. +This snapshot was assessed at `acd37f8` plus the documentation work that +followed it, against tmux 3.2a–3.7b on net8.0 and net10.0. Scoring: **10** means nothing known is missing. **9.5** means the gaps are named and are not defects. Below 9 means something is wrong rather than absent. @@ -100,11 +104,12 @@ published, versioned doc site. --- -## How to re-check this +## How to measure a current tree ```console $ bash eng/quality/measure.sh ``` -The numbers above come from that script, so a change that moves one moves the -evidence rather than only the prose. +The script prints current raw measures; it does not refresh this archived prose. +Current compatibility, package, API, and platform claims require their named CI +or release gates on the exact tree being assessed. diff --git a/eng/docs/render_api_reference.py b/eng/docs/render_api_reference.py index 37da7f2..342f13a 100644 --- a/eng/docs/render_api_reference.py +++ b/eng/docs/render_api_reference.py @@ -1,22 +1,24 @@ """Render the API reference from the compiler's own XML documentation. -The reference is generated from ``LibTmux.xml`` rather than from the approved -contract, because that file is what the compiler wrote down from the doc -comments on the members themselves. A member whose comment is missing is -missing here too, which is the point: the page is evidence about the comments -rather than a second place to write them. +The reference takes summaries from ``LibTmux.xml`` and visibility from the +approved contract. The compiler XML contains comments for internal helpers too; +only exact public member identifiers approved for the core package may render. """ from __future__ import annotations import argparse +from collections import Counter, defaultdict +import json import pathlib +import re import sys import typing as t from xml.etree import ElementTree CSHARP_ROOT = pathlib.Path(__file__).parents[2] OUTPUT_PATH = CSHARP_ROOT / "docs" / "api" / "README.md" +PUBLIC_API_PATH = CSHARP_ROOT / "docs" / "public-api.json" KIND_TITLES = { "T": "Types", "M": "Methods", @@ -24,11 +26,151 @@ "F": "Fields", "E": "Events", } +MemberShape = tuple[str, str, str, int, int] def documentation_paths() -> list[pathlib.Path]: - """Return every built XML documentation file.""" - return sorted((CSHARP_ROOT / "src").glob("*/bin/*/net*/LibTmux*.xml")) + """Return built core XML documentation, preferring the CI configuration.""" + paths = (CSHARP_ROOT / "src" / "LibTmux" / "bin").glob( + "*/net*/LibTmux.xml" + ) + return sorted( + paths, + key=lambda path: ( + path.parent.parent.name != "Release", + path.parent.name != "net10.0", + str(path), + ), + ) + + +def public_type_names(path: pathlib.Path = PUBLIC_API_PATH) -> frozenset[str]: + """Return the contract names of public types in the core assembly.""" + contract = json.loads(path.read_text(encoding="utf-8")) + return frozenset( + entry["id"][2:] + for entry in contract["types"] + if entry["package"] == "LibTmux" and "public" in entry["modifiers"] + ) + + +def contract_surface( + path: pathlib.Path = PUBLIC_API_PATH, +) -> tuple[frozenset[str], Counter[MemberShape]]: + """Return approved public types and member shapes for the core assembly.""" + contract = json.loads(path.read_text(encoding="utf-8")) + public_types = { + entry["id"] + for entry in contract["types"] + if entry["package"] == "LibTmux" and "public" in entry["modifiers"] + } + public_members = [ + entry + for entry in contract["members"] + if entry.get("package") == "LibTmux" + and entry.get("visibility") in {"public", "explicit"} + and entry.get("declaringType") in public_types + and entry.get("kind") != "type" + ] + shapes: Counter[MemberShape] = Counter( + ( + entry["id"][0], + entry["declaringType"][2:], + entry["name"], + len(entry.get("genericParameters", [])), + len(entry.get("parameters", [])), + ) + for entry in public_members + ) + return frozenset(public_types), shapes + + +def public_member_ids( + documentation_path: pathlib.Path, + contract_path: pathlib.Path = PUBLIC_API_PATH, +) -> frozenset[str]: + """Map approved public source IDs to exact compiler XML member IDs.""" + public_types, approved_shapes = contract_surface(contract_path) + type_names = frozenset(identifier[2:] for identifier in public_types) + candidates: dict[MemberShape, list[str]] = defaultdict(list) + selected = set(public_types) + root = ElementTree.parse(documentation_path).getroot() + for member in root.findall("./members/member"): + name = member.get("name") + if name is None: + continue + shape = xml_member_shape(name, type_names) + if shape is not None and shape in approved_shapes: + candidates[shape].append(name) + + for shape, identifiers in candidates.items(): + if len(identifiers) > approved_shapes[shape]: + joined = ", ".join(sorted(identifiers)) + raise ValueError( + "XML documentation has more members than the approved public shape " + f"{shape}: {joined}" + ) + selected.update(identifiers) + return frozenset(selected) + + +def xml_member_shape( + identifier: str, + public_types: frozenset[str], +) -> MemberShape | None: + """Return the contract-comparable shape of one compiler XML identifier.""" + if len(identifier) < 3 or identifier[1] != ":" or identifier[0] == "T": + return None + + body = identifier[2:] + declaring = next( + ( + type_name + for type_name in sorted(public_types, key=len, reverse=True) + if body.startswith(f"{type_name}.") + ), + None, + ) + if declaring is None: + return None + + member = body[len(declaring) + 1 :] + head, separator, parameters = member.partition("(") + arity_match = re.search(r"``(?P[1-9][0-9]*)$", head) + generic_arity = int(arity_match.group("arity")) if arity_match else 0 + if arity_match: + head = head[: arity_match.start()] + parameter_count = count_xml_parameters(parameters) if separator else 0 + return ( + identifier[0], + declaring, + head.replace("#", "."), + generic_arity, + parameter_count, + ) + + +def count_xml_parameters(parameters: str) -> int: + """Count top-level parameters in the tail of a compiler XML identifier.""" + closing = parameters.rfind(")") + if closing < 0: + raise ValueError("Malformed XML documentation member identifier.") + body = parameters[:closing] + if not body: + return 0 + + depth = 0 + count = 1 + for character in body: + if character in "{[": + depth += 1 + elif character in "}]": + depth -= 1 + elif character == "," and depth == 0: + count += 1 + if depth != 0: + raise ValueError("Malformed XML documentation parameter list.") + return count def flatten(node: ElementTree.Element | None) -> str: @@ -39,13 +181,16 @@ def flatten(node: ElementTree.Element | None) -> str: return " ".join("".join(node.itertext()).split()) -def read_members(path: pathlib.Path) -> dict[str, str]: +def read_members( + path: pathlib.Path, + approved_members: frozenset[str], +) -> dict[str, str]: """Return each documented member identifier and its summary.""" root = ElementTree.parse(path).getroot() members: dict[str, str] = {} for member in root.findall("./members/member"): name = member.get("name") - if name is None or ".Internal." in name: + if name is None or name not in approved_members: continue summary = flatten(member.find("summary")) @@ -64,8 +209,8 @@ def render(members: dict[str, str]) -> str: lines = [ "# API reference", "", - "Generated from the XML documentation the compiler emits, so every entry", - "here is the doc comment on the member itself. Regenerate with", + "Generated from compiler XML summaries and gated by the approved public", + "contract, so documented internal helpers never render. Regenerate with", "`uv run python eng/docs/render_api_reference.py`.", "", "See [choosing a mode](../modes/matrix.md) for how the three execution", @@ -79,11 +224,22 @@ def render(members: dict[str, str]) -> str: lines.extend(["", f"## {title}", "", "| Member | Summary |", "|---|---|"]) for name, summary in entries: escaped = summary.replace("|", "\\|") - lines.append(f"| `{name[2:]}` | {escaped} |") + lines.append(f"| {code_span(name[2:])} | {escaped} |") return "\n".join(lines) + "\n" +def code_span(value: str) -> str: + """Render metadata names containing generic-arity backticks as valid Markdown.""" + longest_run = 0 + current_run = 0 + for character in value: + current_run = current_run + 1 if character == "`" else 0 + longest_run = max(longest_run, current_run) + delimiter = "`" * (longest_run + 1) + return f"{delimiter}{value}{delimiter}" + + def main(arguments: t.Sequence[str] | None = None) -> int: """Write the reference, or check the written one is current.""" parser = argparse.ArgumentParser(description=__doc__) @@ -95,7 +251,7 @@ def main(arguments: t.Sequence[str] | None = None) -> int: print("no built XML documentation found; build first", file=sys.stderr) return 1 - rendered = render(read_members(paths[0])) + rendered = render(read_members(paths[0], public_member_ids(paths[0]))) if parsed.check: current = ( OUTPUT_PATH.read_text(encoding="utf-8") if OUTPUT_PATH.exists() else "" diff --git a/eng/docs/sync_snippets.py b/eng/docs/sync_snippets.py index e300b6e..2b8580c 100644 --- a/eng/docs/sync_snippets.py +++ b/eng/docs/sync_snippets.py @@ -1,8 +1,9 @@ """Materialize example regions into the documents that publish them. -Every published block is a ``#region`` inside a method that runs against live -tmux in CI. This copies the region in; ``--check`` fails on drift instead of -writing, which is what CI runs. +Every published block is a ``#region`` inside a compiled example method. The +ordinary tmux suite runs its examples live in CI; platform previews can require +their documented manual harness. This copies the region in; ``--check`` fails +on drift instead of writing, which is what CI runs. The copy is materialized rather than transcluded because these are package READMEs, and nuget.org renders the markdown it is given without resolving @@ -35,10 +36,12 @@ "src/LibTmux.Query.Json/README.md", "src/LibTmux.Workspace/README.md", "src/LibTmux.Mcp/README.md", + "docs/mcp/README.md", "docs/modes/one-shot.md", "docs/modes/control-mode.md", "docs/modes/chaining.md", "docs/modes/matrix.md", + "docs/psmux.md", ) REGION = re.compile( diff --git a/eng/docs/tests/test_render_api_reference.py b/eng/docs/tests/test_render_api_reference.py new file mode 100644 index 0000000..d66de86 --- /dev/null +++ b/eng/docs/tests/test_render_api_reference.py @@ -0,0 +1,273 @@ +"""Prove the API reference contains only the approved public surface.""" + +from __future__ import annotations + +import pathlib +import runpy +import typing as t + +import pytest + + +def load_renderer() -> dict[str, t.Any]: + """Load the renderer as an import-free test namespace.""" + return runpy.run_path( + str(pathlib.Path(__file__).parents[1] / "render_api_reference.py") + ) + + +def test_member_reader_keeps_public_facade_and_drops_generated_types( + tmp_path: pathlib.Path, +) -> None: + """Compiler-generated documentation must not become package API docs.""" + documentation = tmp_path / "LibTmux.xml" + documentation.write_text( + """ + + + + Configures the preview. + + + Gets the data directory. + + + Must not expose an internal member of a public type. + + + Must not be published. + + + Must not be published. + + + +""", + encoding="utf-8", + ) + read_members = load_renderer()["read_members"] + + members = read_members( + documentation, + frozenset( + { + "T:LibTmux.PsmuxConnectionOptions", + "P:LibTmux.PsmuxConnectionOptions.DataDirectory", + } + ), + ) + + assert members == { + "T:LibTmux.PsmuxConnectionOptions": "Configures the preview.", + "P:LibTmux.PsmuxConnectionOptions.DataDirectory": "Gets the data directory.", + } + + +def test_contract_reader_excludes_internal_types(tmp_path: pathlib.Path) -> None: + """The review contract may describe internals without publishing them.""" + contract = tmp_path / "public-api.json" + contract.write_text( + """{ + "types": [ + { + "id": "T:LibTmux.PsmuxConnectionOptions", + "package": "LibTmux", + "modifiers": ["public", "sealed"] + }, + { + "id": "T:LibTmux.Internal.HiddenType", + "package": "LibTmux", + "modifiers": ["internal", "sealed"] + }, + { + "id": "T:LibTmux.Query.Json.QueryJson", + "package": "LibTmux.Query.Json", + "modifiers": ["public", "static"] + } + ], + "members": [ + { + "id": "P:LibTmux.PsmuxConnectionOptions.DataDirectory", + "declaringType": "T:LibTmux.PsmuxConnectionOptions", + "package": "LibTmux", + "visibility": "public" + }, + { + "id": "M:LibTmux.PsmuxConnectionOptions.ValidateInternalState", + "declaringType": "T:LibTmux.PsmuxConnectionOptions", + "package": "LibTmux", + "visibility": "internal" + }, + { + "id": "M:LibTmux.Internal.HiddenType.Escape", + "declaringType": "T:LibTmux.Internal.HiddenType", + "package": "LibTmux", + "visibility": "public" + } + ] +} +""", + encoding="utf-8", + ) + public_type_names = load_renderer()["public_type_names"] + + assert public_type_names(contract) == frozenset({"LibTmux.PsmuxConnectionOptions"}) + + + +def test_contract_visibility_excludes_internal_members_of_public_types( + tmp_path: pathlib.Path, +) -> None: + """A documented helper is not public merely because its type is public.""" + documentation = tmp_path / "LibTmux.xml" + documentation.write_text( + """ + + + + Configures the preview. + + + Gets the data directory. + + + Must not expose an internal member. + + + +""", + encoding="utf-8", + ) + contract = tmp_path / "public-api.json" + contract.write_text( + """{ + "types": [ + { + "id": "T:LibTmux.PsmuxConnectionOptions", + "package": "LibTmux", + "modifiers": ["public", "sealed"] + } + ], + "members": [ + { + "id": "P:LibTmux.PsmuxConnectionOptions.DataDirectory", + "declaringType": "T:LibTmux.PsmuxConnectionOptions", + "name": "DataDirectory", + "kind": "property", + "package": "LibTmux", + "visibility": "public" + }, + { + "id": "M:LibTmux.PsmuxConnectionOptions.ValidateInternalState()", + "declaringType": "T:LibTmux.PsmuxConnectionOptions", + "name": "ValidateInternalState", + "kind": "method", + "parameters": [], + "package": "LibTmux", + "visibility": "internal" + } + ] +} +""", + encoding="utf-8", + ) + renderer = load_renderer() + + approved = renderer["public_member_ids"](documentation, contract) + members = renderer["read_members"](documentation, approved) + + assert members == { + "T:LibTmux.PsmuxConnectionOptions": "Configures the preview.", + "P:LibTmux.PsmuxConnectionOptions.DataDirectory": "Gets the data directory.", + } + + +def test_contract_mapping_fails_closed_on_an_unapproved_overload( + tmp_path: pathlib.Path, +) -> None: + """Name-and-arity collisions must stop generation instead of leaking a helper.""" + documentation = tmp_path / "LibTmux.xml" + documentation.write_text( + """ + + + + Reads by name. + + + Internal numeric helper. + + + +""", + encoding="utf-8", + ) + contract = tmp_path / "public-api.json" + contract.write_text( + """{ + "types": [ + { + "id": "T:LibTmux.PsmuxServer", + "package": "LibTmux", + "modifiers": ["public", "sealed"] + } + ], + "members": [ + { + "id": "M:LibTmux.PsmuxServer.Read(string)", + "declaringType": "T:LibTmux.PsmuxServer", + "name": "Read", + "kind": "method", + "parameters": [{"name": "name", "type": "string"}], + "package": "LibTmux", + "visibility": "public" + } + ] +} +""", + encoding="utf-8", + ) + + with pytest.raises(ValueError, match="more members than the approved public shape"): + load_renderer()["public_member_ids"](documentation, contract) + + +def test_member_reader_uses_one_canonical_type_summary(tmp_path: pathlib.Path) -> None: + """A canonical partial-type summary is the summary readers receive.""" + documentation = tmp_path / "LibTmux.xml" + documentation.write_text( + """ + + + + Represents an immutable server handle and snapshot. + + + +""", + encoding="utf-8", + ) + read_members = load_renderer()["read_members"] + + members = read_members(documentation, frozenset({"T:LibTmux.Server"})) + + assert members == { + "T:LibTmux.Server": "Represents an immutable server handle and snapshot." + } + + +def test_renderer_preserves_generic_metadata_names_as_code() -> None: + """Generic arity markers must not terminate their Markdown code span.""" + render = load_renderer()["render"] + + rendered = render( + {"T:LibTmux.CapturedRelation`1": "Holds captured children."} + ) + + assert "| ``LibTmux.CapturedRelation`1`` | Holds captured children. |" in rendered + + rendered = render( + {"M:LibTmux.Query.QueryExtensions.Compile``1": "Compiles a query."} + ) + + assert "| ```LibTmux.Query.QueryExtensions.Compile``1``` | Compiles a query. |" in rendered diff --git a/eng/parity/inspect_packages.py b/eng/parity/inspect_packages.py index e486c82..0c81e88 100644 --- a/eng/parity/inspect_packages.py +++ b/eng/parity/inspect_packages.py @@ -41,17 +41,26 @@ class Contract: tool : bool Whether this packs as a .NET tool, which carries its binaries and their symbols under ``tools/`` rather than ``lib/``. + dependency_versions : tuple[tuple[str, str, str], ...] + Exact framework, package, and minimum versions the public dependency + contract intentionally fixes. """ dependencies: frozenset[str] tool: bool = False + dependency_versions: tuple[tuple[str, str, str], ...] = () #: Logging abstractions are interfaces with no implementation attached, so a #: caller who wants no logging still pays nothing for it. Every other entry #: names exactly what that package exists to add. CONTRACTS = { - "LibTmux": Contract(frozenset({"Microsoft.Extensions.Logging.Abstractions"})), + "LibTmux": Contract( + frozenset({"Microsoft.Extensions.Logging.Abstractions"}), + dependency_versions=( + ("net8.0", "Microsoft.Extensions.Logging.Abstractions", "8.0.0"), + ), + ), "LibTmux.Query.Json": Contract(frozenset({"LibTmux"})), "LibTmux.Workspace": Contract(frozenset({"LibTmux", "YamlDotNet"})), "LibTmux.Mcp": Contract(frozenset(), tool=True), @@ -176,6 +185,27 @@ def inspect(package: pathlib.Path) -> list[str]: for dependency in root.iter(f"{namespace}dependency") if dependency.attrib.get("id") not in contract.dependencies ) + groups = { + group.attrib.get("targetFramework"): group + for group in root.iter(f"{namespace}group") + } + for framework, dependency_id, expected in contract.dependency_versions: + group = groups.get(framework) + matching = ( + [] + if group is None + else [ + dependency + for dependency in group.findall(f"{namespace}dependency") + if dependency.attrib.get("id") == dependency_id + ] + ) + if len(matching) != 1 or matching[0].attrib.get("version") != expected: + actual = "missing" if len(matching) != 1 else matching[0].attrib.get("version") + violations.append( + f"{identifier} requires {dependency_id} {actual} for {framework}; " + f"expected {expected}" + ) # A package page that says nothing about what the package is, who wrote it, # or where it came from is one a reader has to leave to evaluate. diff --git a/eng/parity/tests/test_packages.py b/eng/parity/tests/test_packages.py index 32a0873..b9bf974 100644 --- a/eng/parity/tests/test_packages.py +++ b/eng/parity/tests/test_packages.py @@ -28,7 +28,10 @@ """ -def dependency_group(dependency: str | None) -> str: +def dependency_group( + dependency: str | None, + dependency_version: str = "8.0.0", +) -> str: """Return the dependency block a package with that dependency carries. A tool bundles what it needs under ``tools/`` and declares nothing, so the @@ -39,7 +42,7 @@ def dependency_group(dependency: str | None) -> str: return ( " \n" ' \n' - f' \n' + f' \n' " \n" " \n" ) @@ -141,6 +144,37 @@ def test_an_extra_dependency_is_reported(tmp_path: pathlib.Path) -> None: assert "LibTmux declares dependency Newtonsoft.Json" in inspect(package) +def test_the_net8_logging_floor_cannot_be_bumped_to_net10( + tmp_path: pathlib.Path, +) -> None: + """Dependency automation must not undo the net8 consumer contract.""" + package = tmp_path / "LibTmux.1.0.0.nupkg" + with zipfile.ZipFile(package, "w") as archive: + archive.writestr("icon.png", "png") + archive.writestr( + "LibTmux.nuspec", + SPECIFICATION.format( + identifier="LibTmux", + dependencies=dependency_group( + "Microsoft.Extensions.Logging.Abstractions", + "10.0.11", + ), + project_url=PROJECT_URL, + repository_url=PROJECT_URL, + commit="a" * 40, + ), + ) + for framework in ("net8.0", "net10.0"): + archive.writestr(f"lib/{framework}/LibTmux.dll", "assembly") + archive.writestr(f"lib/{framework}/LibTmux.xml", "") + (tmp_path / "LibTmux.1.0.0.snupkg").write_bytes(b"symbols") + + assert ( + "LibTmux requires Microsoft.Extensions.Logging.Abstractions 10.0.11 " + "for net8.0; expected 8.0.0" + ) in inspect(package) + + def test_missing_symbols_are_reported(tmp_path: pathlib.Path) -> None: """Stepping into the library while debugging needs the symbols.""" package = build(tmp_path, symbols=False) diff --git a/eng/parity/tests/test_production_plan.py b/eng/parity/tests/test_production_plan.py index 66c512a..225425a 100644 --- a/eng/parity/tests/test_production_plan.py +++ b/eng/parity/tests/test_production_plan.py @@ -338,6 +338,7 @@ "T:LibTmux.TmuxChaining", "T:LibTmux.TmuxCommand", "T:LibTmux.TmuxEvent", + "T:LibTmux.TmuxEventsDroppedEvent", "T:LibTmux.TmuxExitEvent", "T:LibTmux.TmuxNotificationEvent", "T:LibTmux.TmuxOutputEvent", @@ -349,6 +350,12 @@ ), 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", diff --git a/eng/parity/tests/test_public_api.py b/eng/parity/tests/test_public_api.py index c639870..af56f23 100644 --- a/eng/parity/tests/test_public_api.py +++ b/eng/parity/tests/test_public_api.py @@ -13,9 +13,10 @@ TMUX_VERSION_CONTRACT: dict[str, t.Any] = { "grammar": [ - "version = next / release / prerelease", + "version = next / release / micro / prerelease", 'next = "next-" core', 'release = core [patch] ["-openbsd"]', + 'micro = core "." uint', 'prerelease = core ("-rc" posint / "-dev" ["." uint])', 'core = uint "." uint', "patch = 1*LOWER", @@ -27,6 +28,7 @@ "majorMinor": "the two invariant-culture decimal core components", "suffixExamples": { "3.7": None, + "3.3.7": "7", "3.7b": "b", "3.0-rc3": "rc3", "3.3a-openbsd": "a-openbsd", @@ -54,7 +56,7 @@ "03.7", "3.07", "3.7B", - "3.7.1", + "3.7.01", "3.7-", "+3.7", "integer component overflow", @@ -62,9 +64,10 @@ }, "ordering": { "core": "major then minor, numerically ascending", - "sameCore": "next < dev < rcN < final < letter patch", + "sameCore": "next < dev < rcN < final < vendor final < numeric micro < letter patch", "development": "a missing dev number precedes numeric dev numbers", "releaseCandidate": "N compares numerically", + "micro": "N compares numerically", "patch": "bijective base-26 lowercase ordinal: a=1, z=26, aa=27", "vendor": ( "-openbsd immediately follows its corresponding final or patch release" @@ -73,6 +76,7 @@ "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.3 < 3.3.1 < 3.3.10 < 3.3a", "3.7b < next-3.8 < 3.8", ], "invalidOperands": ( @@ -371,16 +375,31 @@ def test_io_is_async_and_cancellation_is_last() -> None: def test_process_api_is_windows_annotated_but_portable_api_is_not() -> None: - """Mark process entry points without contaminating portable values.""" + """Keep tmux annotated while the cross-platform psmux facade stays portable.""" public_api = load_json(csharp_docs_root() / "public-api.json") members = public_api["members"] for member in members: annotations = member.get("platformAnnotations", []) if member.get("processBacked"): - assert annotations == ['UnsupportedOSPlatform("windows")'] + if member["declaringType"].startswith("T:LibTmux.Psmux"): + assert member["portable"] is True + assert not annotations + else: + assert annotations == ['UnsupportedOSPlatform("windows")'] if member.get("portable"): assert not annotations + control = next( + member + for member in members + if member["id"] + == "M:LibTmux.Server.EnterControlModeAsync(string?,System.Threading.CancellationToken)" + ) + assert control["performsIO"] is True + assert control["processBacked"] is True + assert control["portable"] is False + assert control["platformAnnotations"] == ['UnsupportedOSPlatform("windows")'] + def test_entities_are_immutable_handles_not_destructive_disposables() -> None: """Reserve asynchronous disposal for explicitly owned resources.""" diff --git a/eng/parity/tests/test_workflows.py b/eng/parity/tests/test_workflows.py index 5c5d3b8..79b02bd 100644 --- a/eng/parity/tests/test_workflows.py +++ b/eng/parity/tests/test_workflows.py @@ -26,6 +26,8 @@ def verify(root: pathlib.Path) -> list[str]: BUILD = """ +on: + workflow_call: jobs: build: steps: @@ -40,10 +42,14 @@ def verify(root: pathlib.Path) -> list[str]: - 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 + - run: uv run python eng/parity/render_public_api.py --check - run: uv run python eng/docs/sync_snippets.py --check """ MATRIX = """ +on: + workflow_call: jobs: matrix: strategy: @@ -57,13 +63,48 @@ def verify(root: pathlib.Path) -> list[str]: run: dotnet test """ +RELEASE = """ +jobs: + dotnet: + uses: ./.github/workflows/dotnet.yml + compatibility: + uses: ./.github/workflows/dotnet-tmux.yml + psmux: + runs-on: [self-hosted, Windows, X64, psmux] + steps: + - run: | + $env:NUGET_PACKAGES = 'fresh' + dotnet restore LibTmux.slnx + - env: + ARTIFACT_URL: ${{ vars.PSMUX_ARTIFACT_URL }} + SOURCE_URL: ${{ vars.PSMUX_SOURCE_PROVENANCE_URL }} + LICENSE_URL: ${{ vars.PSMUX_LICENSE_URL }} + WSL_DISTRIBUTION: ${{ vars.PSMUX_WSL_DISTRIBUTION }} + WSL_DOTNET_PATH: ${{ vars.PSMUX_WSL_DOTNET_PATH }} + run: Invoke-PsmuxSmoke.ps1 -RunWslSmoke -WslDotnetPath /dotnet -WslRepository $env:GITHUB_WORKSPACE 1abd0eaa3de1ed5491a4f744c8b3db492ae9ac94e9e9a8fea9da217c744ba94e + - run: echo 'net8.0' 'net10.0' + publish: + needs: [dotnet, compatibility, psmux] + steps: + - uses: actions/download-artifact@pinned + - env: + REF_TYPE: ${{ github.ref_type }} + run: dotnet nuget push package.nupkg +""" + -def write(root: pathlib.Path, build: str, matrix: str) -> pathlib.Path: - """Lay out a repository holding the two workflows.""" +def write( + root: pathlib.Path, + build: str, + matrix: str, + release: str = RELEASE, +) -> pathlib.Path: + """Lay out a repository holding the release's workflow set.""" workflows = root / ".github" / "workflows" workflows.mkdir(parents=True) (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") return root @@ -115,6 +156,8 @@ def test_skipped_integration_tests_are_reported(tmp_path: pathlib.Path) -> None: "dotnet pack", "LibTmux.PackageConsumer", "LibTmux.ExampleTests", + "render_api_reference.py --check", + "render_public_api.py --check", "sync_snippets.py --check", ], ) @@ -135,3 +178,86 @@ def test_a_missing_workflow_is_reported(tmp_path: pathlib.Path) -> None: (root / ".github" / "workflows" / "dotnet-tmux.yml").unlink() assert verify(root) == ["missing workflow: dotnet-tmux.yml"] + + +@pytest.mark.parametrize( + "step", + [ + "uses: ./.github/workflows/dotnet.yml", + "uses: ./.github/workflows/dotnet-tmux.yml", + "needs: [dotnet, compatibility, psmux]", + "github.ref_type", + "actions/download-artifact@", + "PSMUX_ARTIFACT_URL", + "PSMUX_SOURCE_PROVENANCE_URL", + "PSMUX_LICENSE_URL", + "PSMUX_WSL_DISTRIBUTION", + "PSMUX_WSL_DOTNET_PATH", + "runs-on: [self-hosted, Windows, X64, psmux]", + "Invoke-PsmuxSmoke.ps1", + "-RunWslSmoke", + "-WslDotnetPath", + "-WslRepository $env:GITHUB_WORKSPACE", + ], +) +def test_a_dropped_release_gate_is_reported( + tmp_path: pathlib.Path, + step: str, +) -> None: + """Publishing must consume every same-commit and psmux proof.""" + root = write( + tmp_path, + BUILD, + MATRIX.format(versions=every_version()), + RELEASE.replace(step, "echo skipped"), + ) + + assert f"release.yml omits {step}" in verify(root) + + +def test_release_cannot_hide_an_existing_version(tmp_path: pathlib.Path) -> None: + """NuGet's immutable duplicate must fail rather than look published.""" + root = write( + tmp_path, + BUILD, + MATRIX.format(versions=every_version()), + RELEASE + "\n# --skip-duplicate\n", + ) + + assert "release.yml can hide an existing immutable package version" in verify(root) + + +def test_release_seeds_its_fresh_cache_before_the_solution_restore( + tmp_path: pathlib.Path, +) -> None: + """The packed consumer needs external dependencies in its isolated cache.""" + root = write( + tmp_path, + BUILD, + MATRIX.format(versions=every_version()), + RELEASE.replace( + "$env:NUGET_PACKAGES = 'fresh'\n dotnet restore LibTmux.slnx", + "dotnet restore LibTmux.slnx\n $env:NUGET_PACKAGES = 'fresh'", + ), + ) + + assert ( + "release.yml isolates NuGet only after restoring dependencies" in verify(root) + ) + + +@pytest.mark.parametrize("name", ["dotnet.yml", "dotnet-tmux.yml"]) +def test_release_gate_workflows_must_be_callable( + tmp_path: pathlib.Path, + name: str, +) -> None: + """A same-commit release call needs a workflow_call entry point.""" + build = BUILD if name != "dotnet.yml" else BUILD.replace("workflow_call:", "manual:") + matrix = ( + MATRIX.format(versions=every_version()) + if name != "dotnet-tmux.yml" + else MATRIX.format(versions=every_version()).replace("workflow_call:", "manual:") + ) + root = write(tmp_path, build, matrix) + + assert f"{name} cannot be called by release.yml" in verify(root) diff --git a/eng/parity/verify_production_plan.py b/eng/parity/verify_production_plan.py index 0fa3524..6bcee71 100644 --- a/eng/parity/verify_production_plan.py +++ b/eng/parity/verify_production_plan.py @@ -351,6 +351,7 @@ "T:LibTmux.TmuxChaining", "T:LibTmux.TmuxCommand", "T:LibTmux.TmuxEvent", + "T:LibTmux.TmuxEventsDroppedEvent", "T:LibTmux.TmuxExitEvent", "T:LibTmux.TmuxNotificationEvent", "T:LibTmux.TmuxOutputEvent", @@ -362,6 +363,12 @@ ), 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", diff --git a/eng/parity/verify_public_api.py b/eng/parity/verify_public_api.py index 4605161..5283adc 100644 --- a/eng/parity/verify_public_api.py +++ b/eng/parity/verify_public_api.py @@ -57,9 +57,10 @@ } TMUX_VERSION_CONTRACT: dict[str, t.Any] = { "grammar": [ - "version = next / release / prerelease", + "version = next / release / micro / prerelease", 'next = "next-" core', 'release = core [patch] ["-openbsd"]', + 'micro = core "." uint', 'prerelease = core ("-rc" posint / "-dev" ["." uint])', 'core = uint "." uint', "patch = 1*LOWER", @@ -71,6 +72,7 @@ "majorMinor": "the two invariant-culture decimal core components", "suffixExamples": { "3.7": None, + "3.3.7": "7", "3.7b": "b", "3.0-rc3": "rc3", "3.3a-openbsd": "a-openbsd", @@ -98,7 +100,7 @@ "03.7", "3.07", "3.7B", - "3.7.1", + "3.7.01", "3.7-", "+3.7", "integer component overflow", @@ -106,9 +108,10 @@ }, "ordering": { "core": "major then minor, numerically ascending", - "sameCore": "next < dev < rcN < final < letter patch", + "sameCore": "next < dev < rcN < final < vendor final < numeric micro < letter patch", "development": "a missing dev number precedes numeric dev numbers", "releaseCandidate": "N compares numerically", + "micro": "N compares numerically", "patch": "bijective base-26 lowercase ordinal: a=1, z=26, aa=27", "vendor": ( "-openbsd immediately follows its corresponding final or patch release" @@ -117,6 +120,7 @@ "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.3 < 3.3.1 < 3.3.10 < 3.3a", "3.7b < next-3.8 < 3.8", ], "invalidOperands": ( @@ -924,10 +928,15 @@ def validate_async_and_platform( ): violations.append(f"invalid cancellation parameter: {member_id}") annotations = member.get("platformAnnotations", []) - if member.get("processBacked") and annotations != [ - 'UnsupportedOSPlatform("windows")' - ]: - violations.append(f"missing Windows annotation: {member_id}") + psmux_facade = str(member.get("declaringType", "")).startswith( + "T:LibTmux.Psmux" + ) + if member.get("processBacked"): + if psmux_facade: + if annotations or member.get("portable") is not True: + violations.append(f"invalid psmux platform contract: {member_id}") + elif annotations != ['UnsupportedOSPlatform("windows")']: + violations.append(f"missing Windows annotation: {member_id}") if member.get("portable") and annotations: violations.append(f"portable member has platform annotation: {member_id}") diff --git a/eng/parity/verify_workflows.py b/eng/parity/verify_workflows.py index 6f29a0f..49930c8 100644 --- a/eng/parity/verify_workflows.py +++ b/eng/parity/verify_workflows.py @@ -26,10 +26,31 @@ "LibTmux.PackageConsumer", "LibTmux.Examples", "LibTmux.ExampleTests", + "render_api_reference.py --check", + "render_public_api.py --check", "sync_snippets.py --check", "fetch-depth: 0", ) +REQUIRED_RELEASE_STEPS = ( + "uses: ./.github/workflows/dotnet.yml", + "uses: ./.github/workflows/dotnet-tmux.yml", + "needs: [dotnet, compatibility, psmux]", + "github.ref_type", + "actions/download-artifact@", + "PSMUX_ARTIFACT_URL", + "PSMUX_SOURCE_PROVENANCE_URL", + "PSMUX_LICENSE_URL", + "PSMUX_WSL_DISTRIBUTION", + "PSMUX_WSL_DOTNET_PATH", + "runs-on: [self-hosted, Windows, X64, psmux]", + "Invoke-PsmuxSmoke.ps1", + "-RunWslSmoke", + "-WslDotnetPath", + "-WslRepository $env:GITHUB_WORKSPACE", + "1abd0eaa3de1ed5491a4f744c8b3db492ae9ac94e9e9a8fea9da217c744ba94e", +) + def verify(root: pathlib.Path) -> list[str]: """Return one message per way the workflows fall short.""" @@ -38,9 +59,10 @@ def verify(root: pathlib.Path) -> list[str]: build = workflows / "dotnet.yml" matrix = workflows / "dotnet-tmux.yml" + release = workflows / "release.yml" violations.extend( f"missing workflow: {path.name}" - for path in (build, matrix) + for path in (build, matrix, release) if not path.is_file() ) @@ -49,6 +71,7 @@ def verify(root: pathlib.Path) -> list[str]: build_text = build.read_text(encoding="utf-8") matrix_text = matrix.read_text(encoding="utf-8") + release_text = release.read_text(encoding="utf-8") violations.extend( f"dotnet.yml omits {step}" @@ -65,6 +88,27 @@ def verify(root: pathlib.Path) -> list[str]: for framework in TARGET_FRAMEWORKS if f"'{framework}'" not in matrix_text ) + violations.extend( + f"release.yml omits {step}" + for step in REQUIRED_RELEASE_STEPS + if step not in release_text + ) + + for name, content in (("dotnet.yml", build_text), ("dotnet-tmux.yml", matrix_text)): + if "workflow_call:" not in content: + violations.append(f"{name} cannot be called by release.yml") + + if "--skip-duplicate" in release_text: + violations.append("release.yml can hide an existing immutable package version") + + cache = release_text.find("$env:NUGET_PACKAGES") + restore = release_text.find("dotnet restore LibTmux.slnx") + if cache < 0 or restore < 0 or cache > restore: + violations.append("release.yml isolates NuGet only after restoring dependencies") + + for framework in TARGET_FRAMEWORKS: + if f"'{framework}'" not in release_text: + violations.append(f"release.yml omits psmux {framework}") # One lane failing says something about that tmux version, which is only # readable when the other lanes still run. diff --git a/eng/psmux/Invoke-PsmuxSmoke.ps1 b/eng/psmux/Invoke-PsmuxSmoke.ps1 new file mode 100644 index 0000000..8b4993b --- /dev/null +++ b/eng/psmux/Invoke-PsmuxSmoke.ps1 @@ -0,0 +1,1098 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string] $PsmuxPath, + + [Parameter(Mandatory)] + [ValidatePattern('^[0-9A-Fa-f]{64}$')] + [string] $ExpectedSha256, + + [Parameter(Mandatory)] + [string] $DataDirectory, + + [Parameter(Mandatory)] + [ValidatePattern('^(?!default$)(?!.*__)[a-z0-9_-]{16,64}$')] + [string] $NamespaceName, + + [Parameter(Mandatory)] + [string] $DotnetPath, + + [Parameter(Mandatory)] + [string] $TestAssembly, + + [Parameter(Mandatory)] + [string] $ExampleAssembly, + + [Parameter(Mandatory)] + [string] $PackageConsumerAssembly, + + [Parameter(Mandatory)] + [ValidateSet('net8.0', 'net10.0')] + [string] $TargetFramework, + + [string] $WslDistribution, + + [string] $WslRepository, + + [string] $WslDotnetPath, + + [switch] $RunWslSmoke, + + [ValidatePattern('^(?!.*__)[A-Za-z0-9_-]+$')] + [string] $SessionName = 'smoke' +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$savedOutputEncoding = [Console]::OutputEncoding +$supportedSha256 = '1abd0eaa3de1ed5491a4f744c8b3db492ae9ac94e9e9a8fea9da217c744ba94e' +$wslTimeoutScript = @' +case "$(LC_ALL=C /usr/bin/timeout --version 2>/dev/null)" in + 'timeout (GNU coreutils) '*) ;; + *) exit 125 ;; +esac +deadline=$1 +shift +exec /usr/bin/timeout --signal=KILL "$deadline" "$@" +'@ +$wslDotnetValidationScript = @' +set -eu +candidate=$1 +framework_major=$2 +case "$candidate" in + /*) ;; + *) exit 126 ;; +esac +resolved=$(/usr/bin/readlink -f -- "$candidate") || exit 126 +case "$resolved" in + /*) ;; + *) exit 126 ;; +esac +[ -f "$resolved" ] && [ -x "$resolved" ] || exit 126 +"$resolved" --info >/dev/null || exit 126 +runtimes=$("$resolved" --list-runtimes) || exit 126 +case " +$runtimes +" in + *" +Microsoft.NETCore.App $framework_major."*) ;; + *) + printf '%s\n' \ + "required Microsoft.NETCore.App $framework_major runtime is unavailable" >&2 + exit 126 + ;; +esac +printf '%s\n' "$resolved" +'@ +$wslPathResolutionScript = @' +set -eu +mode=$1 +requirement=$2 +candidate=$3 +case "$mode" in + windows) + candidate=$(/usr/bin/wslpath -u -- "$candidate") || exit 126 + ;; + linux) ;; + *) exit 126 ;; +esac +case "$candidate" in + /*) ;; + *) exit 126 ;; +esac +if [ "$requirement" = directory ]; then + resolved=$(/usr/bin/readlink -f -- "$candidate") || exit 126 + case "$resolved" in + /*) ;; + *) exit 126 ;; + esac + [ -d "$resolved" ] || exit 126 + candidate=$resolved +elif [ "$requirement" != path ]; then + exit 126 +fi +printf '%s\n' "$candidate" +'@ +if ($ExpectedSha256 -ine $supportedSha256) { + throw 'ExpectedSha256 must match the exact audited psmux client build.' +} +$ExpectedSha256 = $supportedSha256 + +function Get-IsolatedDataDirectory([string] $Path) { + if ($Path -notmatch '^[A-Za-z]:\\') { + throw 'DataDirectory must be an absolute path on a local Windows drive.' + } + + $full = [IO.Path]::GetFullPath($Path) + $root = [IO.Path]::GetPathRoot($full) + $drive = [IO.DriveInfo]::new($root) + if ($drive.DriveType -ne [IO.DriveType]::Fixed) { + throw 'DataDirectory must be on a fixed local Windows drive.' + } + if ([string]::Equals( + $full.TrimEnd([char[]] @([IO.Path]::DirectorySeparatorChar)), + $root.TrimEnd([char[]] @([IO.Path]::DirectorySeparatorChar)), + [System.StringComparison]::OrdinalIgnoreCase)) { + throw 'DataDirectory must not be a filesystem root.' + } + + $relative = $full.Substring($root.Length) + foreach ($segment in $relative.Split( + [char[]] @([IO.Path]::DirectorySeparatorChar), + [System.StringSplitOptions]::RemoveEmptyEntries)) { + if ($segment -eq '.' -or $segment -eq '..' -or + $segment.EndsWith(' ') -or $segment.EndsWith('.')) { + throw 'DataDirectory contains an ambiguous Windows path segment.' + } + if ($segment.IndexOfAny([IO.Path]::GetInvalidFileNameChars()) -ge 0 -or + $segment.ToCharArray().Where({ [char]::IsControl($_) }).Count -gt 0) { + throw 'DataDirectory contains an invalid Windows path segment.' + } + + $device = $segment.Split('.')[0] + if ($device -match '^(?i:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$') { + throw 'DataDirectory contains a reserved Windows device name.' + } + } + + return $full.TrimEnd([char[]] @([IO.Path]::DirectorySeparatorChar)) +} + +function Assert-OnePassingTest([string] $ResultPath, [string] $Leg) { + if (-not (Test-Path -LiteralPath $ResultPath -PathType Leaf)) { + throw "$Leg did not write an xUnit result file." + } + + [xml] $result = Get-Content -LiteralPath $ResultPath -Raw + $assemblies = @($result.assemblies.assembly) + if ($assemblies.Count -ne 1 -or + [int] $assemblies[0].total -ne 1 -or + [int] $assemblies[0].passed -ne 1 -or + [int] $assemblies[0].failed -ne 0 -or + [int] $assemblies[0].skipped -ne 0 -or + [int] $assemblies[0].'not-run' -ne 0 -or + [int] $assemblies[0].errors -ne 0) { + throw "$Leg did not run exactly one passing, non-skipped psmux smoke test." + } +} + +function Get-BoundedNativeErrorDetail([string] $ErrorText) { + if ([string]::IsNullOrWhiteSpace($ErrorText)) { + return + } + + $detail = [regex]::Replace( + $ErrorText.Trim(), + '[\p{Cc}\p{Cf}]+', + ' ') + $detail = [regex]::Replace($detail, '\s+', ' ').Trim() + if ($detail.Length -eq 0) { + return + } + if ($detail.Length -gt 512) { + $detail = '...' + $detail.Substring($detail.Length - 509) + } + return $detail +} + +function Get-NativeExitMessage( + [string] $Leg, + [int] $ExitCode, + [string] $ErrorText) { + $message = "$Leg exited $ExitCode." + $detail = Get-BoundedNativeErrorDetail $ErrorText + if (-not $detail) { + return $message + } + return "$message stderr: $detail" +} + +function Assert-QueryProgram( + [string[]] $Output, + [int] $ExitCode, + [string] $ErrorText, + [string] $ExpectedText, + [string] $Leg) { + if ($ExitCode -ne 0) { + throw (Get-NativeExitMessage $Leg $ExitCode $ErrorText) + } + if ($Output.Where({ $_.Contains($ExpectedText) }).Count -eq 0) { + throw "$Leg did not report the UTF-8 fixture text." + } +} + +function ConvertTo-NativeArgument([string] $Argument) { + if ($null -eq $Argument) { + throw 'Native command arguments must not be null.' + } + if ($Argument.Length -gt 0 -and $Argument -notmatch '[\s"]') { + return $Argument + } + + $quoted = [Text.StringBuilder]::new() + $null = $quoted.Append('"') + $backslashes = 0 + foreach ($character in $Argument.ToCharArray()) { + if ($character -eq [char] 0x5c) { + $backslashes++ + continue + } + if ($character -eq [char] 0x22) { + $null = $quoted.Append([char] 0x5c, (2 * $backslashes) + 1) + $null = $quoted.Append($character) + $backslashes = 0 + continue + } + if ($backslashes -gt 0) { + $null = $quoted.Append([char] 0x5c, $backslashes) + $backslashes = 0 + } + $null = $quoted.Append($character) + } + if ($backslashes -gt 0) { + $null = $quoted.Append([char] 0x5c, 2 * $backslashes) + } + $null = $quoted.Append('"') + return $quoted.ToString() +} + +function ConvertFrom-NativeOutput([string] $Text) { + if ([string]::IsNullOrEmpty($Text)) { + return + } + + $normalized = $Text.Replace("`r`n", "`n").Replace("`r", "`n") + if ($normalized.EndsWith("`n")) { + $normalized = $normalized.Substring(0, $normalized.Length - 1) + } + if ($normalized.Length -gt 0) { + $normalized.Split([char] 0x0a) + } +} + +function ConvertFrom-Utf8Bytes( + [byte[]] $Bytes, + [string] $Leg, + [string] $StreamName) { + $utf8 = [Text.UTF8Encoding]::new($false, $true) + try { + return $utf8.GetString($Bytes) + } + catch [Text.DecoderFallbackException] { + throw [IO.InvalidDataException]::new( + "$Leg emitted invalid UTF-8 on $StreamName.", + $_.Exception) + } +} + +function Invoke-CapturedNative( + [string] $FilePath, + [string[]] $ArgumentList, + [string] $CaptureDirectory, + [int] $TimeoutSeconds, + [string] $Leg, + [bool] $KillDescendantsOnTimeout = $false) { + if ($TimeoutSeconds -le 0) { + throw 'Native command timeouts must be positive.' + } + + $commandLine = [string]::Join( + ' ', + @($ArgumentList | ForEach-Object { ConvertTo-NativeArgument $_ })) + $startInfo = [Diagnostics.ProcessStartInfo]::new() + $startInfo.FileName = $FilePath + $startInfo.Arguments = $commandLine + $startInfo.WorkingDirectory = $CaptureDirectory + $startInfo.UseShellExecute = $false + $startInfo.RedirectStandardOutput = $true + $startInfo.RedirectStandardError = $true + $startInfo.CreateNoWindow = $true + $process = [Diagnostics.Process]::new() + $process.StartInfo = $startInfo + $stdoutBuffer = [IO.MemoryStream]::new() + $stderrBuffer = [IO.MemoryStream]::new() + $timedOut = $false + try { + if (-not $process.Start()) { + throw "$Leg could not start." + } + $stdoutTask = $process.StandardOutput.BaseStream.CopyToAsync($stdoutBuffer) + $stderrTask = $process.StandardError.BaseStream.CopyToAsync($stderrBuffer) + if (-not $process.WaitForExit($TimeoutSeconds * 1000)) { + $timedOut = $true + if (-not $process.HasExited) { + if ($KillDescendantsOnTimeout) { + $taskkill = [IO.Path]::Combine( + $env:SystemRoot, + 'System32', + 'taskkill.exe') + $treeKill = Invoke-CapturedNative ` + $taskkill ` + @('/PID', $process.Id.ToString(), '/T', '/F') ` + $CaptureDirectory ` + 10 ` + "$Leg process-tree termination" + if ($treeKill.ExitCode -ne 0 -and -not $process.HasExited) { + throw (Get-NativeExitMessage ` + "$Leg process-tree termination" ` + $treeKill.ExitCode ` + $treeKill.Error) + } + } + else { + $process.Kill() + } + } + if (-not $process.WaitForExit(5000)) { + throw "$Leg exceeded its timeout and its exact process survived termination." + } + } + $process.WaitForExit() + + $null = $stdoutTask.GetAwaiter().GetResult() + $null = $stderrTask.GetAwaiter().GetResult() + $exitCode = $process.ExitCode + try { + $stdout = ConvertFrom-Utf8Bytes $stdoutBuffer.ToArray() $Leg 'stdout' + $stderr = ConvertFrom-Utf8Bytes $stderrBuffer.ToArray() $Leg 'stderr' + } + catch [IO.InvalidDataException] { + if ($timedOut) { + throw [IO.InvalidDataException]::new( + "$Leg exceeded its $TimeoutSeconds-second timeout. " + + $_.Exception.Message, + $_.Exception) + } + elseif ($exitCode -ne 0) { + throw [IO.InvalidDataException]::new( + "$Leg exited $exitCode. $($_.Exception.Message)", + $_.Exception) + } + throw + } + if ($timedOut) { + $timeoutMessage = "$Leg exceeded its $TimeoutSeconds-second timeout." + $detail = Get-BoundedNativeErrorDetail $stderr + if ($detail) { + $timeoutMessage += " stderr: $detail" + } + throw $timeoutMessage + } + return [pscustomobject] @{ + Output = @(ConvertFrom-NativeOutput $stdout) + Error = $stderr + ExitCode = $exitCode + } + } + finally { + $process.Dispose() + $stdoutBuffer.Dispose() + $stderrBuffer.Dispose() + } +} + +function Test-ExactProcessAlive( + [int] $ProcessId, + [long] $StartTimeUtcTicks) { + $process = Get-Process -Id $ProcessId -ErrorAction SilentlyContinue + if (-not $process) { + return $false + } + + return $process.StartTime.ToUniversalTime().Ticks -eq $StartTimeUtcTicks +} + +function Convert-ToWslPath( + [string] $Path, + [string] $Distribution, + [string] $Kind, + [string] $CaptureDirectory) { + $pathMode = 'windows' + if ($Path.StartsWith('/')) { + $pathMode = 'linux' + } + $pathRequirement = 'path' + $operation = 'translation' + if ($Kind -ceq 'repository') { + $pathRequirement = 'directory' + $operation = 'resolution' + } + $translation = Invoke-CapturedNative ` + 'wsl.exe' ` + @( + '--distribution', $Distribution, + '--exec', '/bin/sh', '-eu', '-c', + $wslTimeoutScript, + 'libtmux-timeout', '15s', + '/bin/sh', '-eu', '-c', + $wslPathResolutionScript, + 'libtmux-path-resolution', + $pathMode, $pathRequirement, $Path) ` + $CaptureDirectory ` + 30 ` + "WSL $Kind path $operation" ` + $true + if ($translation.ExitCode -ne 0) { + throw (Get-NativeExitMessage ` + "WSL $Kind path $operation" ` + $translation.ExitCode ` + $translation.Error) + } + if ($translation.Output.Count -ne 1 -or + [string]::IsNullOrWhiteSpace($translation.Output[0])) { + throw "WSL could not translate the $Kind path." + } + + $resolvedPath = $translation.Output[0].Trim() + if (-not $resolvedPath.StartsWith('/') -or + $resolvedPath.ToCharArray().Where( + { [char]::IsControl($_) }).Count -ne 0) { + throw "WSL returned an invalid $Kind path." + } + + return $resolvedPath +} + +$expectedBanner = @( + 'tmux 3.3.7' + 'psmux 3.3.7 (aa26cd3 2026-08-17)' +) +$fixtureBytes = [byte[]] ( + 0x68, 0xc3, 0xa9, 0x6c, 0x6c, 0x6f, 0x2d, 0xe9, 0x9b, 0xaa, + 0x2d, 0xf0, 0x9f, 0x98, 0x80) +$expectedText = [Text.Encoding]::UTF8.GetString($fixtureBytes) +$fixtureByteList = [string]::Join( + ',', + @($fixtureBytes | ForEach-Object { $_.ToString() })) +$fixtureCommand = + '[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false);' + + '[Console]::WriteLine([Text.Encoding]::UTF8.GetString([byte[]](' + + $fixtureByteList + ')))' +$ownedEnvironment = [System.Collections.Generic.HashSet[string]]::new( + [System.StringComparer]::OrdinalIgnoreCase) +foreach ($name in @( + 'LIBTMUX_PSMUX_BINARY' + 'LIBTMUX_PSMUX_EXPECTED_TEXT' + 'LIBTMUX_PSMUX_NAMESPACE' + 'LIBTMUX_PSMUX_SHA256' + 'LIBTMUX_PSMUX_SMOKE' + 'PSMUX_DATA_DIR' + 'PSMUX_NO_WARM' + 'TMUX' + 'TMUX_PANE' +)) { + $null = $ownedEnvironment.Add($name) +} +foreach ($entry in [Environment]::GetEnvironmentVariables('Process').GetEnumerator()) { + $name = [string] $entry.Key + if ($name.StartsWith('PSMUX_', [System.StringComparison]::OrdinalIgnoreCase) -or + $name.StartsWith( + 'LIBTMUX_PSMUX_', + [System.StringComparison]::OrdinalIgnoreCase)) { + $null = $ownedEnvironment.Add($name) + } +} +$savedEnvironment = @{} +$savedNames = [System.Collections.Generic.List[string]]::new() + +$creationAttempted = $false +$createdIdentity = $null +$createdPid = $null +$createdProcessStartTicks = $null +$createdSessionId = $null +$dataDirectoryCreated = $false +$exitCode = 1 +$configPath = $null +$configCreated = $false +$nativeResultPath = $null +$wslResultPath = $null +$wslRepositoryPath = $null +$resolvedWslDotnetPath = $null +$primaryFailure = $null +$cleanupFailures = [System.Collections.Generic.List[System.Exception]]::new() +try { + [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false) + foreach ($name in $ownedEnvironment) { + $savedEnvironment[$name] = [Environment]::GetEnvironmentVariable($name, 'Process') + $savedNames.Add($name) + [Environment]::SetEnvironmentVariable($name, $null, 'Process') + } + + if ($RunWslSmoke -and ([string]::IsNullOrWhiteSpace($WslDistribution) -or + [string]::IsNullOrWhiteSpace($WslRepository) -or + [string]::IsNullOrWhiteSpace($WslDotnetPath))) { + throw 'RunWslSmoke requires WslDistribution, WslRepository, and WslDotnetPath.' + } + if ($RunWslSmoke -and + ($WslDotnetPath -cne $WslDotnetPath.Trim() -or + -not $WslDotnetPath.StartsWith('/') -or + $WslDotnetPath.ToCharArray().Where( + { [char]::IsControl($_) }).Count -ne 0)) { + throw 'WslDotnetPath must be a control-free absolute Linux path.' + } + if ($RunWslSmoke -and + ($WslRepository -cne $WslRepository.Trim() -or + ($WslRepository[0] -ne '/' -and + $WslRepository -notmatch '^(?:[A-Za-z]:\\|\\\\[^\\]+\\[^\\]+)') -or + $WslRepository.ToCharArray().Where( + { [char]::IsControl($_) }).Count -ne 0)) { + throw 'WslRepository must be a control-free absolute Linux or Windows path.' + } + + foreach ($pathInput in @( + $PsmuxPath, + $DotnetPath, + $TestAssembly, + $ExampleAssembly, + $PackageConsumerAssembly)) { + if ($pathInput -notmatch '^(?:[A-Za-z]:\\|\\\\[^\\]+\\[^\\]+)') { + throw 'Every executable and assembly path must be an absolute Windows path.' + } + } + if ($PsmuxPath -notmatch '^[A-Za-z]:\\') { + throw 'PsmuxPath must be local to a Windows drive.' + } + + $psmuxFile = Get-Item -LiteralPath $PsmuxPath -ErrorAction Stop + if ($psmuxFile.PSIsContainer -or $psmuxFile.Extension -ine '.exe') { + throw 'PsmuxPath must identify an existing .exe file.' + } + if (($psmuxFile.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'PsmuxPath must not be a symbolic link or reparse point.' + } + $psmuxDrive = [IO.DriveInfo]::new([IO.Path]::GetPathRoot($psmuxFile.FullName)) + if ($psmuxDrive.DriveType -ne [IO.DriveType]::Fixed) { + throw 'PsmuxPath must be on a fixed local Windows drive.' + } + + $dotnetFile = Get-Item -LiteralPath $DotnetPath -ErrorAction Stop + if ($dotnetFile.PSIsContainer -or $dotnetFile.Extension -ine '.exe') { + throw 'DotnetPath must identify an existing dotnet.exe file.' + } + + $testFile = Get-Item -LiteralPath $TestAssembly -ErrorAction Stop + if ($testFile.PSIsContainer -or $testFile.Extension -ine '.dll') { + throw 'TestAssembly must identify the built unit-test DLL.' + } + $exampleFile = Get-Item -LiteralPath $ExampleAssembly -ErrorAction Stop + if ($exampleFile.PSIsContainer -or $exampleFile.Extension -ine '.dll') { + throw 'ExampleAssembly must identify the built examples DLL.' + } + $packageFile = Get-Item -LiteralPath $PackageConsumerAssembly -ErrorAction Stop + if ($packageFile.PSIsContainer -or $packageFile.Extension -ine '.dll') { + throw 'PackageConsumerAssembly must identify the built package-consumer DLL.' + } + foreach ($assembly in @($testFile, $exampleFile, $packageFile)) { + if ($assembly.Directory.Name -cne $TargetFramework) { + throw "Every smoke assembly must come from the $TargetFramework output directory." + } + } + + $actualHash = (Get-FileHash -LiteralPath $psmuxFile.FullName -Algorithm SHA256).Hash + if ($actualHash -ine $ExpectedSha256) { + throw "psmux SHA-256 mismatch: expected $ExpectedSha256, got $actualHash." + } + + $binaryText = [Text.Encoding]::ASCII.GetString( + [IO.File]::ReadAllBytes($psmuxFile.FullName)) + if (-not $binaryText.Contains('aa26cd3') -or + -not $binaryText.Contains('2026-08-17')) { + throw 'psmux does not contain the audited build markers.' + } + + $DataDirectory = Get-IsolatedDataDirectory $DataDirectory + if (Test-Path -LiteralPath $DataDirectory) { + throw 'DataDirectory must not exist; choose a fresh high-entropy path for this run.' + } + New-Item -ItemType Directory -Path $DataDirectory | Out-Null + $dataDirectoryCreated = $true + $env:PSMUX_DATA_DIR = $DataDirectory + $env:PSMUX_NO_WARM = '1' + + if ($RunWslSmoke) { + $wslRepositoryPath = Convert-ToWslPath ` + $WslRepository $WslDistribution 'repository' $DataDirectory + $wslRuntimeMajor = '8' + if ($TargetFramework -ceq 'net10.0') { + $wslRuntimeMajor = '10' + } + $wslDotnetResult = Invoke-CapturedNative ` + 'wsl.exe' ` + @( + '--distribution', $WslDistribution, + '--cd', $wslRepositoryPath, + '--exec', '/bin/sh', '-eu', '-c', + $wslTimeoutScript, + 'libtmux-timeout', '15s', + '/bin/sh', '-eu', '-c', + $wslDotnetValidationScript, + 'libtmux-dotnet-validation', + $WslDotnetPath, $wslRuntimeMajor) ` + $DataDirectory ` + 30 ` + 'WSL .NET executable validation' ` + $true + if ($wslDotnetResult.ExitCode -ne 0) { + throw (Get-NativeExitMessage ` + 'WSL .NET executable validation' ` + $wslDotnetResult.ExitCode ` + $wslDotnetResult.Error) + } + if ($wslDotnetResult.Output.Count -ne 1 -or + [string]::IsNullOrWhiteSpace($wslDotnetResult.Output[0])) { + throw 'WSL .NET executable validation returned no canonical path.' + } + $resolvedWslDotnetPath = $wslDotnetResult.Output[0] + if ($resolvedWslDotnetPath -cne $resolvedWslDotnetPath.Trim() -or + -not $resolvedWslDotnetPath.StartsWith('/') -or + $resolvedWslDotnetPath.ToCharArray().Where( + { [char]::IsControl($_) }).Count -ne 0) { + throw 'WSL .NET executable validation returned an invalid canonical path.' + } + } + + $bannerResult = Invoke-CapturedNative ` + $psmuxFile.FullName ` + @('-V') ` + $DataDirectory ` + 30 ` + 'psmux version query' + $banner = $bannerResult.Output + if ($bannerResult.ExitCode -ne 0) { + throw (Get-NativeExitMessage ` + 'psmux version query' ` + $bannerResult.ExitCode ` + $bannerResult.Error) + } + if ([string]::Join("`n", $banner) -cne + [string]::Join("`n", $expectedBanner)) { + throw "psmux reported an unaudited banner: $([string]::Join(' | ', $banner))" + } + + $configPath = Join-Path $DataDirectory 'libtmux-smoke.conf' + [IO.File]::WriteAllText( + $configPath, + "set -g warm off`n", + [Text.UTF8Encoding]::new($false)) + $configCreated = $true + + $existingResult = Invoke-CapturedNative ` + $psmuxFile.FullName ` + @('-L', $NamespaceName, 'list-sessions', '-F', '#{session_name}') ` + $DataDirectory ` + 30 ` + 'psmux isolated namespace inspection' + $existing = $existingResult.Output + if ($existingResult.ExitCode -ne 0) { + throw (Get-NativeExitMessage ` + 'psmux isolated namespace inspection' ` + $existingResult.ExitCode ` + $existingResult.Error) + } + if ($existing.Count -ne 0) { + throw "The isolated namespace is not empty: $([string]::Join(', ', $existing))" + } + + $creationAttempted = $true + $creationResult = Invoke-CapturedNative ` + $psmuxFile.FullName ` + @( + '-f', $configPath, + '-L', $NamespaceName, + 'new-session', + '-d', + '-s', $SessionName, + '--', 'powershell.exe', '-NoLogo', '-NoProfile', '-NoExit') ` + $DataDirectory ` + 30 ` + 'psmux session creation' + $creationExitCode = $creationResult.ExitCode + + $identityFormat = "#{pid}:#{start_time}`t#{session_id}`t#{session_name}" + $escapedSessionName = [regex]::Escape($SessionName) + $identityPattern = '^[1-9][0-9]*:[1-9][0-9]*\t(\$[0-9]+)\t' + + $escapedSessionName + '$' + for ($attempt = 0; $attempt -lt 50 -and -not $createdSessionId; $attempt++) { + $identityResult = Invoke-CapturedNative ` + $psmuxFile.FullName ` + @( + '-L', $NamespaceName, + 'display-message', + '-p', + '-t', $SessionName, + $identityFormat) ` + $DataDirectory ` + 30 ` + 'psmux session identity query' + $identity = $identityResult.Output + if ($identityResult.ExitCode -eq 0 -and $identity.Count -eq 1 -and + $identity[0] -match $identityPattern) { + $candidatePid = [int] ($identity[0].Split(':')[0]) + $candidateProcess = Get-Process -Id $candidatePid -ErrorAction Stop + $createdIdentity = $identity[0] + $createdPid = $candidatePid + $createdProcessStartTicks = $candidateProcess.StartTime.ToUniversalTime().Ticks + $createdSessionId = $Matches[1] + break + } + + Start-Sleep -Milliseconds 100 + } + + if ($creationExitCode -ne 0) { + throw (Get-NativeExitMessage ` + 'psmux new-session after the creation attempt' ` + $creationExitCode ` + $creationResult.Error) + } + if (-not $createdSessionId) { + throw 'psmux created no session with an exact verifiable identity.' + } + + $sendResult = Invoke-CapturedNative ` + $psmuxFile.FullName ` + @( + '-L', $NamespaceName, + 'send-keys', + '-t', "${SessionName}:0.0", + $fixtureCommand, + 'Enter') ` + $DataDirectory ` + 30 ` + 'psmux fixture input' + if ($sendResult.ExitCode -ne 0) { + throw (Get-NativeExitMessage ` + 'psmux fixture input' ` + $sendResult.ExitCode ` + $sendResult.Error) + } + + $ready = $false + for ($attempt = 0; $attempt -lt 50 -and -not $ready; $attempt++) { + $captureResult = Invoke-CapturedNative ` + $psmuxFile.FullName ` + @( + '-L', $NamespaceName, + 'capture-pane', + '-p', + '-t', "${SessionName}:0.0") ` + $DataDirectory ` + 30 ` + 'psmux pane capture' + $capture = $captureResult.Output + if ($captureResult.ExitCode -ne 0) { + throw (Get-NativeExitMessage ` + 'psmux pane capture' ` + $captureResult.ExitCode ` + $captureResult.Error) + } + $ready = $capture.Where({ $_ -clike "*$expectedText*" }).Count -gt 0 + if (-not $ready) { + Start-Sleep -Milliseconds 100 + } + } + if (-not $ready) { + throw 'The UTF-8 fixture did not become visible in the smoke pane.' + } + + $env:LIBTMUX_PSMUX_BINARY = $psmuxFile.FullName + $env:LIBTMUX_PSMUX_EXPECTED_TEXT = $expectedText + $env:LIBTMUX_PSMUX_NAMESPACE = $NamespaceName + $env:LIBTMUX_PSMUX_SHA256 = $ExpectedSha256.ToLowerInvariant() + $env:LIBTMUX_PSMUX_SMOKE = '1' + + $nativeResultPath = Join-Path $DataDirectory 'libtmux-native-result.xml' + $nativeTestResult = Invoke-CapturedNative ` + $dotnetFile.FullName ` + @( + $testFile.FullName, + '-noColor', + '-noLogo', + '-failSkips', + '-result-xml', $nativeResultPath, + '-class', 'LibTmux.UnitTests.Connection.PsmuxProcessSmokeTests') ` + $DataDirectory ` + 180 ` + 'Native Windows .NET smoke' ` + $true + $exitCode = $nativeTestResult.ExitCode + if ($exitCode -ne 0) { + throw (Get-NativeExitMessage ` + 'Native Windows .NET smoke' ` + $exitCode ` + $nativeTestResult.Error) + } + Assert-OnePassingTest $nativeResultPath 'Native Windows .NET' + $nativeExampleResult = Invoke-CapturedNative ` + $dotnetFile.FullName ` + @($exampleFile.FullName, '--psmux') ` + $DataDirectory ` + 60 ` + 'Native Windows example' ` + $true + Assert-QueryProgram ` + $nativeExampleResult.Output ` + $nativeExampleResult.ExitCode ` + $nativeExampleResult.Error ` + $expectedText ` + 'Native Windows example' + $nativePackageResult = Invoke-CapturedNative ` + $dotnetFile.FullName ` + @($packageFile.FullName, '--psmux') ` + $DataDirectory ` + 60 ` + 'Native Windows packed consumer' ` + $true + Assert-QueryProgram ` + $nativePackageResult.Output ` + $nativePackageResult.ExitCode ` + $nativePackageResult.Error ` + $expectedText ` + 'Native Windows packed consumer' + + if ($exitCode -eq 0 -and $RunWslSmoke) { + $wslPsmuxPath = Convert-ToWslPath ` + $psmuxFile.FullName $WslDistribution 'audited psmux' $DataDirectory + $wslDataDirectory = Convert-ToWslPath ` + $DataDirectory $WslDistribution 'isolated data-directory' $DataDirectory + $wslTestAssembly = Convert-ToWslPath ` + $testFile.FullName $WslDistribution 'unit-test assembly' $DataDirectory + $wslExampleAssembly = Convert-ToWslPath ` + $exampleFile.FullName $WslDistribution 'example assembly' $DataDirectory + $wslPackageAssembly = Convert-ToWslPath ` + $packageFile.FullName $WslDistribution 'package-consumer assembly' $DataDirectory + $wslResultPath = Join-Path $DataDirectory 'libtmux-wsl-result.xml' + $wslResultArgument = "$($wslDataDirectory.TrimEnd('/'))/libtmux-wsl-result.xml" + $wslTestResult = Invoke-CapturedNative ` + 'wsl.exe' ` + @( + '--distribution', $WslDistribution, + '--cd', $wslRepositoryPath, + '--exec', '/bin/sh', '-eu', '-c', + $wslTimeoutScript, + 'libtmux-timeout', '240s', + '/usr/bin/env', + "LIBTMUX_PSMUX_BINARY=$wslPsmuxPath", + "LIBTMUX_PSMUX_EXPECTED_TEXT=$expectedText", + "LIBTMUX_PSMUX_NAMESPACE=$NamespaceName", + "LIBTMUX_PSMUX_SHA256=$($ExpectedSha256.ToLowerInvariant())", + 'LIBTMUX_PSMUX_SMOKE=1', + "PSMUX_DATA_DIR=$DataDirectory", + 'WSLENV=PSMUX_DATA_DIR/w', + $resolvedWslDotnetPath, + $wslTestAssembly, + '-noColor', + '-noLogo', + '-failSkips', + '-result-xml', $wslResultArgument, + '-class', 'LibTmux.UnitTests.Connection.PsmuxProcessSmokeTests') ` + $DataDirectory ` + 300 ` + 'WSL .NET smoke' ` + $true + $exitCode = $wslTestResult.ExitCode + if ($exitCode -ne 0) { + throw (Get-NativeExitMessage ` + 'WSL .NET smoke' ` + $exitCode ` + $wslTestResult.Error) + } + Assert-OnePassingTest $wslResultPath 'WSL .NET' + $wslExampleResult = Invoke-CapturedNative ` + 'wsl.exe' ` + @( + '--distribution', $WslDistribution, + '--cd', $wslRepositoryPath, + '--exec', '/bin/sh', '-eu', '-c', + $wslTimeoutScript, + 'libtmux-timeout', '120s', + '/usr/bin/env', + "LIBTMUX_PSMUX_BINARY=$wslPsmuxPath", + "LIBTMUX_PSMUX_EXPECTED_TEXT=$expectedText", + "LIBTMUX_PSMUX_NAMESPACE=$NamespaceName", + "PSMUX_DATA_DIR=$DataDirectory", + 'WSLENV=PSMUX_DATA_DIR/w', + $resolvedWslDotnetPath, + $wslExampleAssembly, + '--psmux') ` + $DataDirectory ` + 180 ` + 'WSL example' ` + $true + Assert-QueryProgram ` + $wslExampleResult.Output ` + $wslExampleResult.ExitCode ` + $wslExampleResult.Error ` + $expectedText ` + 'WSL example' + + $wslPackageResult = Invoke-CapturedNative ` + 'wsl.exe' ` + @( + '--distribution', $WslDistribution, + '--cd', $wslRepositoryPath, + '--exec', '/bin/sh', '-eu', '-c', + $wslTimeoutScript, + 'libtmux-timeout', '120s', + '/usr/bin/env', + "LIBTMUX_PSMUX_BINARY=$wslPsmuxPath", + "LIBTMUX_PSMUX_EXPECTED_TEXT=$expectedText", + "LIBTMUX_PSMUX_NAMESPACE=$NamespaceName", + "PSMUX_DATA_DIR=$DataDirectory", + 'WSLENV=PSMUX_DATA_DIR/w', + $resolvedWslDotnetPath, + $wslPackageAssembly, + '--psmux') ` + $DataDirectory ` + 180 ` + 'WSL packed consumer' ` + $true + Assert-QueryProgram ` + $wslPackageResult.Output ` + $wslPackageResult.ExitCode ` + $wslPackageResult.Error ` + $expectedText ` + 'WSL packed consumer' + } +} +catch { + $primaryFailure = $_ +} +finally { + try { + if ($creationAttempted) { + $ownedSidPath = Join-Path $DataDirectory "$NamespaceName`__$SessionName.sid" + if (-not $createdSessionId) { + throw 'A creation attempt has no exact identity; retaining its data directory.' + } + else { + # aa26 resolves $N to its complete namespaced registry base; + # adding -L here would prefix the namespace a second time. + $currentIdentityResult = Invoke-CapturedNative ` + $psmuxFile.FullName ` + @( + 'display-message', + '-p', + '-t', $createdSessionId, + $identityFormat) ` + $DataDirectory ` + 30 ` + 'psmux cleanup identity query' + $currentIdentity = $currentIdentityResult.Output + if ($currentIdentityResult.ExitCode -ne 0) { + if (Test-Path -LiteralPath $ownedSidPath) { + throw (Get-NativeExitMessage ` + 'psmux cleanup identity query' ` + $currentIdentityResult.ExitCode ` + $currentIdentityResult.Error) + } + } + elseif ($currentIdentity.Count -ne 1) { + if (Test-Path -LiteralPath $ownedSidPath) { + throw 'The created session still exists but its identity cannot be verified.' + } + } + elseif ($currentIdentity[0] -cne $createdIdentity) { + throw 'The created session identity changed; refusing to kill its replacement.' + } + else { + $killResult = Invoke-CapturedNative ` + $psmuxFile.FullName ` + @('kill-session', '-t', $createdSessionId) ` + $DataDirectory ` + 30 ` + 'psmux exact session cleanup' + if ($killResult.ExitCode -ne 0) { + throw (Get-NativeExitMessage ` + 'psmux exact session cleanup' ` + $killResult.ExitCode ` + $killResult.Error) + } + + for ($attempt = 0; $attempt -lt 50 -and + (Test-Path -LiteralPath $ownedSidPath); $attempt++) { + Start-Sleep -Milliseconds 100 + } + if (Test-Path -LiteralPath $ownedSidPath) { + throw 'The exact created session registry survived cleanup.' + } + } + + for ($attempt = 0; $attempt -lt 50 -and + (Test-ExactProcessAlive ` + $createdPid $createdProcessStartTicks); $attempt++) { + Start-Sleep -Milliseconds 100 + } + if (Test-ExactProcessAlive $createdPid $createdProcessStartTicks) { + throw 'The exact created session process survived cleanup.' + } + } + } + } + catch { + $cleanupFailures.Add($_.Exception) + } + + foreach ($ownedPath in @($nativeResultPath, $wslResultPath, $configPath)) { + try { + if ($ownedPath -and (Test-Path -LiteralPath $ownedPath -PathType Leaf)) { + if ($ownedPath -eq $configPath -and -not $configCreated) { + throw 'Refusing to remove a configuration file this run did not create.' + } + Remove-Item -LiteralPath $ownedPath -Force + } + } + catch { + $cleanupFailures.Add($_.Exception) + } + } + + try { + if ($dataDirectoryCreated -and $cleanupFailures.Count -eq 0 -and + (Test-Path -LiteralPath $DataDirectory -PathType Container)) { + $liveRegistry = @(Get-ChildItem ` + -LiteralPath $DataDirectory ` + -Filter '*.port' ` + -Recurse ` + -ErrorAction Stop) + if ($liveRegistry.Count -ne 0) { + throw 'The owned data directory still contains a live psmux registry.' + } + Remove-Item -LiteralPath $DataDirectory -Recurse -Force + } + } + catch { + $cleanupFailures.Add($_.Exception) + } + + try { + foreach ($name in $savedNames) { + [Environment]::SetEnvironmentVariable($name, $savedEnvironment[$name], 'Process') + } + } + catch { + $cleanupFailures.Add($_.Exception) + } + + try { + [Console]::OutputEncoding = $savedOutputEncoding + } + catch { + $cleanupFailures.Add($_.Exception) + } +} + +if ($primaryFailure) { + if ($cleanupFailures.Count -gt 0) { + $primaryFailure.Exception.Data['LibTmux.PsmuxSmokeCleanupFailure'] = + [System.AggregateException]::new($cleanupFailures) + } + throw $primaryFailure +} +if ($cleanupFailures.Count -gt 0) { + throw [System.AggregateException]::new( + 'The psmux smoke cleanup failed.', + $cleanupFailures) +} + +exit $exitCode diff --git a/eng/psmux/tests/test_smoke_harness.py b/eng/psmux/tests/test_smoke_harness.py new file mode 100644 index 0000000..b1f10e8 --- /dev/null +++ b/eng/psmux/tests/test_smoke_harness.py @@ -0,0 +1,250 @@ +"""Keep the native psmux harness fail-closed before its first process launch.""" + +from __future__ import annotations + +import pathlib +import re + + +SCRIPT = pathlib.Path(__file__).parents[1] / "Invoke-PsmuxSmoke.ps1" +REPOSITORY = pathlib.Path(__file__).parents[3] +EXAMPLE_PROGRAM = REPOSITORY / "examples" / "LibTmux.Examples" / "Program.cs" +PACKAGE_PROGRAM = REPOSITORY / "tests" / "LibTmux.PackageConsumer" / "Program.cs" + + +def source() -> str: + """Return the checked-in PowerShell harness.""" + return SCRIPT.read_text(encoding="utf-8") + + +def test_first_psmux_launch_uses_verified_binary_and_isolated_data() -> None: + """Unsafe release binaries must be rejected without ever executing them.""" + script = source() + first_launch = script.index("$bannerResult = Invoke-CapturedNative") + + assert script.index("Get-FileHash", 0, first_launch) >= 0 + assert script.index("$ExpectedSha256 -ine $supportedSha256", 0, first_launch) >= 0 + assert "1abd0eaa3de1ed5491a4f744c8b3db492ae9ac94e9e9a8fea9da217c744ba94e" in script + assert script.index("$binaryText.Contains('aa26cd3')", 0, first_launch) >= 0 + assert script.index("$binaryText.Contains('2026-08-17')", 0, first_launch) >= 0 + assert script.index("$env:PSMUX_DATA_DIR = $DataDirectory", 0, first_launch) >= 0 + assert script.index("$env:PSMUX_NO_WARM = '1'", 0, first_launch) >= 0 + assert '"set -g warm off`n"' in script + assert "PsmuxPath must be local to a Windows drive" in script + assert script.index("WslDotnetPath must be", 0, first_launch) >= 0 + assert script.index("WslRepository must be", 0, first_launch) >= 0 + assert script.index("$wslDotnetResult = Invoke-CapturedNative", 0, first_launch) >= 0 + + +def test_native_processes_are_bounded_and_return_explicit_status() -> None: + """PowerShell must not infer success from a missing or stale exit code.""" + script = source() + + assert "$LASTEXITCODE" not in script + assert "@(&" not in script + assert script.count("Invoke-CapturedNative `") == 17 + assert "Start-Process" not in script + assert "$startInfo = [Diagnostics.ProcessStartInfo]::new()" in script + assert "$startInfo.WorkingDirectory = $CaptureDirectory" in script + assert "$startInfo.UseShellExecute = $false" in script + assert "$startInfo.RedirectStandardOutput = $true" in script + assert "$startInfo.RedirectStandardError = $true" in script + assert "$process.StandardOutput.BaseStream.CopyToAsync($stdoutBuffer)" in script + assert "$process.StandardError.BaseStream.CopyToAsync($stderrBuffer)" in script + assert "$null = $stdoutTask.GetAwaiter().GetResult()" in script + assert "$null = $stderrTask.GetAwaiter().GetResult()" in script + assert "$startInfo.StandardOutputEncoding" not in script + assert "$startInfo.StandardErrorEncoding" not in script + assert "ConvertFrom-Utf8Bytes $stdoutBuffer.ToArray() $Leg 'stdout'" in script + assert "ConvertFrom-Utf8Bytes $stderrBuffer.ToArray() $Leg 'stderr'" in script + assert '"$Leg emitted invalid UTF-8 on $StreamName."' in script + assert "$process.WaitForExit($TimeoutSeconds * 1000)" in script + assert "$process.Kill()" in script + assert "$process.WaitForExit(5000)" in script + assert "$exitCode = $process.ExitCode" in script + assert "ExitCode = $exitCode" in script + assert "ConvertTo-NativeArgument" in script + assert "$KillDescendantsOnTimeout = $false" in script + assert "@('/PID', $process.Id.ToString(), '/T', '/F')" in script + assert '"$Leg process-tree termination"' in script + + +def test_wsl_workloads_have_inner_process_group_deadlines() -> None: + """Killing wsl.exe alone must not leave its adopted Linux workload alive.""" + script = source() + + assert "'timeout (GNU coreutils) '*" in script + assert "LC_ALL=C /usr/bin/timeout --version" in script + assert 'exec /usr/bin/timeout --signal=KILL "$deadline" "$@"' in script + assert "--kill-after" not in script + assert "--foreground" not in script + assert script.count("$wslTimeoutScript,") == 5 + assert script.count("'libtmux-timeout'") == 5 + assert script.count("'15s'") == 2 + assert script.count("'240s'") == 1 + assert script.count("'120s'") == 2 + assert '"WSL $Kind path $operation" `\n $true' in script + assert "'WSL .NET smoke' `\n $true" in script + assert "'WSL example' `\n $true" in script + assert "'WSL packed consumer' `\n $true" in script + + +def test_wsl_dotnet_is_validated_once_and_invoked_by_absolute_path() -> None: + """Non-login WSL calls must not search an incomplete inherited PATH.""" + script = source() + + assert "[string] $WslDotnetPath" in script + assert "candidate=$1" in script + assert 'resolved=$(/usr/bin/readlink -f -- "$candidate") || exit 126' in script + assert '[ -f "$resolved" ] && [ -x "$resolved" ]' in script + assert '"$resolved" --info >/dev/null || exit 126' in script + assert 'runtimes=$("$resolved" --list-runtimes) || exit 126' in script + assert 'Microsoft.NETCore.App $framework_major.' in script + assert '"required Microsoft.NETCore.App $framework_major runtime is unavailable"' in script + assert "$wslRuntimeMajor = '8'" in script + assert "$wslRuntimeMajor = '10'" in script + assert "$WslDotnetPath, $wslRuntimeMajor" in script + assert "'WSL .NET executable validation' `\n $true" in script + assert "$wslRepositoryPath = Convert-ToWslPath" in script + assert script.count("'--cd', $wslRepositoryPath") == 4 + assert "$resolvedWslDotnetPath = $wslDotnetResult.Output[0]" in script + assert "$resolvedWslDotnetPath.StartsWith('/')" in script + assert script.count("$resolvedWslDotnetPath,") == 3 + assert re.search(r"^\s*\$WslDotnetPath\s*=", script, re.IGNORECASE | re.MULTILINE) is None + assert script.count("'/bin/sh'") == 7 + assert script.count("'/usr/bin/env'") == 3 + assert script.count("/usr/bin/wslpath") == 1 + assert "command -v mise" not in script + assert "'-lc'" not in script + + +def test_wsl_repository_is_canonicalized_for_both_input_forms() -> None: + """Linux paths must bypass wslpath while every repository becomes a real directory.""" + script = source() + + assert "$pathMode = 'windows'" in script + assert "if ($Path.StartsWith('/'))" in script + assert "$pathMode = 'linux'" in script + assert 'candidate=$(/usr/bin/wslpath -u -- "$candidate") || exit 126' in script + assert 'resolved=$(/usr/bin/readlink -f -- "$candidate") || exit 126' in script + assert '[ -d "$resolved" ] || exit 126' in script + assert "$pathRequirement = 'directory'" in script + assert "$wslPathResolutionScript," in script + assert "$pathMode, $pathRequirement, $Path" in script + assert 'return $resolvedPath' in script + + +def test_harness_owns_every_file_and_exact_cleanup_target() -> None: + """The harness must not overwrite or broadly clean caller data.""" + script = source() + + assert "DataDirectory must not exist" in script + assert "absolute path on a local Windows drive" in script + assert script.count("[IO.DriveType]::Fixed") == 2 + assert r"if ($Path -notmatch '^[A-Za-z]:\\')" in script + assert "GetEnvironmentVariables('Process').GetEnumerator()" in script + assert "StartsWith('PSMUX_', [System.StringComparison]::OrdinalIgnoreCase)" in script + assert "'PSMUX_NO_WARM'" in script + assert "'LIBTMUX_PSMUX_'" in script + assert "New-Item -ItemType Directory -Path $DataDirectory | Out-Null" in script + assert "New-Item -ItemType Directory -Path $DataDirectory -Force" not in script + assert "kill-server" not in script + creation = script.index("new-session") + identity = script.index("$createdSessionId = $Matches[1]", creation) + creation_error = script.index("if ($creationExitCode -ne 0)", identity) + cleanup = script.index("if ($creationAttempted)", creation_error) + + assert script.index("$creationAttempted = $true", 0, creation) >= 0 + assert identity < creation_error + assert "@('kill-session', '-t', $createdSessionId)" in script[cleanup:] + assert "-L $NamespaceName" not in script[cleanup:] + assert "kill-session -t $SessionName" not in script + assert "$currentIdentity[0] -cne $createdIdentity" in script[cleanup:] + assert "refusing to kill its replacement" in script[cleanup:] + assert "no exact identity; retaining its data directory" in script[cleanup:] + assert "Test-ExactProcessAlive $createdPid $createdProcessStartTicks" in script[cleanup:] + assert "[long] $StartTimeUtcTicks" in script + assert "-Recurse" in script[cleanup:] + assert "The owned data directory still contains a live psmux registry" in script + assert "Remove-Item -LiteralPath $DataDirectory -Recurse -Force" in script + assert "Refusing to remove a configuration file this run did not create" in script + + +def test_each_runtime_leg_requires_one_non_skipped_test() -> None: + """A stale class name or a skipped smoke must not produce a green harness.""" + script = source() + + assert script.count("-failSkips") == 2 + assert script.count("-result-xml") == 2 + assert "Assert-OnePassingTest $nativeResultPath 'Native Windows .NET'" in script + assert "Assert-OnePassingTest $wslResultPath 'WSL .NET'" in script + assert "'Native Windows .NET smoke' `\n $exitCode `\n $nativeTestResult.Error" in script + assert "'WSL .NET smoke' `\n $exitCode `\n $wslTestResult.Error" in script + assert "[int] $assemblies[0].total -ne 1" in script + assert "[int] $assemblies[0].passed -ne 1" in script + assert "[string] $ExampleAssembly" in script + assert "[string] $PackageConsumerAssembly" in script + assert "[ValidateSet('net8.0', 'net10.0')]" in script + assert "$assembly.Directory.Name -cne $TargetFramework" in script + assert "'Native Windows example'" in script + assert "'Native Windows packed consumer'" in script + assert "'WSL example'" in script + assert "'WSL packed consumer'" in script + assert script.count("Assert-QueryProgram") == 5 + + +def test_nonzero_process_details_are_bounded_and_preserve_exit_status() -> None: + """A useful stderr excerpt must not displace the primary native failure.""" + script = source() + + assert "'[\\p{Cc}\\p{Cf}]+'" in script + assert "[regex]::Replace($detail, '\\s+', ' ').Trim()" in script + assert "$detail.Length -gt 512" in script + assert "'...' + $detail.Substring($detail.Length - 509)" in script + assert 'return "$message stderr: $detail"' in script + assert '"$Leg exited $exitCode. $($_.Exception.Message)"' in script + timeout = script.index("$timedOut = $true") + termination = script.index("$process.WaitForExit(5000)", timeout) + drain = script.index("$null = $stdoutTask.GetAwaiter().GetResult()", termination) + report = script.index("$timeoutMessage =", drain) + assert timeout < termination < drain < report + for result in ( + "$nativeExampleResult.Error", + "$nativePackageResult.Error", + "$wslTestResult.Error", + "$wslExampleResult.Error", + "$wslPackageResult.Error", + ): + assert result in script + + +def test_windows_powershell_source_is_ascii_and_restores_process_state() -> None: + """Windows PowerShell 5.1 must parse the same UTF-8 fixture without a BOM.""" + script = source() + + script.encode("ascii") + assert "[Text.Encoding]::UTF8.GetString($fixtureBytes)" in script + assert "[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false)" in script + assert "$fixtureByteList" in script + assert '"Write-Output \'$expectedText\'"' not in script + assert "$fixtureCommand," in script + assert "foreach ($name in $savedNames)" in script + assert "[Console]::OutputEncoding = $savedOutputEncoding" in script + assert "LibTmux.PsmuxSmokeCleanupFailure" in script + + +def test_native_managed_psmux_producers_force_strict_utf8() -> None: + """Redirected Windows apps must not inherit the unrepresentable OEM code page.""" + for path, invocation in ( + (EXAMPLE_PROGRAM, "await Snippets.Psmux.QueryPsmux();"), + (PACKAGE_PROGRAM, "return await RunPsmuxAsync();"), + ): + program = path.read_text(encoding="utf-8") + branch = program.index('if (args is ["--psmux"])') + encoding = program.index( + "Console.OutputEncoding = new UTF8Encoding(false, true);", + branch, + ) + output = program.index(invocation, encoding) + + assert branch < encoding < output diff --git a/examples/LibTmux.Examples/ExampleAttribute.cs b/examples/LibTmux.Examples/ExampleAttribute.cs index 4a133cc..a9edb7a 100644 --- a/examples/LibTmux.Examples/ExampleAttribute.cs +++ b/examples/LibTmux.Examples/ExampleAttribute.cs @@ -1,6 +1,6 @@ namespace LibTmux.Examples; -/// Marks a method as an example that runs against a live tmux server. +/// Marks a method as a published example. /// /// The method name identifies the example, and names the #region a /// document publishes from it. @@ -14,4 +14,7 @@ public sealed class ExampleAttribute : Attribute /// Gets the line saying what the example shows. public string Title { get; } + + /// Gets or sets whether the ordinary tmux example suite runs this example. + public bool RunsInDefaultSuite { get; set; } = true; } diff --git a/examples/LibTmux.Examples/ExampleCase.cs b/examples/LibTmux.Examples/ExampleCase.cs index 7ac8241..3b6ad44 100644 --- a/examples/LibTmux.Examples/ExampleCase.cs +++ b/examples/LibTmux.Examples/ExampleCase.cs @@ -30,15 +30,15 @@ private ExampleCase(MethodInfo method, string title) private MethodInfo Method { get; } - /// Finds every example in this assembly, in a stable order. - /// The examples, ordered by topic and then by name. + /// Finds the ordinary tmux examples, in a stable order. + /// The default-suite examples, ordered by topic and then by name. public static IReadOnlyList Discover() => [ .. typeof(ExampleCase).Assembly .GetTypes() .SelectMany(type => type.GetMethods(BindingFlags.Public | BindingFlags.Static)) .Select(method => (Method: method, Example: method.GetCustomAttribute())) - .Where(found => found.Example is not null) + .Where(found => found.Example?.RunsInDefaultSuite is true) .Select(found => Create(found.Method, found.Example!)) .OrderBy(example => example.Topic, StringComparer.Ordinal) .ThenBy(example => example.Id, StringComparer.Ordinal), diff --git a/examples/LibTmux.Examples/LibTmux.Examples.csproj b/examples/LibTmux.Examples/LibTmux.Examples.csproj index 42958a6..400cb3c 100644 --- a/examples/LibTmux.Examples/LibTmux.Examples.csproj +++ b/examples/LibTmux.Examples/LibTmux.Examples.csproj @@ -1,7 +1,7 @@ Exe - net10.0 + net8.0;net10.0 LibTmux.Examples false true diff --git a/examples/LibTmux.Examples/Program.cs b/examples/LibTmux.Examples/Program.cs index 2539e75..28f0f7b 100644 --- a/examples/LibTmux.Examples/Program.cs +++ b/examples/LibTmux.Examples/Program.cs @@ -1,24 +1,44 @@ using System.Diagnostics; using System.Runtime.Versioning; +using System.Text; namespace LibTmux.Examples; -/// Runs every example against a tmux server of its own. +/// Runs the example suite selected on the command line. /// /// The same list LibTmux.ExampleTests runs, on the console instead of /// in a test report. /// -[UnsupportedOSPlatform("windows")] internal static class Program { - private static async Task Main() + private static async Task Main(string[] args) { + if (args is ["--psmux"]) + { + Console.OutputEncoding = new UTF8Encoding(false, true); + await Snippets.Psmux.QueryPsmux(); + return 0; + } + + if (args.Length != 0) + { + Console.Error.WriteLine("usage: LibTmux.Examples [--psmux]"); + return 2; + } + if (OperatingSystem.IsWindows()) { - Console.Error.WriteLine("tmux does not run on Windows."); + Console.Error.WriteLine( + "The ordinary examples require tmux on Linux or macOS; use --psmux for the Windows query preview."); return 1; } + return await RunTmuxExamplesAsync(); + } + + [UnsupportedOSPlatform("windows")] + private static async Task RunTmuxExamplesAsync() + { int failed = 0; foreach (ExampleCase example in ExampleCase.Discover()) { diff --git a/examples/LibTmux.Examples/Snippets/ControlMode.cs b/examples/LibTmux.Examples/Snippets/ControlMode.cs index ac80860..591cfbf 100644 --- a/examples/LibTmux.Examples/Snippets/ControlMode.cs +++ b/examples/LibTmux.Examples/Snippets/ControlMode.cs @@ -25,4 +25,31 @@ public static async Task WatchForWindowAdd(Server server, CancellationToken ct) } #endregion } + + /// Reads the marker that says the event buffer discarded events. + [Example("React to a control stream that fell behind")] + 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 foreach (TmuxEvent observed in control.Events.WithCancellation(ct)) + { + if (observed is TmuxEventsDroppedEvent dropped) + { + // Anything cached from this stream is now a guess, so the + // marker is a signal to re-read rather than to log. + Console.WriteLine($"missed {dropped.Count}, {dropped.TotalDropped} in total"); + continue; + } + + if (observed is TmuxNotificationEvent { Name: "window-add" }) + { + break; + } + } + #endregion + } } diff --git a/examples/LibTmux.Examples/Snippets/Psmux.cs b/examples/LibTmux.Examples/Snippets/Psmux.cs new file mode 100644 index 0000000..9acc4cc --- /dev/null +++ b/examples/LibTmux.Examples/Snippets/Psmux.cs @@ -0,0 +1,50 @@ +namespace LibTmux.Examples.Snippets; + +/// Compile-checked psmux examples published by the Windows preview guide. +public static class Psmux +{ + /// Reads the sole session, its windows, its panes, and pane text. + [Example( + "Query one pinned psmux namespace from Windows or WSL", + RunsInDefaultSuite = false)] + public static async Task QueryPsmux() + { + #region QueryPsmux + using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + CancellationToken cancellationToken = cancellation.Token; + + string executable = Environment.GetEnvironmentVariable("LIBTMUX_PSMUX_BINARY") + ?? throw new InvalidOperationException("LIBTMUX_PSMUX_BINARY is required."); + string dataDirectory = Environment.GetEnvironmentVariable("PSMUX_DATA_DIR") + ?? throw new InvalidOperationException("PSMUX_DATA_DIR is required."); + string namespaceName = Environment.GetEnvironmentVariable("LIBTMUX_PSMUX_NAMESPACE") + ?? throw new InvalidOperationException("LIBTMUX_PSMUX_NAMESPACE is required."); + + PsmuxServer server = await PsmuxServer.ConnectAsync( + new PsmuxConnectionOptions( + executablePath: executable, + expectedBinarySha256: PsmuxServer.SupportedBinarySha256, + dataDirectory: dataDirectory, + namespaceName: namespaceName), + cancellationToken); + PsmuxSession session = await server.GetSessionAsync(cancellationToken); + + Console.WriteLine($"{session.Id} {session.Name}"); + foreach (PsmuxWindow window in await session.GetWindowsAsync(cancellationToken)) + { + Console.WriteLine($" {window.Id} {window.Index}: {window.Name}"); + foreach (PsmuxPane pane in await window.GetPanesAsync(cancellationToken)) + { + IReadOnlyList lines = await pane.CaptureAsync( + new PsmuxCaptureOptions(joinWrappedLines: true), + cancellationToken); + Console.WriteLine($" {pane.Id} {pane.Width}x{pane.Height}"); + foreach (string line in lines) + { + Console.WriteLine($" {line}"); + } + } + } + #endregion + } +} diff --git a/examples/LibTmux.Examples/packages.lock.json b/examples/LibTmux.Examples/packages.lock.json index 22b6c41..3b54e68 100644 --- a/examples/LibTmux.Examples/packages.lock.json +++ b/examples/LibTmux.Examples/packages.lock.json @@ -280,7 +280,7 @@ "libtmux.mcp": { "type": "Project", "dependencies": { - "LibTmux": "[0.0.0-alpha.6, )", + "LibTmux": "[0.0.0-alpha.8, )", "Microsoft.Extensions.Hosting": "[10.0.11, )", "ModelContextProtocol": "[2.2.0, )", "ModelContextProtocol.Extensions.Tasks": "[2.2.0, )" @@ -345,6 +345,390 @@ "ModelContextProtocol": "[2.2.0]" } } + }, + "net8.0": { + "Microsoft.Extensions.AI.Abstractions": { + "type": "Transitive", + "resolved": "10.8.3", + "contentHash": "K0B05oApxmviWalNHPMBBcRC7erKiDATz3ENNR/jqTR9JwIwLRefgDhj2jCRwL1aca99pXUe0qyQC73/xIuZig==", + "dependencies": { + "System.Text.Json": "10.0.10" + } + }, + "Microsoft.Extensions.Caching.Abstractions": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "4ZFBNE+jzR+CrWWlhOesnmywCW7pYKT0dxyAQRdL11yJwxe4jvcAu31eorFtEkoFeCDcUTeNssgPv2yaRRptaQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.10" + } + }, + "Microsoft.Extensions.Configuration": { + "type": "Transitive", + "resolved": "10.0.11", + "contentHash": "wlhRqZW8LcJPa+vk2oLAc/REXDItHtkFQdf/QcXYGZbZOO13izcsKY1pCvuFQYwUiZD+hwSZwsKASjqT+BNaVg==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.11", + "Microsoft.Extensions.Primitives": "10.0.11" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "10.0.11", + "contentHash": "fVi053xdpda9Em7vSkmgVxO/PtgC2m78ekReKWsgcyskqY0U82Bz/MONwxpGzI0hElYKJfw+fupqMVeKW3fSaA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.11" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "Transitive", + "resolved": "10.0.11", + "contentHash": "rFn8RuszZn3qquPVkDytMUlPc2+rXl9MCoygwc1XmAgC5vg5/oXJ8hkOosOrLoBLsqdTy4lFwP6iQdPS9uSYOA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.11", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.11" + } + }, + "Microsoft.Extensions.Configuration.CommandLine": { + "type": "Transitive", + "resolved": "10.0.11", + "contentHash": "1KHr/1L56llwQ/yI0tAisEA31UpPsn8aasjASIwELOaN4JIUcbjuQBMdFOIzfNBBeULoUa0XfBe5QDtRRUY+fg==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.11", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.11" + } + }, + "Microsoft.Extensions.Configuration.EnvironmentVariables": { + "type": "Transitive", + "resolved": "10.0.11", + "contentHash": "KICyU3eVi5jvloKm01EXV69L97H/zkhISVtV98cIuzuFOxNx3xTUVcXqvWTz3aq7OvUuDB/MFlPFjmxRaKF7/A==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.11", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.11" + } + }, + "Microsoft.Extensions.Configuration.FileExtensions": { + "type": "Transitive", + "resolved": "10.0.11", + "contentHash": "mDW7KVFB05M6jiRUyaZiOMWhS31n5HlSZwoYctHAZAucD4sMDJ70IxOmkGDt6RpstchD+keWBjhdzcMpSkWvWQ==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.11", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.11", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.11", + "Microsoft.Extensions.FileProviders.Physical": "10.0.11", + "Microsoft.Extensions.Primitives": "10.0.11" + } + }, + "Microsoft.Extensions.Configuration.Json": { + "type": "Transitive", + "resolved": "10.0.11", + "contentHash": "nSPrT8U/cNoB4coqkmnanAMK9PsL7lsjG+LLUKEwHRFwS6E78b8S1wdv/y88EOxBhasWov1rLd7RTHmmsYPOLg==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.11", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.11", + "Microsoft.Extensions.Configuration.FileExtensions": "10.0.11", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.11", + "System.Text.Json": "10.0.11" + } + }, + "Microsoft.Extensions.Configuration.UserSecrets": { + "type": "Transitive", + "resolved": "10.0.11", + "contentHash": "BRliLdUowglV8GS+J1G/QsSofCJYYFg3U8QZx0ACRn+a91az/Qnpy+h6PyHS94WgV2TSazX5D/cuuk6wnCJatw==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.11", + "Microsoft.Extensions.Configuration.Json": "10.0.11", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.11", + "Microsoft.Extensions.FileProviders.Physical": "10.0.11" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "10.0.11", + "contentHash": "PSmotV19c7E3lKed++uYo1kSiXFI+uTl37CBSrhq+CfLC3FCHjG7R91+xPnNehQfHS1b0Tzo/CCLPWH3qaEheg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "10.0.11", + "contentHash": "/a1aJz4m7ylhEDf25ugQChLQoN5XwoGjWw/BoR/ZWWKsO1v4DdJElS1uyngahz4B/eOzjFk1KNTkarRLE5wsIg==" + }, + "Microsoft.Extensions.Diagnostics": { + "type": "Transitive", + "resolved": "10.0.11", + "contentHash": "HT70uGPxMLqqnOzKMcnQtDmeV4r0KHr4qVCLhP7SXil9jMEm8sQXwcybxVVFGXZJ1V44xV0mLqQ54aZbcR2OiQ==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.11", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.11", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.11" + } + }, + "Microsoft.Extensions.Diagnostics.Abstractions": { + "type": "Transitive", + "resolved": "10.0.11", + "contentHash": "se7Kx8QpJEt+nf26L4qIVAofGTDr1wbexxsh/Fm3Xc04xUkqUXK06KUS7FLwSQYSjqb7q9n+T7MEcXYBhI1Y5g==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", + "Microsoft.Extensions.Options": "10.0.11", + "System.Diagnostics.DiagnosticSource": "10.0.11" + } + }, + "Microsoft.Extensions.FileProviders.Abstractions": { + "type": "Transitive", + "resolved": "10.0.11", + "contentHash": "JOjac6SQQgZmdmB8WGEw61/7siqMZoWJMkmq2p1goJGxqI59lO6oB4bOl0jNsbaPBdYy5Mlkb+6U7T4+CjnD8Q==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.11" + } + }, + "Microsoft.Extensions.FileProviders.Physical": { + "type": "Transitive", + "resolved": "10.0.11", + "contentHash": "Tq/UqMaczePv9yWwSsJZRgKtgA46djVR5xHj/lZBCueQ3ag8f9v5mu0EdhrNx7tXxNk+Y9OurG2oKuSKINjr0A==", + "dependencies": { + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.11", + "Microsoft.Extensions.FileSystemGlobbing": "10.0.11", + "Microsoft.Extensions.Primitives": "10.0.11" + } + }, + "Microsoft.Extensions.FileSystemGlobbing": { + "type": "Transitive", + "resolved": "10.0.11", + "contentHash": "2i6rtW/B5rCnWCnhdmWWEmaM9O0HD0zsPY9eRqa++y4tclI3Uw8zvGbBvhY/LjAdtf8gUHhUPcAWj3DRlWMXmQ==" + }, + "Microsoft.Extensions.Hosting.Abstractions": { + "type": "Transitive", + "resolved": "10.0.11", + "contentHash": "pwtpF7iF/NNaOBcX+pvMZ7y2+JAVbH5KkNrH9uMZtuVxVJsFTDiWiCR7Tk3HVptsAijaetipUZVRVK2LLq+nvA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.11", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.11", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.11", + "Microsoft.Extensions.Logging.Abstractions": "10.0.11" + } + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "10.0.11", + "contentHash": "nUOJwgFkSiLHiVGFpU22pIJtuWYewuSYQ3JVuP/gdK8ASMT807Px+TYQiRWs6uSsOmoyFTaVCwKXTasczV6BpA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "10.0.11", + "Microsoft.Extensions.Logging.Abstractions": "10.0.11", + "Microsoft.Extensions.Options": "10.0.11" + } + }, + "Microsoft.Extensions.Logging.Configuration": { + "type": "Transitive", + "resolved": "10.0.11", + "contentHash": "S7LvLeVHKNPaY2NMyxW7c2TBGsLgxoSUBCV5Ev5iN8kgC7EPR2UB7eW7vHsElGMcIUDwRmoxLfvGDynCn3q6EA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.11", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.11", + "Microsoft.Extensions.Configuration.Binder": "10.0.11", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", + "Microsoft.Extensions.Logging": "10.0.11", + "Microsoft.Extensions.Logging.Abstractions": "10.0.11", + "Microsoft.Extensions.Options": "10.0.11", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.11" + } + }, + "Microsoft.Extensions.Logging.Console": { + "type": "Transitive", + "resolved": "10.0.11", + "contentHash": "dFc0yDudyD1iIg6z9XT7ofsT3hVO7Y4ylrxGHIVRR0GaZ4CUk4ujOrMoy7wWEdNHZhvJooySg6hZpOxyS8zEVA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", + "Microsoft.Extensions.Logging": "10.0.11", + "Microsoft.Extensions.Logging.Abstractions": "10.0.11", + "Microsoft.Extensions.Logging.Configuration": "10.0.11", + "Microsoft.Extensions.Options": "10.0.11", + "System.Text.Json": "10.0.11" + } + }, + "Microsoft.Extensions.Logging.Debug": { + "type": "Transitive", + "resolved": "10.0.11", + "contentHash": "wr+j1bjdFXhc8lKTLoq+RbwFM8M+orcMS9xrcqLmDGOxJcXpKizEeE5h6v/GKwCZV02FmhaA7OlNjoq072jZpQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", + "Microsoft.Extensions.Logging": "10.0.11", + "Microsoft.Extensions.Logging.Abstractions": "10.0.11" + } + }, + "Microsoft.Extensions.Logging.EventLog": { + "type": "Transitive", + "resolved": "10.0.11", + "contentHash": "Eck9GpCCpvZ3f6L7IUlN+mPtRVefnf7PsiIG5vi61QawPtLNCEAv2TPD/M3SojcU0PFaef+BxiVGPOShFHtDog==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", + "Microsoft.Extensions.Logging": "10.0.11", + "Microsoft.Extensions.Logging.Abstractions": "10.0.11", + "Microsoft.Extensions.Options": "10.0.11", + "System.Diagnostics.EventLog": "10.0.11" + } + }, + "Microsoft.Extensions.Logging.EventSource": { + "type": "Transitive", + "resolved": "10.0.11", + "contentHash": "hs6QWECLLohi2VKqUvSGRUvrg7eXR1DqKL95Jrtz3cdD2g2nBA+yJPdRQLZ7SLmnTZWycxfMDK2s0ho+rfst5w==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", + "Microsoft.Extensions.Logging": "10.0.11", + "Microsoft.Extensions.Logging.Abstractions": "10.0.11", + "Microsoft.Extensions.Options": "10.0.11", + "Microsoft.Extensions.Primitives": "10.0.11", + "System.Text.Json": "10.0.11" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "10.0.11", + "contentHash": "eY1GAKcTfD2maP27J84X9IovT3yjHJ2dVDzPmDg6/XqYvt3jMzJhtfQCLjG9pVsZGAd+8DQ2QrjaDcs2+VQLGw==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", + "Microsoft.Extensions.Primitives": "10.0.11" + } + }, + "Microsoft.Extensions.Options.ConfigurationExtensions": { + "type": "Transitive", + "resolved": "10.0.11", + "contentHash": "syEhXQ/sEaSBFaqzlp9gDGHX/nk6gkQkh1sIUpBO1mlBj3Phu1rmb4ML1uCiyPW9N6Kxfxv3y5FGObC+bV01Qw==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.11", + "Microsoft.Extensions.Configuration.Binder": "10.0.11", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", + "Microsoft.Extensions.Options": "10.0.11", + "Microsoft.Extensions.Primitives": "10.0.11" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "10.0.11", + "contentHash": "SXcz+kF+4Oo9b1+55zntpJFYfwb1jw66ioxptyNOOTDc8g2FHnBFWjZpsWfCvZIhzr0x+4e2trVTs4OKwQfBtw==" + }, + "ModelContextProtocol.Core": { + "type": "Transitive", + "resolved": "2.2.0", + "contentHash": "FeBfXU6T8k+jw4afg4sfxdEX2rL/e5oKOk9ROOGztu9k47+7Bz08sdaToYt2XvMY1opNbwxYQOFMj6wH9TInhA==", + "dependencies": { + "Microsoft.Extensions.AI.Abstractions": "10.8.3", + "Microsoft.Extensions.Logging.Abstractions": "10.0.10", + "System.IO.Pipelines": "10.0.10", + "System.Net.ServerSentEvents": "10.0.10" + } + }, + "System.Diagnostics.DiagnosticSource": { + "type": "Transitive", + "resolved": "10.0.11", + "contentHash": "QoXcAdDhBudxorzYF1F5Uo7FY0CSWgmTjqVRJjcQaYAkbO3dCPXDJajJwyApTGHCJ+T5PfVyKZqXEKZ8PH+UWQ==" + }, + "System.Diagnostics.EventLog": { + "type": "Transitive", + "resolved": "10.0.11", + "contentHash": "QTXEoQBzz00SFWbo7nAg1Ogd4f99lwqcO9uAJ7MYSLEUR28f6As32QktrqG2Fr9cfAfd1GjLyGYspE7Ipj7P6w==" + }, + "System.IO.Pipelines": { + "type": "Transitive", + "resolved": "10.0.11", + "contentHash": "rg8LPOZ62quw3aTnsQNh9rssncaMs69WEM1DiJdDkV+o4Llb2s5Pasy5Bulfm8zL+pE4gDcSQo7V2zW2jvxwYg==" + }, + "System.Net.ServerSentEvents": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "1m3dGOl5YI9VhOE+MPCSII+WXZcyYVr5D/UbBifOUxkrx2npczhWjdl0PYZ1tMGygVce1mIfUDhdM1LBiEQFNw==" + }, + "System.Text.Encodings.Web": { + "type": "Transitive", + "resolved": "10.0.11", + "contentHash": "R7H2/Oqr3onFff/JKDpfRjwxcQZlocF/eQ0ce8go/OTjqz+6LEp/fZK8j1YnDobtysChATEg7krVq4hQJ3IoeQ==" + }, + "System.Text.Json": { + "type": "Transitive", + "resolved": "10.0.11", + "contentHash": "X2rXQy7g8KEUvWVbAz6vOk6byI1eMgmzFeXbKxq4BfZCfFy5S91K+K+hd4ZBlSp0wL1Y8FyGRXs6+hvABTjwQw==", + "dependencies": { + "System.IO.Pipelines": "10.0.11", + "System.Text.Encodings.Web": "10.0.11" + } + }, + "libtmux": { + "type": "Project", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "[8.0.0, )" + } + }, + "libtmux.mcp": { + "type": "Project", + "dependencies": { + "LibTmux": "[0.0.0-alpha.8, )", + "Microsoft.Extensions.Hosting": "[10.0.11, )", + "ModelContextProtocol": "[2.2.0, )", + "ModelContextProtocol.Extensions.Tasks": "[2.2.0, )" + } + }, + "Microsoft.Extensions.Hosting": { + "type": "CentralTransitive", + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "eIDa/Rl+93aj17gMlFsJJx+LhBvb3CP0Mu1PeVYkDp2Y3S4Jock8UynfGQEcx7lrlq+gKW+ECQJHbro/LTPDEQ==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.11", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.11", + "Microsoft.Extensions.Configuration.Binder": "10.0.11", + "Microsoft.Extensions.Configuration.CommandLine": "10.0.11", + "Microsoft.Extensions.Configuration.EnvironmentVariables": "10.0.11", + "Microsoft.Extensions.Configuration.FileExtensions": "10.0.11", + "Microsoft.Extensions.Configuration.Json": "10.0.11", + "Microsoft.Extensions.Configuration.UserSecrets": "10.0.11", + "Microsoft.Extensions.DependencyInjection": "10.0.11", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", + "Microsoft.Extensions.Diagnostics": "10.0.11", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.11", + "Microsoft.Extensions.FileProviders.Physical": "10.0.11", + "Microsoft.Extensions.Hosting.Abstractions": "10.0.11", + "Microsoft.Extensions.Logging": "10.0.11", + "Microsoft.Extensions.Logging.Abstractions": "10.0.11", + "Microsoft.Extensions.Logging.Configuration": "10.0.11", + "Microsoft.Extensions.Logging.Console": "10.0.11", + "Microsoft.Extensions.Logging.Debug": "10.0.11", + "Microsoft.Extensions.Logging.EventLog": "10.0.11", + "Microsoft.Extensions.Logging.EventSource": "10.0.11", + "Microsoft.Extensions.Options": "10.0.11" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "CentralTransitive", + "requested": "[8.0.0, )", + "resolved": "10.0.11", + "contentHash": "Ljd0Uxoq5XpScD2Bg0nM/r3mwx7Ao5Uq24eo2ARxbGvqJ7Zht6rt2cJtwVRH4Cv+1ZVMdXz6TB43KbpmsxRrvQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", + "System.Diagnostics.DiagnosticSource": "10.0.11" + } + }, + "ModelContextProtocol": { + "type": "CentralTransitive", + "requested": "[2.2.0, )", + "resolved": "2.2.0", + "contentHash": "4Pb9u02Nwsp0poueDsqNdyGRojFxOYpljB7zDBsq+aHL+Afou3OgxlBc3GWFVnsRMRJrUtWqDh3s6k2JgPzmrQ==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "10.0.10", + "Microsoft.Extensions.Hosting.Abstractions": "10.0.10", + "ModelContextProtocol.Core": "[2.2.0]" + } + }, + "ModelContextProtocol.Extensions.Tasks": { + "type": "CentralTransitive", + "requested": "[2.2.0, )", + "resolved": "2.2.0", + "contentHash": "cIGGEIL/KVbPBxflSF6TRKmjNTGk668e8x/R+Jx9l8VPHH70BisCmjdtHdkDHbbBr9gGMbjBjJdiNMJecweCGw==", + "dependencies": { + "ModelContextProtocol": "[2.2.0]" + } + } } } -} \ No newline at end of file +} diff --git a/src/LibTmux.Mcp/Diagnostics/Log.cs b/src/LibTmux.Mcp/Diagnostics/Log.cs index e96cd00..fc0267c 100644 --- a/src/LibTmux.Mcp/Diagnostics/Log.cs +++ b/src/LibTmux.Mcp/Diagnostics/Log.cs @@ -63,4 +63,32 @@ internal static partial void ControlClientUnavailable( Level = LogLevel.Warning, Message = "Tool {Tool} failed.")] internal static partial void ToolFailed(ILogger logger, Exception error, string tool); + + [LoggerMessage( + EventId = 8, + Level = LogLevel.Debug, + Message = "Control client for socket {Socket} could not be cleaned up.")] + internal static partial void ControlClientCleanupFailed( + ILogger logger, + Exception error, + string? socket); + + [LoggerMessage( + EventId = 9, + Level = LogLevel.Warning, + Message = "Background job {JobId} in pane {PaneId} could no longer be watched.")] + internal static partial void JobWatcherFailed( + ILogger logger, + Exception error, + string jobId, + string paneId); + + [LoggerMessage( + EventId = 10, + Level = LogLevel.Debug, + Message = "Hierarchy subscriber callback for endpoint {Endpoint} failed.")] + internal static partial void HierarchySubscriberFailed( + ILogger logger, + Exception error, + string endpoint); } diff --git a/src/LibTmux.Mcp/Filters/ResourceResponseBudgetFilter.cs b/src/LibTmux.Mcp/Filters/ResourceResponseBudgetFilter.cs new file mode 100644 index 0000000..2795165 --- /dev/null +++ b/src/LibTmux.Mcp/Filters/ResourceResponseBudgetFilter.cs @@ -0,0 +1,32 @@ +using ModelContextProtocol; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace LibTmux.Mcp; + +/// Refuses a serialized resource result that exceeds policy. +internal static class ResourceResponseBudgetFilter +{ + /// Builds the resource-response budget filter. + internal static McpRequestFilter Create( + ServerPolicy policy) + { + ArgumentNullException.ThrowIfNull(policy); + return next => async (request, cancellationToken) => + { + ReadResourceResult result = await next(request, cancellationToken) + .ConfigureAwait(false); + int applicationBudget = policy.MaxBytes - Utf8JsonBudget.ProtocolMetadataReserve; + if (applicationBudget > 0 + && Utf8JsonBudget.Fits(result, applicationBudget, ToolJson.Options)) + { + return result; + } + + throw new McpException( + $"The resource response exceeded this server's {policy.MaxBytes} UTF-8 byte " + + "limit. Read a narrower resource or raise " + + $"{ServerPolicy.MaxBytesVariable} and restart the MCP server."); + }; + } +} diff --git a/src/LibTmux.Mcp/Filters/ToolFailureFilter.cs b/src/LibTmux.Mcp/Filters/ToolFailureFilter.cs index 33f31b6..6aa600d 100644 --- a/src/LibTmux.Mcp/Filters/ToolFailureFilter.cs +++ b/src/LibTmux.Mcp/Filters/ToolFailureFilter.cs @@ -37,6 +37,7 @@ internal static McpRequestFilter Create() next => async (request, cancellationToken) => { string tool = request.Params?.Name ?? "a tmux tool"; + bool mayModify = ToolMetadata.MayModify(request, tool); ILogger logger = request.Services?.GetService() ?.CreateLogger(nameof(ToolFailureFilter)) ?? NullLogger.Instance; @@ -61,6 +62,7 @@ internal static McpRequestFilter Create() logger, tool, retried, + mayModify, "The tmux server was restarted and the retry failed too. " + "Call tmux_list_servers to see what is running now."); } @@ -71,6 +73,7 @@ internal static McpRequestFilter Create() logger, tool, error, + mayModify, "This tmux is too old for that operation. " + "Call tmux_server_info to see which version is running."); } @@ -80,13 +83,23 @@ internal static McpRequestFilter Create() logger, tool, error, + mayModify, "That session, window or pane no longer exists. " + "Call tmux_hierarchy to see what does."); } catch (TmuxCommandException error) { // tmux's own message is the most specific thing anybody has. - return Failure(logger, tool, error, $"tmux refused the command: {error.Message}"); + return Failure( + logger, + tool, + error, + mayModify, + $"tmux refused the command: {error.Message}"); + } + catch (TmuxOperationCanceledException error) when (error.CommandMayHaveExecuted) + { + return Failure(logger, tool, error, mayModify, error.Message); } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { @@ -94,13 +107,14 @@ internal static McpRequestFilter Create() logger, tool, null, + mayModify, "The operation ran out of time. Nothing was rolled back — whatever " - + "was started is still running in its pane. Read the pane before " - + "trying again, so you do not start it twice."); + + "was started is still running in its pane. Do not retry until you " + + "have read the pane, so you do not start it twice."); } catch (LibTmuxException error) { - return Failure(logger, tool, error, error.Message); + return Failure(logger, tool, error, mayModify, error.Message); } catch (Exception error) when (error is not OperationCanceledException) { @@ -109,6 +123,7 @@ internal static McpRequestFilter Create() logger, tool, error, + mayModify, $"{error.Message} This is unexpected — check the server's log on " + "standard error before retrying, because retrying unchanged will " + "most likely fail the same way."); @@ -126,6 +141,7 @@ private static CallToolResult Failure( ILogger logger, string tool, Exception? error, + bool mayModify, string advice) { if (error is not null) @@ -136,7 +152,61 @@ private static CallToolResult Failure( return new CallToolResult { IsError = true, - Content = [new TextContentBlock { Text = $"{tool} failed. {advice}" }], + Content = + [ + new TextContentBlock + { + Text = $"{tool} failed. {ActionableAdvice(tool, error, mayModify, advice)}", + }, + ], }; } + + internal static string ActionableAdvice( + string tool, + Exception? error, + bool mayModify, + string advice) + { + if (TryPasteCleanup(error, out string? buffer)) + { + return $"The paste failed, and temporary tmux buffer {buffer} may still " + + "contain the pasted text because cleanup failed. Do not retry the paste. " + + "Inspect with tmux_list_buffers, then ask the operator to remove that " + + $"exact buffer with tmux delete-buffer -b {buffer}."; + } + + bool mayHaveActed = error is TmuxOperationCanceledException cancellation + && cancellation.CommandMayHaveExecuted + || error is LibTmuxException tmux + && tmux.Dispatch != TmuxDispatchState.NotDispatched; + if (!mayModify || !mayHaveActed) + { + return advice; + } + + string recovery = string.Equals(tool, "tmux_start_job", StringComparison.Ordinal) + ? " Call tmux_list_jobs now; any possibly started command has a retained handle." + : " Inspect tmux state first."; + return advice + + " tmux may have acted before the failure. Do not retry this operation." + + recovery; + } + + private static bool TryPasteCleanup(Exception? error, out string? buffer) + { + buffer = null; + if (error?.Data[WriteTools.PasteBufferCleanupFailureDataKey] is not Exception + || error.Data[WriteTools.PasteBufferCleanupBufferDataKey] is not string candidate + || candidate.Length is < 1 or > 64 + || !candidate.StartsWith("libtmux_mcp_", StringComparison.Ordinal) + || candidate.Any(static character => + !char.IsAsciiLetterOrDigit(character) && character != '_')) + { + return false; + } + + buffer = candidate; + return true; + } } diff --git a/src/LibTmux.Mcp/Filters/ToolMetadata.cs b/src/LibTmux.Mcp/Filters/ToolMetadata.cs new file mode 100644 index 0000000..b499727 --- /dev/null +++ b/src/LibTmux.Mcp/Filters/ToolMetadata.cs @@ -0,0 +1,23 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace LibTmux.Mcp; + +internal static class ToolMetadata +{ + internal static bool MayModify( + RequestContext request, + string tool) + { + McpServerOptions? options = request.Services + ?.GetService>()?.Value; + McpServerTool? registered = options?.ToolCollection? + .FirstOrDefault(candidate => string.Equals( + candidate.ProtocolTool.Name, + tool, + StringComparison.Ordinal)); + return registered?.ProtocolTool.Annotations?.ReadOnlyHint != true; + } +} diff --git a/src/LibTmux.Mcp/Filters/ToolResponseBudgetFilter.cs b/src/LibTmux.Mcp/Filters/ToolResponseBudgetFilter.cs new file mode 100644 index 0000000..d4d3509 --- /dev/null +++ b/src/LibTmux.Mcp/Filters/ToolResponseBudgetFilter.cs @@ -0,0 +1,113 @@ +using System.Text.Json; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace LibTmux.Mcp; + +/// Bounds a serialized tool result to policy. +internal static class ToolResponseBudgetFilter +{ + /// Builds the response-budget filter. + internal static McpRequestFilter Create( + ServerPolicy policy) + { + ArgumentNullException.ThrowIfNull(policy); + return next => async (request, cancellationToken) => + { + CallToolResult result = await next(request, cancellationToken).ConfigureAwait(false); + if (Utf8JsonBudget.FitsToolResult(result, policy.MaxBytes, ToolJson.Options)) + { + return result; + } + + if (result.IsError != true + && TryReadActionResult(result.StructuredContent, out ActionResult? action) + && action is not null) + { + return CompletedAction(action); + } + + string message = result.IsError == true + ? OversizedError(request, policy.MaxBytes) + : $"The tool response exceeded this server's {policy.MaxBytes} UTF-8 " + + "byte limit. Narrow the target or result count, or raise " + + $"{ServerPolicy.MaxBytesVariable} and restart the MCP server."; + return new CallToolResult + { + IsError = true, + Content = + [ + new TextContentBlock + { + Text = message, + }, + ], + }; + }; + } + + private static string OversizedError( + RequestContext request, + int maxBytes) + { + string tool = request.Params?.Name ?? "a tmux tool"; + return ToolMetadata.MayModify(request, tool) + ? $"The tool failed, and its detailed error exceeded this server's " + + $"{maxBytes} UTF-8 byte limit. tmux may have acted. Do not retry; " + + "inspect tmux state first." + : $"The read failed, and its detailed error exceeded this server's " + + $"{maxBytes} UTF-8 byte limit. Narrow the target or raise " + + $"{ServerPolicy.MaxBytesVariable} and restart the MCP server."; + } + + private static bool TryReadActionResult( + JsonElement? structuredContent, + out ActionResult? action) + { + action = null; + if (structuredContent is not JsonElement structured + || structured.ValueKind != JsonValueKind.Object + || !structured.TryGetProperty("changed", out JsonElement changed) + || changed.ValueKind != JsonValueKind.String) + { + return false; + } + + try + { + action = structured.Deserialize(ToolJson.Options); + return action is not null; + } + catch (JsonException) + { + return false; + } + } + + private static CallToolResult CompletedAction(ActionResult original) + { + var acknowledgement = new ActionResult( + "The action completed, but its detailed acknowledgement exceeded the " + + "server response limit. Do not retry it; inspect tmux state first.", + PaneId: ValidPaneId(original.PaneId), + WindowId: ValidWindowId(original.WindowId), + SessionId: ValidSessionId(original.SessionId)); + JsonElement structured = JsonSerializer.SerializeToElement( + acknowledgement, + ToolJson.Options); + return new CallToolResult + { + Content = [new TextContentBlock { Text = structured.GetRawText() }], + StructuredContent = structured, + }; + } + + private static string? ValidPaneId(string? value) => + PaneId.TryParse(value, out PaneId id) ? id.ToString() : null; + + private static string? ValidWindowId(string? value) => + WindowId.TryParse(value, out WindowId id) ? id.ToString() : null; + + private static string? ValidSessionId(string? value) => + SessionId.TryParse(value, out SessionId id) ? id.ToString() : null; +} diff --git a/src/LibTmux.Mcp/Filters/Utf8JsonBudget.cs b/src/LibTmux.Mcp/Filters/Utf8JsonBudget.cs new file mode 100644 index 0000000..0e623cc --- /dev/null +++ b/src/LibTmux.Mcp/Filters/Utf8JsonBudget.cs @@ -0,0 +1,383 @@ +using System.Buffers; +using System.Runtime.CompilerServices; +using System.Text; +using System.Text.Encodings.Web; +using System.Text.Json; +using ModelContextProtocol.Protocol; + +namespace LibTmux.Mcp; + +/// Checks a JSON value against a UTF-8 byte ceiling without retaining it. +internal static class Utf8JsonBudget +{ + private static readonly ConditionalWeakTable + StructuredEnvelopeSizes = new(); + private static readonly ConditionalWeakTable + JsonEncodingProfiles = new(); + + // MCP 2.2 appends server identity and may wrap a result in task metadata + // after application filters run, so every result keeps room for both. + internal const int ProtocolMetadataReserve = 512; + + /// Answers whether the serialized value fits the ceiling. + internal static bool Fits(T value, int maxBytes, JsonSerializerOptions options) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxBytes); + ArgumentNullException.ThrowIfNull(options); + + try + { + using var sink = new LimitedWriteStream(maxBytes); + JsonSerializer.Serialize(sink, value, options); + return true; + } + catch (BudgetExceededException) + { + return false; + } + } + + /// Counts the serialized UTF-8 bytes without retaining them. + internal static int GetByteCount(T value, JsonSerializerOptions options) + { + ArgumentNullException.ThrowIfNull(options); + using var sink = new LimitedWriteStream(int.MaxValue); + JsonSerializer.Serialize(sink, value, options); + return checked((int)sink.Length); + } + + /// Budgets the complete MCP result produced for a structured return value. + internal static int GetStructuredToolResultByteCount( + T value, + JsonSerializerOptions options) + { + ArgumentNullException.ThrowIfNull(options); + using var sink = new JsonFragmentCountingStream(options); + JsonSerializer.Serialize(sink, value, options); + return checked( + GetStructuredEnvelopeSize(options) + + sink.RawLength + + sink.EmbeddedStringContentLength + + ProtocolMetadataReserve); + } + + /// Answers whether a result fits after protocol metadata is appended. + internal static bool FitsToolResult( + CallToolResult value, + int maxBytes, + JsonSerializerOptions options) + { + ArgumentOutOfRangeException.ThrowIfLessThanOrEqual( + maxBytes, + ProtocolMetadataReserve); + return Fits(value, maxBytes - ProtocolMetadataReserve, options); + } + + /// Counts one JSON fragment in both copies of a structured tool result. + internal static int GetStructuredJsonFragmentByteCount( + T value, + JsonSerializerOptions options) + { + ArgumentNullException.ThrowIfNull(options); + using var sink = new JsonFragmentCountingStream(options); + JsonSerializer.Serialize(sink, value, options); + return checked(sink.RawLength + sink.EmbeddedStringContentLength); + } + + /// Counts a bounded-text fragment without materializing large JSON strings. + internal static int GetStructuredJsonFragmentByteCount( + BoundedText value, + JsonSerializerOptions options) + { + ArgumentNullException.ThrowIfNull(value); + ArgumentNullException.ThrowIfNull(options); + var skeleton = new BoundedText( + [], + value.Truncated, + value.DroppedLines, + value.DroppedBytes); + int bytes = GetStructuredJsonFragmentByteCount(skeleton, options); + if (value.Lines.Count == 0) + { + return bytes; + } + + int embeddedQuoteBytes = MeasureJsonString("\"", options).RawContentLength; + for (int index = 0; index < value.Lines.Count; index++) + { + JsonStringMetrics line = MeasureJsonString(value.Lines[index], options); + bytes = checked( + bytes + + 2 + + line.RawContentLength + + (2 * embeddedQuoteBytes) + + line.EmbeddedContentLength + + (index > 0 ? 2 : 0)); + } + + return bytes; + } + + /// Counts a matched-line fragment without materializing its text. + internal static int GetStructuredJsonFragmentByteCount( + MatchedLine value, + JsonSerializerOptions options) + { + ArgumentNullException.ThrowIfNull(value); + ArgumentNullException.ThrowIfNull(options); + int skeleton = GetStructuredJsonFragmentByteCount( + new MatchedLine(value.Row, string.Empty), + options); + JsonStringMetrics text = MeasureJsonString(value.Text, options); + return checked(skeleton + text.RawContentLength + text.EmbeddedContentLength); + } + + /// Counts one string's content in the raw and embedded JSON copies. + internal static int GetStructuredJsonStringContentByteCount( + string value, + JsonSerializerOptions options) + { + ArgumentNullException.ThrowIfNull(value); + ArgumentNullException.ThrowIfNull(options); + JsonStringMetrics metrics = MeasureJsonString(value, options); + return checked(metrics.RawContentLength + metrics.EmbeddedContentLength); + } + + private static int GetStructuredEnvelopeSize(JsonSerializerOptions options) => + StructuredEnvelopeSizes.GetValue( + options, + static current => new EnvelopeSize(MeasureStructuredEnvelope(current))).Value; + + private static int MeasureStructuredEnvelope(JsonSerializerOptions options) + { + JsonElement structured = JsonSerializer.SerializeToElement(new { }, options); + var result = new CallToolResult + { + Content = [new TextContentBlock { Text = "{}" }], + StructuredContent = structured, + }; + + return checked(GetByteCount(result, options) - 4); + } + + private static JsonStringMetrics MeasureJsonString( + string value, + JsonSerializerOptions options) + { + JsonEncodingProfile profile = JsonEncodingProfiles.GetValue( + options, + static current => new JsonEncodingProfile( + current.Encoder ?? JavaScriptEncoder.Default)); + int rawLength = 0; + int embeddedLength = 0; + ReadOnlySpan remaining = value; + while (!remaining.IsEmpty) + { + JsonStringMetrics rune; + int consumed; + if (remaining[0] <= 0x7f) + { + rune = profile.Ascii[remaining[0]]; + consumed = 1; + } + else + { + OperationStatus status = Rune.DecodeFromUtf16( + remaining, + out Rune decoded, + out consumed); + if (status != OperationStatus.Done) + { + decoded = Rune.ReplacementChar; + consumed = 1; + } + + rune = EncodeRune(decoded, profile.Encoder); + } + + rawLength = checked(rawLength + rune.RawContentLength); + embeddedLength = checked(embeddedLength + rune.EmbeddedContentLength); + remaining = remaining[consumed..]; + } + + return new JsonStringMetrics(rawLength, embeddedLength); + } + + private static JsonStringMetrics EncodeRune(Rune rune, JavaScriptEncoder encoder) + { + Span source = stackalloc byte[4]; + Span encoded = stackalloc byte[64]; + Span embedded = stackalloc byte[384]; + int sourceLength = rune.EncodeToUtf8(source); + OperationStatus first = encoder.EncodeUtf8( + source[..sourceLength], + encoded, + out int firstConsumed, + out int firstWritten, + isFinalBlock: true); + OperationStatus second = encoder.EncodeUtf8( + encoded[..firstWritten], + embedded, + out int secondConsumed, + out int secondWritten, + isFinalBlock: true); + if (first != OperationStatus.Done + || firstConsumed != sourceLength + || second != OperationStatus.Done + || secondConsumed != firstWritten) + { + throw new InvalidOperationException("The JSON encoder exceeded its rune bound."); + } + + return new JsonStringMetrics(firstWritten, secondWritten); + } + + private sealed class EnvelopeSize(int value) + { + internal int Value { get; } = value; + } + + private sealed class JsonEncodingProfile + { + internal JsonEncodingProfile(JavaScriptEncoder encoder) + { + Encoder = encoder; + Ascii = new JsonStringMetrics[128]; + for (int value = 0; value < Ascii.Length; value++) + { + Ascii[value] = EncodeRune(new Rune(value), encoder); + } + } + + internal JsonStringMetrics[] Ascii { get; } + + internal JavaScriptEncoder Encoder { get; } + } + + private sealed class LimitedWriteStream(int maxBytes) : Stream + { + private int _bytesWritten; + + public override bool CanRead => false; + + public override bool CanSeek => false; + + public override bool CanWrite => true; + + public override long Length => _bytesWritten; + + public override long Position + { + get => _bytesWritten; + set => throw new NotSupportedException(); + } + + public override void Flush() + { + } + + public override int Read(byte[] buffer, int offset, int count) => + throw new NotSupportedException(); + + public override long Seek(long offset, SeekOrigin origin) => + throw new NotSupportedException(); + + public override void SetLength(long value) => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) + { + ArgumentNullException.ThrowIfNull(buffer); + ArgumentOutOfRangeException.ThrowIfNegative(offset); + ArgumentOutOfRangeException.ThrowIfNegative(count); + ArgumentOutOfRangeException.ThrowIfGreaterThan(offset, buffer.Length - count); + Count(count); + } + + public override void Write(ReadOnlySpan buffer) => Count(buffer.Length); + + private void Count(int count) + { + if (count > maxBytes - _bytesWritten) + { + throw new BudgetExceededException(); + } + + _bytesWritten += count; + } + } + + private sealed class JsonFragmentCountingStream : Stream + { + private readonly int _backslashExpansion; + private readonly int _quoteExpansion; + private int _embeddedExpansion; + private int _rawLength; + + internal JsonFragmentCountingStream(JsonSerializerOptions options) + { + _quoteExpansion = MeasureJsonString("\"", options).RawContentLength - 1; + _backslashExpansion = MeasureJsonString("\\", options).RawContentLength - 1; + } + + internal int RawLength => _rawLength; + + internal int EmbeddedStringContentLength => checked(_rawLength + _embeddedExpansion); + + public override bool CanRead => false; + + public override bool CanSeek => false; + + public override bool CanWrite => true; + + public override long Length => _rawLength; + + public override long Position + { + get => _rawLength; + set => throw new NotSupportedException(); + } + + public override void Flush() + { + } + + public override int Read(byte[] buffer, int offset, int count) => + throw new NotSupportedException(); + + public override long Seek(long offset, SeekOrigin origin) => + throw new NotSupportedException(); + + public override void SetLength(long value) => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) + { + ArgumentNullException.ThrowIfNull(buffer); + ArgumentOutOfRangeException.ThrowIfNegative(offset); + ArgumentOutOfRangeException.ThrowIfNegative(count); + ArgumentOutOfRangeException.ThrowIfGreaterThan(offset, buffer.Length - count); + Count(buffer.AsSpan(offset, count)); + } + + public override void Write(ReadOnlySpan buffer) => Count(buffer); + + private void Count(ReadOnlySpan buffer) + { + _rawLength = checked(_rawLength + buffer.Length); + foreach (byte value in buffer) + { + if (value is (byte)'"' or (byte)'\\') + { + _embeddedExpansion = checked( + _embeddedExpansion + + (value == (byte)'"' ? _quoteExpansion : _backslashExpansion)); + } + } + } + } + + private readonly record struct JsonStringMetrics( + int RawContentLength, + int EmbeddedContentLength); + + private sealed class BudgetExceededException : Exception; +} diff --git a/src/LibTmux.Mcp/Jobs/JobStore.cs b/src/LibTmux.Mcp/Jobs/JobStore.cs index 13dbe27..8f49f68 100644 --- a/src/LibTmux.Mcp/Jobs/JobStore.cs +++ b/src/LibTmux.Mcp/Jobs/JobStore.cs @@ -1,5 +1,8 @@ -using System.Collections.Concurrent; +using System.Runtime.ExceptionServices; using System.Runtime.Versioning; +using System.Text; +using System.Threading.Channels; +using LibTmux.Internal; using Microsoft.Extensions.Logging; using ModelContextProtocol; @@ -23,24 +26,37 @@ namespace LibTmux.Mcp; /// /// [UnsupportedOSPlatform("windows")] -public sealed class JobStore : IDisposable +public sealed class JobStore : IDisposable, IAsyncDisposable { - private const int MaxRetained = 100; + internal const int Capacity = 100; + internal const string RecoveryJobIdDataKey = "LibTmux.Mcp.JobId"; - private readonly ConcurrentDictionary _jobs = new(StringComparer.Ordinal); + private readonly object _gate = new(); + private readonly Dictionary _jobs = new(StringComparer.Ordinal); + private readonly HashSet _starting = []; + private readonly HashSet _operations = []; private readonly ILogger? _logger; private readonly CancellationTokenSource _shutdown = new(); + private Exception? _operationFailure; + private Task? _disposeTask; + private bool _stopping; /// Initializes the store. /// Records how a job ended. public JobStore(ILogger? logger = null) => _logger = logger; /// - public void Dispose() + public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult(); + + /// + public ValueTask DisposeAsync() { - // The commands themselves are tmux's; only the watchers stop. - _shutdown.Cancel(); - _shutdown.Dispose(); + lock (_gate) + { + _stopping = true; + _disposeTask ??= DisposeCoreAsync(); + return new ValueTask(_disposeTask); + } } /// Starts a command and answers a handle for it immediately. @@ -50,32 +66,71 @@ public void Dispose() /// Whether to keep the command out of shell history. /// Cancels sending the command. /// The job. - public async Task StartAsync( + public Task StartAsync( + Server server, + Pane pane, + string command, + bool suppressHistory, + CancellationToken cancellationToken) => + StartAsync( + server, + pane, + command, + suppressHistory, + ServerPolicy.DefaultMaxBytes, + cancellationToken); + + internal Task StartAsync( Server server, Pane pane, string command, bool suppressHistory, + int maxCommandBytes, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(server); ArgumentNullException.ThrowIfNull(pane); ArgumentException.ThrowIfNullOrWhiteSpace(command); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxCommandBytes); + RequireOwnership(server, pane); + + int commandBytes = Encoding.UTF8.GetByteCount(command); + if (commandBytes > maxCommandBytes) + { + throw new McpException( + $"The command is {commandBytes} UTF-8 bytes; the job input ceiling is " + + $"{maxCommandBytes}. Put a longer script in a file and start that file instead."); + } - // The handle IS the run token's id, so a caller holding a job id can be - // matched against the bookkeeping lines that job left in its pane. WriteTools.RunToken token = WriteTools.RunToken.Create(); - Job job = new(token.Id, pane.Id.ToString(), command, token); - Forget(); - _jobs[job.JobId] = job; - - await WriteTools - .SendRunPayloadAsync(server, pane, command, token, suppressHistory, cancellationToken) - .ConfigureAwait(false); - - // Watching is deliberately detached: the point of a job is that the - // caller does not wait, so nothing here may be awaited by the tool call. - job.Watcher = Task.Run(() => WatchAsync(server, pane, job), CancellationToken.None); - return job.Describe(); + var job = new StoredJob(token.Id, server, pane, commandBytes, token); + job.RequireToolResultsFit(maxCommandBytes); + var reservation = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + lock (_gate) + { + ObjectDisposedException.ThrowIf(_stopping, this); + ForgetLocked(); + if (_jobs.Count + _starting.Count >= Capacity) + { + throw new McpException( + $"This MCP server is already tracking {Capacity} live job operations. " + + "Wait for one of their tmux watchers to finish before starting another; " + + "cancellation retains its slot until then."); + } + + _starting.Add(job); + TrackLocked(reservation.Task); + } + + return StartCoreAsync( + server, + pane, + command, + suppressHistory, + job, + reservation, + cancellationToken); } /// Answers what a job is doing. @@ -84,57 +139,172 @@ await WriteTools /// No job has that handle. public JobInfo Get(string jobId) => Require(jobId).Describe(); - /// Answers every job this server still remembers. + /// Answers the jobs that fit within one response. + /// The complete UTF-8 response ceiling, including protocol reserve. /// The jobs, most recently started first. - public IReadOnlyList List() => - [.. _jobs.Values.OrderByDescending(job => job.StartedAt).Select(job => job.Describe())]; + /// The envelope cannot fit within the byte ceiling. + public JobList List(int maxBytes = ServerPolicy.DefaultMaxBytes) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxBytes); + StoredJob[] jobs; + lock (_gate) + { + jobs = [.. _jobs.Values.OrderByDescending(job => job.StartedAt)]; + } - /// Reads the cursor a job's output was last collected from. - /// The handle. - /// The cursor, or null when nothing has been collected yet. - public string? CursorFor(string jobId) => Require(jobId).Cursor; + JobInfo[] available = [.. jobs.Select(job => job.Describe())]; + JobList empty = new([], available.Length, available.Length > 0); + int envelopeBytes = Utf8JsonBudget.GetStructuredToolResultByteCount( + empty, + ToolJson.Options); + if (envelopeBytes > maxBytes) + { + throw new McpException( + $"The job-list response needs at least {envelopeBytes} UTF-8 bytes; " + + $"the configured ceiling is {maxBytes}."); + } - /// Records where a job's output has now been collected to. - /// The handle. - /// The new cursor. - public void Advance(string jobId, string cursor) => Require(jobId).Cursor = cursor; + var kept = new List(available.Length); + foreach (JobInfo candidate in available) + { + JobList proposed = new( + [.. kept, candidate], + available.Length, + kept.Count + 1 < available.Length); + if (Utf8JsonBudget.GetStructuredToolResultByteCount( + proposed, + ToolJson.Options) > maxBytes) + { + break; + } - /// Interrupts a job and stops watching it. - /// The pane it runs in. + kept.Add(candidate); + } + + return new JobList([.. kept], available.Length, kept.Count < available.Length); + } + + /// Interrupts a job on the endpoint that started it. /// The handle. + /// An optional assertion about the originating socket. /// Cancels sending the interrupt. /// The job. - /// - /// Interrupting means sending the pane a C-c, which is a request - /// rather than a guarantee: a program that ignores it keeps running, and - /// the job then ends up reported as cancelled while the pane is still busy. - /// The pane's current command is the honest check. - /// - public async Task CancelAsync( - Pane pane, + public Task CancelAsync( string jobId, + string? socketName = null, + CancellationToken cancellationToken = default) => + Resolve(jobId, socketName).CancelAsync(cancellationToken); + + internal StoredJob Resolve(string jobId, string? socketName) + { + StoredJob job = Require(jobId); + job.RequireSocket(socketName); + return job; + } + + private async Task StartCoreAsync( + Server server, + Pane pane, + string command, + bool suppressHistory, + StoredJob job, + TaskCompletionSource reservation, CancellationToken cancellationToken) { - ArgumentNullException.ThrowIfNull(pane); - Job job = Require(jobId); - if (job.State == JobState.Running) + using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, + _shutdown.Token); + bool dispatchAttempted = false; + bool published = false; + try { - await pane.SendKeysAsync( - new SendKeysRequest(text: "C-c", enter: false, literal: false), - cancellationToken) + PaneRead baseline = await PaneReader + .ReadVisibleAsync(pane, null, linked.Token) .ConfigureAwait(false); - job.Finish(JobState.Cancelled, null); + job.SetInitialCursor( + TailCursor.Build(pane, baseline.State, baseline.CursorRows).Encode()); + + dispatchAttempted = true; + await WriteTools + .SendRunPayloadAsync( + server, + pane, + command, + job.Token, + suppressHistory, + WriteTools.JobStatusMarkerLifetime, + linked.Token) + .ConfigureAwait(false); + + Publish(job, server, pane); + published = true; + return job.Describe(); } + catch (TmuxOperationCanceledException error) when ( + dispatchAttempted && error.CommandMayHaveExecuted) + { + Publish(job, server, pane); + published = true; + error.Data[RecoveryJobIdDataKey] = job.JobId; + throw; + } + catch (LibTmuxException error) when ( + dispatchAttempted && error.Dispatch != TmuxDispatchState.NotDispatched) + { + Publish(job, server, pane); + published = true; + error.Data[RecoveryJobIdDataKey] = job.JobId; + throw; + } + catch + { + if (!published) + { + RejectStart(job); + } + + throw; + } + finally + { + reservation.TrySetResult(); + } + } - return job.Describe(); + private void Publish(StoredJob job, Server server, Pane pane) + { + Task watcher = WatchAsync(server, pane, job); + job.Watcher = watcher; + lock (_gate) + { + if (!_starting.Remove(job)) + { + throw new InvalidOperationException("The job start reservation no longer exists."); + } + + _jobs.Add(job.JobId, job); + TrackLocked(watcher); + } + } + + private void RejectStart(StoredJob job) + { + job.TryFinish(JobState.Lost, null); + lock (_gate) + { + _starting.Remove(job); + } } - private Job Require(string jobId) + private StoredJob Require(string jobId) { ArgumentException.ThrowIfNullOrWhiteSpace(jobId); - if (_jobs.TryGetValue(jobId.Trim(), out Job? job)) + lock (_gate) { - return job; + if (_jobs.TryGetValue(jobId.Trim(), out StoredJob? job)) + { + return job; + } } throw new McpException( @@ -143,26 +313,53 @@ private Job Require(string jobId) + "running in its pane."); } - private void Forget() + private static void RequireOwnership(Server server, Pane pane) { - if (_jobs.Count < MaxRetained) + Server owner; + try { - return; + owner = pane.Server; + } + catch (IncompleteSnapshotException error) + { + throw new McpException( + "The job pane has no exact server ownership. Resolve it from the server " + + "that will start the command.", + error); } - // Only finished jobs are dropped: a running one still has a result - // somebody is waiting for. - foreach (Job stale in _jobs.Values - .Where(job => job.State != JobState.Running) - .OrderBy(job => job.EndedAt ?? job.StartedAt) - .Take(_jobs.Count - MaxRetained + 1)) + string? serverEndpoint = server.Connection?.GetEndpointFingerprint(); + string? paneEndpoint = owner.Connection?.GetEndpointFingerprint(); + if (serverEndpoint is null + || paneEndpoint is null + || !string.Equals(serverEndpoint, paneEndpoint, StringComparison.Ordinal) + || server.Generation != pane.Generation + || owner.Generation != pane.Generation) { - _jobs.TryRemove(stale.JobId, out _); + throw new McpException( + $"Pane {pane.Id} belongs to a different tmux endpoint or server generation. " + + "Resolve the pane from the same server passed to StartAsync."); + } + } + + private void ForgetLocked() + { + foreach (StoredJob stale in _jobs.Values + .Where(job => job.CanReleaseSlot) + .OrderBy(job => job.EndedAt ?? job.StartedAt)) + { + if (_jobs.Count + _starting.Count < Capacity) + { + break; + } + + _jobs.Remove(stale.JobId); } } - private async Task WatchAsync(Server server, Pane pane, Job job) + private async Task WatchAsync(Server server, Pane pane, StoredJob job) { + Exception? unexpected = null; try { await server.WaitForAsync( @@ -173,69 +370,359 @@ await server.WaitForAsync( int? status = await WriteTools .ReadStatusAsync(pane, job.Token, _shutdown.Token) .ConfigureAwait(false); - job.Finish(JobState.Exited, status); + job.TryFinish(JobState.Exited, status); } - catch (OperationCanceledException) + catch (OperationCanceledException) when (_shutdown.IsCancellationRequested) { - // Shutdown. The command is tmux's and carries on without us. + // The command belongs to tmux and survives this bookkeeping store. } catch (LibTmuxException) { - job.Finish(JobState.Lost, null); + job.TryFinish(JobState.Lost, null); + } + catch (Exception error) + { + unexpected = error; + job.TryFinish(JobState.Lost, null); } - if (_logger is not null) + if (_logger is not null && unexpected is not null) + { + Log.JobWatcherFailed(_logger, unexpected, job.JobId, job.PaneId); + } + + if (_logger is not null && job.State != JobState.Running) { Log.JobEnded(_logger, job.JobId, job.PaneId, job.State); } } - private sealed class Job( - string jobId, - string paneId, - string command, - WriteTools.RunToken token) + private void TrackLocked(Task operation) + { + foreach (Task completed in _operations + .Where(static tracked => tracked.IsCompleted) + .ToArray()) + { + RecordFailureLocked(completed); + _operations.Remove(completed); + } + + _operations.Add(operation); + } + + private void RecordFailureLocked(Task operation) { - internal string JobId { get; } = jobId; + if (_operationFailure is null && operation.IsFaulted) + { + _operationFailure = operation.Exception; + } + } - internal string PaneId { get; } = paneId; + private async Task DisposeCoreAsync() + { + Exception? failure; + lock (_gate) + { + failure = _operationFailure; + } - internal WriteTools.RunToken Token { get; } = token; + try + { + try + { + _shutdown.Cancel(); + } + catch (Exception error) + { + failure ??= error; + } - internal DateTimeOffset StartedAt { get; } = DateTimeOffset.UtcNow; + while (true) + { + Task[] pending; + lock (_gate) + { + pending = [.. _operations]; + } + + if (pending.Length == 0) + { + break; + } + + Task all = Task.WhenAll(pending); + try + { + await all.ConfigureAwait(false); + } + catch (Exception error) + { + failure ??= all.Exception ?? error; + } + + lock (_gate) + { + foreach (Task completed in pending) + { + RecordFailureLocked(completed); + _operations.Remove(completed); + } + + failure ??= _operationFailure; + } + } + } + finally + { + _shutdown.Dispose(); + } - internal DateTimeOffset? EndedAt { get; private set; } + if (failure is not null) + { + ExceptionDispatchInfo.Capture(failure).Throw(); + } + } + + internal sealed class StoredJob + { + private static readonly Progress Running = new(JobState.Running, null, null); + + private readonly Channel _cancelGate = Gate(); + private readonly Channel _outputGate = Gate(); + private readonly TaskCompletionSource _terminal = new( + TaskCreationOptions.RunContinuationsAsynchronously); + private readonly int _commandBytes; + private readonly string _endpointFingerprint; + private readonly string? _socketName; + private readonly string? _socketPath; + private Progress _progress = Running; + private string? _cursor; + + internal StoredJob( + string jobId, + Server server, + Pane pane, + int commandBytes, + WriteTools.RunToken token) + { + ArgumentException.ThrowIfNullOrWhiteSpace(jobId); + ArgumentNullException.ThrowIfNull(server); + ArgumentNullException.ThrowIfNull(pane); + ArgumentOutOfRangeException.ThrowIfNegative(commandBytes); + + JobId = jobId; + Server = server; + Pane = pane; + PaneId = pane.Id.ToString(); + ServerGeneration = pane.Generation; + _commandBytes = commandBytes; + TmuxConnection connection = pane.Server.Connection + ?? throw new IncompleteSnapshotException("connection", SnapshotDepth.Server); + _endpointFingerprint = connection.GetEndpointFingerprint(); + Token = token; + StartedAt = DateTimeOffset.UtcNow; + + (_socketName, _socketPath) = connection.ResolvedSocket; + } + + internal string JobId { get; } + + internal string PaneId { get; } + + internal Server Server { get; } + + internal Pane Pane { get; } + + internal ServerGeneration ServerGeneration { get; } + + internal WriteTools.RunToken Token { get; } - internal JobState State { get; private set; } = JobState.Running; + internal DateTimeOffset StartedAt { get; } - internal int? ExitStatus { get; private set; } + internal DateTimeOffset? EndedAt => Volatile.Read(ref _progress).EndedAt; - internal string? Cursor { get; set; } + internal JobState State => Volatile.Read(ref _progress).State; internal Task? Watcher { get; set; } - internal void Finish(JobState state, int? exitStatus) + internal Task Terminal => _terminal.Task; + + internal bool CanReleaseSlot => + State != JobState.Running && Watcher is { IsCompleted: true }; + + internal bool TryFinish(JobState state, int? exitStatus) + { + if (state == JobState.Running) + { + throw new ArgumentOutOfRangeException(nameof(state)); + } + + var terminal = new Progress(state, exitStatus, DateTimeOffset.UtcNow); + bool changed = ReferenceEquals( + Interlocked.CompareExchange(ref _progress, terminal, Running), + Running); + if (changed) + { + _terminal.TrySetResult(); + } + + return changed; + } + + internal async Task CancelAsync(CancellationToken cancellationToken) { - if (State != JobState.Running) + _ = await _cancelGate.Reader.ReadAsync(cancellationToken).ConfigureAwait(false); + try + { + if (State == JobState.Running) + { + await Pane.SendKeysAsync( + new SendKeysRequest(text: "C-c", enter: false, literal: false), + cancellationToken) + .ConfigureAwait(false); + TryFinish(JobState.Cancelled, null); + } + + return Describe(); + } + finally + { + _cancelGate.Writer.TryWrite(0); + } + } + + internal async ValueTask AcquireOutputAsync( + CancellationToken cancellationToken) + { + _ = await _outputGate.Reader.ReadAsync(cancellationToken).ConfigureAwait(false); + return new OutputLease(this); + } + + internal void SetInitialCursor(string cursor) + { + ArgumentException.ThrowIfNullOrWhiteSpace(cursor); + if (Interlocked.CompareExchange(ref _cursor, cursor, null) is not null) + { + throw new InvalidOperationException("The job output baseline was already set."); + } + } + + internal void RequireSocket(string? suppliedSocketName) + { + if (string.IsNullOrWhiteSpace(suppliedSocketName)) { return; } - State = state; - ExitStatus = exitStatus; - EndedAt = DateTimeOffset.UtcNow; + string supplied = suppliedSocketName.Trim(); + if (_socketName is not null + && string.Equals(supplied, _socketName, StringComparison.Ordinal)) + { + return; + } + + string endpoint = _socketPath is not null + ? $"socket path '{_socketPath}'" + : _socketName is not null + ? $"socket '{_socketName}'" + : "its recorded endpoint"; + throw new McpException( + $"Job '{JobId}' belongs to {endpoint}, not supplied socket '{supplied}'. " + + "Omit socketName to use the job's recorded endpoint."); } - internal JobInfo Describe() => new( - JobId: JobId, - PaneId: PaneId, - Command: command, - State: State, - ExitStatus: ExitStatus, - StartedAt: StartedAt, - EndedAt: EndedAt, - ElapsedSeconds: Math.Round( - ((EndedAt ?? DateTimeOffset.UtcNow) - StartedAt).TotalSeconds, - 3)); + internal JobInfo Describe() + { + Progress progress = Volatile.Read(ref _progress); + double elapsedSeconds = Math.Round( + ((progress.EndedAt ?? DateTimeOffset.UtcNow) - StartedAt).TotalSeconds, + 3); + return CreateDescription(progress, elapsedSeconds); + } + + internal void RequireToolResultsFit(int maxBytes) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxBytes); + string endpoint = _socketPath ?? _socketName ?? string.Empty; + int endpointBytes = Encoding.UTF8.GetByteCount(endpoint); + if (endpointBytes > maxBytes) + { + throw new McpException( + $"The job handle response cannot fit because its endpoint identity is " + + $"{endpointBytes} UTF-8 bytes; the configured ceiling is {maxBytes}. " + + $"Use a shorter socket name or raise {ServerPolicy.MaxBytesVariable} " + + "and restart the MCP server."); + } + + JobInfo worstCase = CreateDescription( + new Progress(JobState.Cancelled, int.MinValue, DateTimeOffset.MaxValue), + double.MaxValue); + int requiredBytes = Utf8JsonBudget.GetStructuredToolResultByteCount( + worstCase, + ToolJson.Options); + if (requiredBytes > maxBytes) + { + throw new McpException( + $"The job handle response needs at least {requiredBytes} UTF-8 bytes; " + + $"the configured ceiling is {maxBytes}. Use a shorter socket name or " + + $"raise {ServerPolicy.MaxBytesVariable} and restart the MCP server."); + } + } + + private JobInfo CreateDescription(Progress progress, double elapsedSeconds) => + new( + JobId: JobId, + PaneId: PaneId, + SocketName: _socketName, + SocketPath: _socketPath, + EndpointFingerprint: _endpointFingerprint, + ServerGeneration: ServerGeneration, + CommandBytes: _commandBytes, + State: progress.State, + ExitStatus: progress.ExitStatus, + StartedAt: StartedAt, + EndedAt: progress.EndedAt, + ElapsedSeconds: elapsedSeconds); + + private sealed record Progress( + JobState State, + int? ExitStatus, + DateTimeOffset? EndedAt); + + private static Channel Gate() + { + Channel gate = Channel.CreateBounded(new BoundedChannelOptions(1) + { + FullMode = BoundedChannelFullMode.Wait, + SingleReader = false, + SingleWriter = false, + }); + gate.Writer.TryWrite(0); + return gate; + } + + internal sealed class OutputLease : IDisposable + { + private StoredJob? _job; + + internal OutputLease(StoredJob job) => _job = job; + + internal string? Cursor => RequireJob()._cursor; + + internal void Advance(string cursor) + { + ArgumentException.ThrowIfNullOrWhiteSpace(cursor); + RequireJob()._cursor = cursor; + } + + public void Dispose() + { + StoredJob? job = Interlocked.Exchange(ref _job, null); + job?._outputGate.Writer.TryWrite(0); + } + + private StoredJob RequireJob() => + _job ?? throw new ObjectDisposedException(nameof(OutputLease)); + } } } diff --git a/src/LibTmux.Mcp/McpServerComposition.cs b/src/LibTmux.Mcp/McpServerComposition.cs index 044615f..d13caef 100644 --- a/src/LibTmux.Mcp/McpServerComposition.cs +++ b/src/LibTmux.Mcp/McpServerComposition.cs @@ -1,6 +1,7 @@ using System.Runtime.Versioning; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using ModelContextProtocol.Extensions.Tasks; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; @@ -47,6 +48,8 @@ public static IMcpServerBuilder Add( services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + var taskStore = new BoundedMcpTaskStore(); + services.AddSingleton(_ => new SubscriptionAdmission()); IMcpServerBuilder builder = services .AddMcpServer(options => @@ -58,10 +61,15 @@ public static IMcpServerBuilder Add( }; options.ServerInstructions = ServerInstructions.Compose(policy, callerPaneId); }) - .WithTools() + .WithTools(ToolJson.Options) .WithResources() .WithPrompts() - .WithRequestFilters(filters => filters.AddCallToolFilter(ToolFailureFilter.Create())) + .WithRequestFilters(filters => + { + filters.AddCallToolFilter(next => + ToolResponseBudgetFilter.Create(policy)(ToolFailureFilter.Create()(next))); + filters.AddReadResourceFilter(ResourceResponseBudgetFilter.Create(policy)); + }) // A subscription is what turns the hierarchy from something a // client re-reads on a timer into something that tells it when to. @@ -76,6 +84,7 @@ public static IMcpServerBuilder Add( await scope.GetRequiredService() .SubscribeAsync( uri, + notify, async changed => { foreach (string each in changed) @@ -101,37 +110,36 @@ await scope.GetRequiredService() && context.Services is IServiceProvider scope) { await scope.GetRequiredService() - .UnsubscribeAsync(uri) + .UnsubscribeAsync(uri, context.Server) .ConfigureAwait(false); } return new EmptyResult(); }) - // The revision that replaced resources/subscribe answers listen - // itself, granting the subscription without telling the - // application — so a client on a current revision would subscribe - // and hear nothing. Owning the stream is what closes that. + // The current revision grants listen without notifying the application; + // owning the stream is what starts the watcher that emits its events. .WithSubscriptionsListenHandler(SubscriptionStream.Create()) - // The protocol's own answer to a call that waits. A client that - // declares the extension gets a handle back at once and collects - // later; one that has not keeps the blocking call it had. + // Task-capable clients may collect a wait later; other clients block. .WithTasks( - new InMemoryMcpTaskStore(), + taskStore, tasks => tasks.ExecutionModeSelector = TaskCapableTools.Select); + services.AddSingleton>( + new BoundedMcpTaskCancellationOptions(taskStore)); + // Registration, not filtering. A tool the operator's tier does not // allow never reaches the model's list, so it cannot be called by name, // guessed at, or argued for. if (policy.Allows(SafetyTier.Mutating)) { - builder.WithTools(); + builder.WithTools(ToolJson.Options); } if (policy.Allows(SafetyTier.Destructive)) { - builder.WithTools(); + builder.WithTools(ToolJson.Options); } return builder; diff --git a/src/LibTmux.Mcp/Policy/TaskCapableTools.cs b/src/LibTmux.Mcp/Policy/TaskCapableTools.cs index e33fa65..31bcefd 100644 --- a/src/LibTmux.Mcp/Policy/TaskCapableTools.cs +++ b/src/LibTmux.Mcp/Policy/TaskCapableTools.cs @@ -30,7 +30,6 @@ internal static class TaskCapableTools /// The tools whose whole job is to wait for something. private static readonly HashSet Waiting = new(StringComparer.Ordinal) { - "tmux_run", "tmux_wait_for_text", "tmux_wait_for_channel", "tmux_job", diff --git a/src/LibTmux.Mcp/Prompts/RecipePrompts.cs b/src/LibTmux.Mcp/Prompts/RecipePrompts.cs index d02a0b2..7463fff 100644 --- a/src/LibTmux.Mcp/Prompts/RecipePrompts.cs +++ b/src/LibTmux.Mcp/Prompts/RecipePrompts.cs @@ -23,19 +23,19 @@ public static string RunAndReport( [Description("The shell command to run.")] string command, [Description("The pane id to run it in, such as %1. Optional.")] string? paneId = null) { - string target = paneId is null ? string.Empty : $", pane_id=\"{paneId}\""; + string target = paneId is null ? string.Empty : $", paneId=\"{paneId}\""; return $""" Run this in tmux and report what happened: tmux_run(command={command.Replace("\"", "\\\"", StringComparison.Ordinal)}{target}) - Read exit_status, timed_out and output from the result. + Read exitStatus, timedOut and output from the result. - - exit_status is the shell's real status, not a guess from the screen. + - exitStatus is the shell's real status, not a guess from the screen. Trust it over anything the output appears to say. - - If timed_out is true the command is STILL RUNNING in the pane. Do not - re-run it — that would start a second copy. Either call tmux_run again - with a longer timeout_seconds, or watch it with tmux_tail_pane. + - If timedOut is true the command MAY still be running in the pane. + Never re-run it: inspect with tmux_snapshot_pane or continue watching + with tmux_tail_pane. A retry could start a second copy. - If this may take minutes, stop and use tmux_start_job instead, then collect it with tmux_job. @@ -53,15 +53,15 @@ public static string DiagnosePane( $""" Work out what is wrong in tmux pane {paneId}. Change nothing yet. - 1. tmux_snapshot_pane(pane_id="{paneId}") — content, cursor, size and the + 1. tmux_snapshot_pane(paneId="{paneId}") — content, cursor, size and the running command in one call. - 2. Read pane.current_command. A shell means whatever ran has finished; a + 2. Read pane.currentCommand. A shell means whatever ran has finished; a program name means it is still going and may simply be slow. 3. If pane.dead is true the program exited: the screen shows its last output. tmux_respawn_pane restarts it. - 4. If content_truncated is set, call again with a larger max_lines — the + 4. If content.truncated is true, call again with a larger maxLines — the interesting line may be above what you were shown. - 5. If alternate_screen is true, a full-screen program owns the pane and + 5. If alternateScreen is true, a full-screen program owns the pane and scrollback holds what was there BEFORE it started, not its output. 6. To watch it change, call tmux_tail_pane and keep the cursor. Do not re-capture the whole pane repeatedly. @@ -83,9 +83,9 @@ public static string BuildWorkspace( 1. tmux_create_session(name="{sessionName}", width=200, height=50). Give a size: nothing will attach, and a session with no client stays - at 80x24, which wraps most output. Keep the returned pane_id as A. - 2. tmux_split_pane(pane_id=A, direction="Below") — keep its pane_id as B. - 3. tmux_split_pane(pane_id=B, direction="Right") — keep its pane_id as C. + at 80x24, which wraps most output. Keep the returned paneId as A. + 2. tmux_split_pane(paneId=A, direction="Below") — keep its paneId as B. + 3. tmux_split_pane(paneId=B, direction="Right") — keep its paneId as C. 4. Label them so a human can tell them apart: tmux_set_pane_title on A, B and C. 5. Start what belongs in each with tmux_send_keys. No wait is needed @@ -106,18 +106,18 @@ public static string InterruptPane( $""" Stop whatever is running in tmux pane {paneId} and confirm it stopped. - 1. tmux_list_panes and note current_command for {paneId}. That is the + 1. tmux_list_panes and note currentCommand for {paneId}. That is the thing you are trying to change; comparing it before and after is the only reliable check. - 2. tmux_send_keys(pane_id="{paneId}", keys="C-c", literal=false) — + 2. tmux_send_keys(paneId="{paneId}", keys="C-c", literal=false) — tmux reads C-c as an interrupt only when literal is false. - 3. Read current_command again. Back to a shell means it worked. + 3. Read currentCommand again. Back to a shell means it worked. Do not wait on a prompt pattern to decide this: a prompt glyph you did not predict reads as failure, and the terminal echoes ^C whenever the signal is DELIVERED, whether or not the program died. - If current_command is unchanged, the program is ignoring the interrupt. + If currentCommand is unchanged, the program is ignoring the interrupt. Stop and ask what to do. Do not escalate to C-\ or kill on your own — SIGQUIT can dump core, and killing the pane destroys its scrollback. """; diff --git a/src/LibTmux.Mcp/README.md b/src/LibTmux.Mcp/README.md index 2a974b4..afb66b5 100644 --- a/src/LibTmux.Mcp/README.md +++ b/src/LibTmux.Mcp/README.md @@ -49,163 +49,19 @@ build and learn whether it passed. Watch a dev server come up. Find which of eleven panes is showing the stack trace. Lay out a workspace and drive it. The design goal is that an assistant never gets **stuck** and never **wastes -context**: no tool polls, no tool returns unbounded output, and no failure -comes back as "an error occurred". +context**: waits are event-driven when control mode is available and use a +bounded polling fallback otherwise; no tool returns unbounded output, and no +failure comes back as "an error occurred". -## Waiting, not polling +## How it behaves -The tool an assistant reaches for first is usually the wrong one. These four -cover the cases, and the server's instructions steer between them: +What to wait on rather than poll, what bounds every result, which tools each +safety tier registers, and what a resource subscription holds open: +[how the server behaves](https://github.com/libtmux/libtmux-dotnet/blob/master/docs/mcp/README.md). -| You want | Use | Why | -|---|---|---| -| Run a command, know if it worked | `tmux_run` | Waits, returns the shell's **real exit status** | -| The same, but it takes minutes | `tmux_start_job` → `tmux_job` | Returns a handle at once; collect later | -| Output you did **not** start | `tmux_wait_for_text` | Wakes on the pane printing, not on a timer | -| Watch a pane across turns | `tmux_tail_pane` | Answers only what is **new** since its cursor | - -A client that speaks the [Tasks extension](https://modelcontextprotocol.io) can -start `tmux_run`, `tmux_wait_for_text`, `tmux_wait_for_channel` or `tmux_job` -as a task and collect the result later — the protocol's own version of what -`tmux_start_job` does by hand. It is offered, never required, so a client -without it keeps the blocking call it had. A listing stays a plain call: making -it a task would cost a round trip to collect an answer that was already there. - -Nothing here sleeps in a loop. A wait subscribes to tmux's own -[control mode](https://github.com/tmux/tmux/wiki/Control-Mode), so tmux reports -pane output as it happens and the wait is released the moment there is -something to look at. - -Two details make that safe. The control client attaches with `ignore-size` -(tmux 3.2+), so it never drags the window down to its own size; and it is -reference counted per session, so it exists only while a wait is running. What -arrives on that stream is the pane's raw terminal bytes, so it is used as a -signal and never as content — the text you get always comes from a capture, -which is what tmux has already rendered. - -If control mode cannot start, waits fall back to polling. Cost changes; -answers do not. - -The tools are ordinary classes, so an application that already hosts an -assistant can run one directly instead of launching a second process: - - -```csharp -using LibTmux; -using LibTmux.Mcp; - -WriteTools tools = McpTools.Writing(server); - -RunResult result = await tools.RunAsync( - "test -f /etc/hostname && echo present", - pane.Id.ToString(), - timeoutSeconds: 20, - cancellationToken: ct); - -// The status comes from the shell, not from reading the screen, so a -// command that prints nothing still says what it did. -Console.WriteLine($"exit {result.ExitStatus}, timed out: {result.TimedOut}"); -``` - - -## Nothing returns unbounded output - -Every content-bearing result is cut to a budget, keeps the **newest** lines, -and says what it dropped: - -```json -{ - "lines": ["...", "make: *** [build] Error 1"], - "truncated": true, - "droppedLines": 407, - "droppedBytes": 2034 -} -``` - -A reader that cannot see lines are missing concludes the pane never printed -them: - - -```csharp -using LibTmux; -using LibTmux.Mcp; - -ReadTools reading = McpTools.Reading(server); - -CaptureResult captured = await reading.CapturePaneAsync( - pane.Id.ToString(), - includeHistory: true, - maxLines: 5, - cancellationToken: ct); - -// The newest line says what happened, so the budget keeps the end and -// reports what was dropped — silence would look like nothing printed. -Console.WriteLine(captured.Content.ToDisplayString()); -Console.WriteLine($"dropped {captured.Content.DroppedLines} earlier lines"); -``` - - -`tmux_tail_pane` avoids the problem instead of managing it. Pass its cursor -back and the tenth read of a busy pane costs what the first did: - - -```csharp -using LibTmux; -using LibTmux.Mcp; - -ReadTools reading = McpTools.Reading(server); -string paneId = pane.Id.ToString(); - -// A first call establishes a position and returns nothing, so watching -// a pane never starts by paying for a screenful nobody asked for. -TailResult first = await reading.TailPaneAsync(paneId, cancellationToken: ct); - -await reading.WaitForTextAsync( - paneId, - patterns: null, - timeoutSeconds: 5, - cancellationToken: ct); - -TailResult next = await reading.TailPaneAsync(paneId, first.Cursor, cancellationToken: ct); -Console.WriteLine($"{next.Content.Lines.Count} new lines"); -``` - - -To offer these beside your own tools rather than as a separate process: - - -```csharp -using LibTmux; -using LibTmux.Mcp; -using Microsoft.Extensions.DependencyInjection; - -ServiceCollection services = new(); -services.AddLogging(); - -// Registers the tools, resources and prompts, and gates them on the -// tier. Choose the transport yourself — this returns the builder. -McpServerComposition.Add( - services, - new ServerPolicy { Tier = SafetyTier.ReadOnly }, - server.ConnectionOptions, - callerPaneId: null); -``` - - -## Three tiers, and a tool you do not have cannot be called - -`LIBTMUX_SAFETY` picks how much of tmux is exposed. Tools above the tier are -**not registered**, so they never reach the model's list: - -| `LIBTMUX_SAFETY` | Offers | Example | -|---|---|---| -| `readonly` | Reading only | `tmux_capture_pane`, `tmux_search_panes` | -| `mutating` *(default)* | Reading, creating, changing | `tmux_run`, `tmux_split_pane` | -| `destructive` | Everything, including removal | `tmux_kill_session` | - -A tier bounds the tools, not the intent: an assistant denied `tmux_kill_session` -can still type `exit` into a pane with `tmux_send_keys`. Use `readonly` when -that distinction matters. +The surface itself is generated by asking the server what it advertises, so it +cannot describe a tool that is not there: +[the tool reference](https://github.com/libtmux/libtmux-dotnet/blob/master/docs/mcp/tools.md). ## Configuration @@ -218,38 +74,13 @@ that distinction matters. | `LIBTMUX_MCP_MAX_LINES` | `500` | Default line budget | | `LIBTMUX_MCP_MAX_BYTES` | `128000` | Byte budget per result | +`LIBTMUX_SAFETY` picks how much of tmux is exposed: a tool above the tier is +not registered, so it never reaches the model's list. + An unreadable value is clamped and logged rather than refused — except `LIBTMUX_SAFETY`, where anything unrecognised falls to `readonly`, because a typo must never widen what the server offers. -## Resources and prompts - -Six resources expose the hierarchy without a tool call — `tmux://hierarchy`, -`tmux://sessions`, `tmux://sessions/{id}/panes`, `tmux://panes/{id}/content`, -`tmux://self`, `tmux://servers`. A client can pin or refresh one on its own -initiative; one nobody reads costs nothing. - -A client that subscribes to `tmux://hierarchy`, `tmux://sessions` or -`tmux://servers` is told when they change, from tmux's own notifications rather -than from a timer — so a view goes stale only when something actually moved. -That watcher holds a second control client, started on the first subscription -and stopped with the last, attached with `no-output` because it wants the -hierarchy and not every byte a pane prints. - -Both subscription shapes are served: `resources/subscribe`, and the -`subscriptions/listen` stream that replaced it in the 2026-07-28 revision. -The newer one is answered by this server rather than by the SDK's built-in -handling, because that grants the subscription without telling the application -— which would leave a client subscribed to a watcher nobody started, waiting -for events that never come. - -Long calls report progress while they run, so a wait shows as running rather -than hung. It costs nothing when the client asks for none. - -Four prompts package workflows that are easy to get wrong: -`tmux_run_and_report`, `tmux_diagnose_pane`, `tmux_build_workspace`, -`tmux_interrupt_pane`. - ## Which pane am I in? When the client that launched this server was itself inside tmux, the server @@ -268,13 +99,6 @@ session is torn down while the reply is still being written and you get no bytes back. A real client holds the stream open for the session, which is what the pause imitates. -## Standard output belongs to the protocol - -Every log line goes to standard error, and the default level is `Warning` so a -working server is quiet. A message written to the wrong stream does not produce -a stray log line — it corrupts the protocol and the client disconnects. That is -worth knowing if you wrap this in something of your own. - ## Which tmux it drives Whatever `tmux` resolves to on the path, or the binary `LIBTMUX_TMUX` names. diff --git a/src/LibTmux.Mcp/Results/BoundedText.cs b/src/LibTmux.Mcp/Results/BoundedText.cs index f02b750..415e8ac 100644 --- a/src/LibTmux.Mcp/Results/BoundedText.cs +++ b/src/LibTmux.Mcp/Results/BoundedText.cs @@ -5,8 +5,8 @@ namespace LibTmux.Mcp; /// Terminal text cut to fit a budget, with what was cut reported. /// The text that fits, oldest first. /// Whether anything was dropped to make it fit. -/// How many lines were dropped from the start. -/// How many UTF-8 bytes were dropped from the start. +/// How many complete lines were dropped from the start. +/// The exact number of UTF-8 bytes dropped from the start. /// /// Dropping is always from the oldest end. A terminal's newest line is the one /// that says what happened, so a budget that discarded it would answer the @@ -33,41 +33,61 @@ public static BoundedText Fit(IReadOnlyList lines, int? maxLines, int ma ArgumentNullException.ThrowIfNull(lines); ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxBytes); - int firstKept = 0; + if (lines.Count == 0) + { + return Empty; + } + + int firstEligible = 0; if (maxLines is int lineBudget) { - firstKept = Math.Max(0, lines.Count - Math.Max(lineBudget, 0)); + firstEligible = Math.Max(0, lines.Count - Math.Max(lineBudget, 0)); } // A joined capture can hold one logical line far wider than the pane, - // so the byte budget has to be applied after the line budget rather - // than assumed to follow from it. The newest line is kept even when it - // alone overruns: answering with nothing is never the better answer. - int bytes = 0; + // so apply the byte ceiling to the final newline-joined representation. + int retainedBytes = 0; int start = lines.Count; - for (int index = lines.Count - 1; index >= firstKept; index--) + string? clippedFirstLine = null; + for (int index = lines.Count - 1; index >= firstEligible; index--) { - int lineBytes = Encoding.UTF8.GetByteCount(lines[index]) + 1; - if (start < lines.Count && bytes + lineBytes > maxBytes) + int separatorBytes = start < lines.Count ? 1 : 0; + int lineBytes = Encoding.UTF8.GetByteCount(lines[index]); + if (lineBytes <= maxBytes - retainedBytes - separatorBytes) { - break; + retainedBytes = checked(retainedBytes + separatorBytes + lineBytes); + start = index; + continue; } - bytes += lineBytes; - start = index; + int remaining = maxBytes - retainedBytes - separatorBytes; + if (remaining > 0) + { + string suffix = Utf8Suffix(lines[index], remaining); + if (suffix.Length > 0) + { + clippedFirstLine = suffix; + retainedBytes = checked( + retainedBytes + + separatorBytes + + Encoding.UTF8.GetByteCount(clippedFirstLine)); + start = index; + } + } + + break; } - start = Math.Max(firstKept, start); - if (start <= 0) + int droppedLines = start; + bool clipped = clippedFirstLine is not null; + bool truncated = droppedLines > 0 || clipped; + if (!truncated) { return new BoundedText(lines, false, 0, 0); } - int droppedBytes = 0; - for (int index = 0; index < start; index++) - { - droppedBytes += Encoding.UTF8.GetByteCount(lines[index]) + 1; - } + int totalBytes = JoinedUtf8ByteCount(lines); + int droppedBytes = checked(totalBytes - retainedBytes); string[] kept = new string[lines.Count - start]; for (int index = 0; index < kept.Length; index++) @@ -75,7 +95,12 @@ public static BoundedText Fit(IReadOnlyList lines, int? maxLines, int ma kept[index] = lines[start + index]; } - return new BoundedText(kept, true, start, droppedBytes); + if (clipped) + { + kept[0] = clippedFirstLine!; + } + + return new BoundedText(kept, true, droppedLines, droppedBytes); } /// Renders the text as one block, noting any loss at the top. @@ -88,7 +113,50 @@ public string ToDisplayString() { string body = string.Join('\n', Lines); return Truncated - ? $"[{DroppedLines} earlier lines ({DroppedBytes} bytes) omitted to fit the budget]\n{body}" + ? $"[{DroppedLines} complete earlier lines and {DroppedBytes} UTF-8 bytes " + + $"omitted from the start to fit the budget]\n{body}" : body; } + + private static int JoinedUtf8ByteCount(IReadOnlyList lines) + { + int bytes = 0; + for (int index = 0; index < lines.Count; index++) + { + bytes = checked(bytes + Encoding.UTF8.GetByteCount(lines[index])); + if (index > 0) + { + bytes = checked(bytes + 1); + } + } + + return bytes; + } + + private static string Utf8Suffix(string line, int byteBudget) + { + int start = line.Length; + int bytes = 0; + while (start > 0) + { + int previous = start - 1; + if (previous > 0 + && char.IsLowSurrogate(line[previous]) + && char.IsHighSurrogate(line[previous - 1])) + { + previous--; + } + + int runeBytes = Encoding.UTF8.GetByteCount(line.AsSpan(previous, start - previous)); + if (runeBytes > byteBudget - bytes) + { + break; + } + + bytes += runeBytes; + start = previous; + } + + return line[start..]; + } } diff --git a/src/LibTmux.Mcp/Results/JobResults.cs b/src/LibTmux.Mcp/Results/JobResults.cs index 6e844bf..280d3ae 100644 --- a/src/LibTmux.Mcp/Results/JobResults.cs +++ b/src/LibTmux.Mcp/Results/JobResults.cs @@ -19,7 +19,11 @@ public enum JobState /// A command that outlives the call that started it. /// The handle to ask about it with. /// The pane it runs in. -/// What was run. +/// The named socket it runs on, or null for a socket path. +/// The socket path it runs on, or null for a named socket. +/// The exact socket endpoint, as a stable opaque digest. +/// The tmux daemon that owned the pane when it started. +/// The UTF-8 size of the command, whose text is not retained. /// Where it has got to. /// What it exited with, once it has. /// When it was started. @@ -29,17 +33,32 @@ public enum JobState /// Starting one costs a single call and returns at once, so a command that /// takes ten minutes does not spend ten minutes of the model's turn. The pane /// keeps running it either way; the job is what makes the result collectable. +/// Command text is deliberately absent because shell commands commonly contain +/// credentials; the handle and endpoint are enough to collect or cancel it. /// public sealed record JobInfo( string JobId, string PaneId, - string Command, + string? SocketName, + string? SocketPath, + string EndpointFingerprint, + ServerGeneration ServerGeneration, + int CommandBytes, JobState State, int? ExitStatus, DateTimeOffset StartedAt, DateTimeOffset? EndedAt, double ElapsedSeconds); +/// A bounded inventory of background jobs. +/// The jobs that fit, newest first. +/// How many jobs the server remembers. +/// Whether older jobs were omitted to fit the response budget. +public sealed record JobList( + IReadOnlyList Jobs, + int TotalJobs, + bool Truncated); + /// A background command, and whatever it has printed since last asked. /// Where the command has got to. /// diff --git a/src/LibTmux.Mcp/Results/Observations.cs b/src/LibTmux.Mcp/Results/Observations.cs index 26af46c..31ee651 100644 --- a/src/LibTmux.Mcp/Results/Observations.cs +++ b/src/LibTmux.Mcp/Results/Observations.cs @@ -97,10 +97,16 @@ public sealed record WaitResult( /// /// The shell's exit status, or null when the command did not finish in time. /// -/// Whether the wait ran out before the command finished. +/// +/// Whether waiting stopped before completion. The shell command may still be +/// running; inspect the pane and do not retry it. Use tmux_start_job when +/// work must remain recoverable after the wait. +/// /// What the command printed, within the budget. /// How long it took. /// The timeout actually used, after the server's ceiling. +/// Whether scrollback dropped output before it could be read. +/// Whether the pre-command output position could no longer be found. /// /// The command runs in a subshell, so a cd or an export in it /// does not survive into the next call. @@ -111,7 +117,9 @@ public sealed record RunResult( bool TimedOut, BoundedText Output, double ElapsedSeconds, - double EffectiveTimeoutSeconds); + double EffectiveTimeoutSeconds, + bool LinesMissed = false, + bool AnchorLost = false); /// One pane whose text matched a search. /// The pane that matched. diff --git a/src/LibTmux.Mcp/Results/SearchResultBudget.cs b/src/LibTmux.Mcp/Results/SearchResultBudget.cs new file mode 100644 index 0000000..e95677e --- /dev/null +++ b/src/LibTmux.Mcp/Results/SearchResultBudget.cs @@ -0,0 +1,164 @@ +using ModelContextProtocol; + +namespace LibTmux.Mcp; + +/// Builds one search result within global line and byte ceilings. +internal sealed class SearchResultBudget +{ + private readonly string _pattern; + private readonly int _maximumPanes; + private readonly int _maxMatches; + private readonly int _maxBytes; + private readonly List _panes = []; + private int _bytes; + private int _matches; + + /// Initializes a result budget, reserving its fixed metadata first. + internal SearchResultBudget( + string pattern, + int maximumPanes, + int maxMatches, + int maxBytes) + { + ArgumentNullException.ThrowIfNull(pattern); + ArgumentOutOfRangeException.ThrowIfNegative(maximumPanes); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxMatches); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxBytes); + _pattern = pattern; + _maximumPanes = maximumPanes; + _maxMatches = maxMatches; + _maxBytes = maxBytes; + + _bytes = Utf8JsonBudget.GetStructuredToolResultByteCount( + new SearchResult( + _pattern, + _maximumPanes, + [], + Truncated: false), + ToolJson.Options); + if (_bytes > _maxBytes) + { + throw new McpException( + $"The search pattern alone exceeds this server's {maxBytes} UTF-8 byte " + + $"limit. Use a shorter pattern or raise {ServerPolicy.MaxBytesVariable}."); + } + } + + /// Adds a matching line or explains why it cannot be added. + internal SearchMatchBudgetOutcome TryAdd( + string paneId, + string windowId, + string sessionId, + List paneMatches, + MatchedLine match) + { + ArgumentNullException.ThrowIfNull(paneId); + ArgumentNullException.ThrowIfNull(windowId); + ArgumentNullException.ThrowIfNull(sessionId); + ArgumentNullException.ThrowIfNull(paneMatches); + ArgumentNullException.ThrowIfNull(match); + if (_matches >= _maxMatches) + { + return SearchMatchBudgetOutcome.GlobalLimit; + } + + int remainingBytes = _maxBytes - _bytes; + int minimumCurrentBytes = AddedBytes( + paneId, + windowId, + sessionId, + paneMatches, + new MatchedLine(0, string.Empty)); + if (minimumCurrentBytes > remainingBytes) + { + bool anotherPaneNeedsComma = _panes.Count > 0 || paneMatches.Count > 0; + int minimumFuturePaneBytes = checked( + Utf8JsonBudget.GetStructuredJsonFragmentByteCount( + new PaneMatch("%0", "@0", "$0", []), + ToolJson.Options) + + Utf8JsonBudget.GetStructuredJsonFragmentByteCount( + new MatchedLine(0, string.Empty), + ToolJson.Options) + + (anotherPaneNeedsComma ? 2 : 0)); + return minimumFuturePaneBytes > remainingBytes + ? SearchMatchBudgetOutcome.GlobalLimit + : SearchMatchBudgetOutcome.PaneCannotFit; + } + + if (match.Text.Length > remainingBytes / 2 + || System.Text.Encoding.UTF8.GetByteCount(match.Text) > remainingBytes / 2 + || Utf8JsonBudget.GetStructuredJsonStringContentByteCount( + match.Text, + ToolJson.Options) > remainingBytes) + { + return SearchMatchBudgetOutcome.ItemTooLarge; + } + + int addedBytes = AddedBytes(paneId, windowId, sessionId, paneMatches, match); + if (addedBytes > remainingBytes) + { + return SearchMatchBudgetOutcome.ItemTooLarge; + } + + _bytes += addedBytes; + paneMatches.Add(match); + _matches++; + return SearchMatchBudgetOutcome.Added; + } + + /// Commits the matches accumulated for one pane. + internal void Commit( + string paneId, + string windowId, + string sessionId, + IReadOnlyList paneMatches) + { + ArgumentNullException.ThrowIfNull(paneMatches); + if (paneMatches.Count > 0) + { + _panes.Add(new PaneMatch( + paneId, + windowId, + sessionId, + paneMatches.ToArray())); + } + } + + /// Builds the bounded result. + internal SearchResult Build(int panesSearched, bool truncated) => + new(_pattern, panesSearched, _panes, truncated); + + private int AddedBytes( + string paneId, + string windowId, + string sessionId, + List paneMatches, + MatchedLine match) => + paneMatches.Count == 0 + ? checked( + Utf8JsonBudget.GetStructuredJsonFragmentByteCount( + new PaneMatch(paneId, windowId, sessionId, []), + ToolJson.Options) + + Utf8JsonBudget.GetStructuredJsonFragmentByteCount(match, ToolJson.Options) + + (_panes.Count > 0 ? 2 : 0)) + : checked( + Utf8JsonBudget.GetStructuredJsonFragmentByteCount(match, ToolJson.Options) + + 2); + +} + +/// Why a search match was accepted or refused. +internal enum SearchMatchBudgetOutcome +{ + /// The match was added. + Added = 0, + + /// This match is too large, but a smaller one can still fit. + ItemTooLarge = 1, + + /// This pane's metadata cannot fit, but a smaller endpoint may. + PaneCannotFit = 2, + + /// No further match can fit the global line or byte budget. + GlobalLimit = 3, +} diff --git a/src/LibTmux.Mcp/Results/StructuredTextResultBudget.cs b/src/LibTmux.Mcp/Results/StructuredTextResultBudget.cs new file mode 100644 index 0000000..9e1210d --- /dev/null +++ b/src/LibTmux.Mcp/Results/StructuredTextResultBudget.cs @@ -0,0 +1,263 @@ +using System.Text; +using ModelContextProtocol; + +namespace LibTmux.Mcp; + +/// Fits terminal text inside a complete structured MCP result. +internal static class StructuredTextResultBudget +{ + private const int MaximumCorrectionSteps = 5; + private const int CorrectionSlackBytes = 64; + + /// Keeps the newest text whose complete structured result fits. + internal static T Fit( + IReadOnlyList lines, + int? maxLines, + int maxBytes, + Func createResult, + string resultName) + { + ArgumentNullException.ThrowIfNull(lines); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxBytes); + ArgumentNullException.ThrowIfNull(createResult); + ArgumentException.ThrowIfNullOrWhiteSpace(resultName); + + BoundedText fullText = BoundedText.Fit(lines, maxLines, maxBytes); + T full = createResult(fullText); + int fullBytes = Size(fullText, createResult); + if (fullBytes <= maxBytes) + { + return full; + } + + BoundedText emptyText = DropAll(fullText); + T minimum = createResult(emptyText); + int minimumBytes = Size(emptyText, createResult); + if (minimumBytes > maxBytes) + { + throw new McpException( + $"The {resultName} metadata cannot fit this server's {maxBytes} UTF-8 " + + $"byte limit. Shorten the request or target metadata, or raise " + + $"{ServerPolicy.MaxBytesVariable} and restart the MCP server."); + } + + int retainedBytes = JoinedUtf8ByteCount(fullText.Lines); + if (retainedBytes == 0) + { + return minimum; + } + + int candidateBytes = EstimateBudget( + retainedBytes, + maxBytes - minimumBytes, + fullBytes - minimumBytes, + retainedBytes); + candidateBytes = Math.Min(candidateBytes, retainedBytes - 1); + T best = minimum; + int bestBytes = minimumBytes; + int bestBudget = 0; + bool foundContent = false; + for (int step = 0; step < MaximumCorrectionSteps && candidateBytes > 0; step++) + { + BoundedText candidateText = Merge( + fullText, + BoundedText.Fit(fullText.Lines, null, candidateBytes)); + T candidate = createResult(candidateText); + int candidateSize = Size(candidateText, createResult); + if (candidateSize <= maxBytes) + { + best = candidate; + bestBytes = candidateSize; + bestBudget = candidateBytes; + foundContent |= candidateText.Lines.Count > 0; + int expanded = EstimateBudget( + candidateBytes, + maxBytes - minimumBytes, + candidateSize - minimumBytes, + retainedBytes - 1); + expanded = Math.Min(expanded, retainedBytes - 1); + if (expanded <= candidateBytes) + { + break; + } + + candidateBytes = expanded; + continue; + } + + if (candidateBytes == 1) + { + break; + } + + int corrected = EstimateBudget( + candidateBytes, + maxBytes - minimumBytes, + candidateSize - minimumBytes, + candidateBytes - 1); + candidateBytes = Math.Min( + candidateBytes - 1, + Math.Max(1, corrected - CorrectionSlackBytes)); + } + + if (!foundContent) + { + int high = retainedBytes - 1; + int lastFit = 0; + int probe = 1; + while (probe <= high) + { + BoundedText probeText = Merge( + fullText, + BoundedText.Fit(fullText.Lines, null, probe)); + T probeResult = createResult(probeText); + int probeSize = Size(probeText, createResult); + if (probeSize > maxBytes) + { + high = probe - 1; + break; + } + + best = probeResult; + bestBytes = probeSize; + bestBudget = probe; + lastFit = probe; + if (probe == high) + { + break; + } + + probe = probe > high / 2 ? high : probe * 2; + } + + int low = lastFit + 1; + while (low <= high) + { + int midpoint = low + ((high - low) / 2); + BoundedText midpointText = Merge( + fullText, + BoundedText.Fit(fullText.Lines, null, midpoint)); + T midpointResult = createResult(midpointText); + int midpointSize = Size(midpointText, createResult); + if (midpointSize <= maxBytes) + { + best = midpointResult; + bestBytes = midpointSize; + bestBudget = midpoint; + low = midpoint + 1; + } + else + { + high = midpoint - 1; + } + } + } + + int headroomThreshold = Math.Max(256, maxBytes / 100); + if (maxBytes - bestBytes >= headroomThreshold + && bestBudget < retainedBytes - 1) + { + int low = bestBudget + 1; + int high = retainedBytes - 1; + while (low <= high) + { + int midpoint = low + ((high - low) / 2); + BoundedText midpointText = Merge( + fullText, + BoundedText.Fit(fullText.Lines, null, midpoint)); + T midpointResult = createResult(midpointText); + int midpointSize = Size(midpointText, createResult); + if (midpointSize <= maxBytes) + { + best = midpointResult; + low = midpoint + 1; + } + else + { + high = midpoint - 1; + } + } + } + + return best; + } + + private static int Size(BoundedText text, Func createResult) + { + var skeletonText = new BoundedText( + [], + text.Truncated, + text.DroppedLines, + text.DroppedBytes); + T skeleton = createResult(skeletonText); + int skeletonSize = Utf8JsonBudget.GetStructuredToolResultByteCount( + skeleton, + ToolJson.Options); + int textSize = Utf8JsonBudget.GetStructuredJsonFragmentByteCount( + text, + ToolJson.Options); + int skeletonTextSize = Utf8JsonBudget.GetStructuredJsonFragmentByteCount( + skeletonText, + ToolJson.Options); + return checked(skeletonSize + textSize - skeletonTextSize); + } + + private static BoundedText DropAll(BoundedText text) + { + if (text.Lines.Count == 0) + { + return text; + } + + return Merge(text, BoundedText.Fit(text.Lines, 0, 1)); + } + + private static BoundedText Merge(BoundedText earlier, BoundedText later) + { + if (!later.Truncated) + { + return earlier; + } + + return new BoundedText( + later.Lines, + Truncated: true, + DroppedLines: checked(earlier.DroppedLines + later.DroppedLines), + DroppedBytes: checked(earlier.DroppedBytes + later.DroppedBytes)); + } + + private static int EstimateBudget( + int referenceBytes, + int availableBytes, + int variableBytes, + int maximumBytes) + { + if (maximumBytes <= 0) + { + return 0; + } + + if (availableBytes <= 0 || variableBytes <= 0) + { + return 1; + } + + long estimate = (long)referenceBytes * availableBytes / variableBytes; + return (int)Math.Clamp(estimate, 1, maximumBytes); + } + + private static int JoinedUtf8ByteCount(IReadOnlyList lines) + { + int bytes = 0; + for (int index = 0; index < lines.Count; index++) + { + bytes = checked(bytes + Encoding.UTF8.GetByteCount(lines[index])); + if (index > 0) + { + bytes = checked(bytes + 1); + } + } + + return bytes; + } +} diff --git a/src/LibTmux.Mcp/ServerInstructions.cs b/src/LibTmux.Mcp/ServerInstructions.cs index 226489b..91fa0f9 100644 --- a/src/LibTmux.Mcp/ServerInstructions.cs +++ b/src/LibTmux.Mcp/ServerInstructions.cs @@ -83,7 +83,8 @@ public static string Compose(ServerPolicy policy, string? callerPaneId) "Drives tmux: terminal sessions, windows and panes on this machine. " + "Hierarchy is Server > Session > Window > Pane. Target by id — %1 is a pane, " + "@1 a window, $1 a session — because ids survive renames and layout changes. " - + "Every tool takes socket_name; tmux_list_servers finds the sockets."; + + "Tools that address tmux take socketName; tmux_list_servers discovers sockets, " + + "and tmux_list_jobs spans the jobs recorded by this MCP process."; private const string Scope = "USE FOR: tmux panes, windows, sessions, splits, scrollback, copy mode, " @@ -105,9 +106,9 @@ public static string Compose(ServerPolicy policy, string? callerPaneId) + "tmux_tail_pane, passing back its cursor."; private const string Budget = - "COST: capture tools keep the NEWEST lines and report what they dropped; " - + "if content_truncated is set, lines are missing, not absent. Prefer " - + "tmux_tail_pane over re-capturing a pane you are watching."; + "COST: terminal text keeps the NEWEST lines and reports what was dropped. " + + "Check content.truncated, output.truncated, or tail.truncated; true means " + + "lines are missing, not absent. Prefer tmux_tail_pane while watching."; private const string Gaps = "ABSENT ON PURPOSE: no hook writing (a hook outlives this conversation — put " diff --git a/src/LibTmux.Mcp/Streaming/HierarchyEndpointWatch.cs b/src/LibTmux.Mcp/Streaming/HierarchyEndpointWatch.cs new file mode 100644 index 0000000..267dcfe --- /dev/null +++ b/src/LibTmux.Mcp/Streaming/HierarchyEndpointWatch.cs @@ -0,0 +1,915 @@ +using System.Runtime.Versioning; +using Microsoft.Extensions.Logging; + +namespace LibTmux.Mcp; + +/// Owns subscriptions and one control run for an exact tmux generation. +[UnsupportedOSPlatform("windows")] +internal sealed class HierarchyEndpointWatch : IAsyncDisposable +{ + private static readonly TimeSpan InitialRecoveryDelay = TimeSpan.FromMilliseconds(100); + private static readonly TimeSpan MaximumRecoveryDelay = TimeSpan.FromSeconds(2); + private readonly object _gate = new(); + private readonly ILogger? _logger; + private readonly Func _delay; + private readonly Func? _beforeRecoveryOutcome; + private readonly Action? _recoveryOutcomeObserved; + private readonly CancellationTokenSource _lifetime = new(); + private readonly SemaphoreSlim _subscriptionGate = new(1, 1); + private readonly Dictionary _subscribers = new( + ReferenceEqualityComparer.Instance); + private TaskCompletionSource _subscriberAvailable = NewSignal(); + private Func>? _startSession; + private Task? _recovery; + private WatchRun? _run; + private StartTransition? _transition; + private bool _invalidationPending; + private bool _retired; + + internal HierarchyEndpointWatch( + HierarchyWatchKey key, + ILogger? logger, + Func delay, + Func? beforeRecoveryOutcome, + Action? recoveryOutcomeObserved) + { + Key = key; + _logger = logger; + _delay = delay; + _beforeRecoveryOutcome = beforeRecoveryOutcome; + _recoveryOutcomeObserved = recoveryOutcomeObserved; + } + + internal HierarchyWatchKey Key { get; } + + internal Task EnterSubscriptionAsync(CancellationToken cancellationToken) => + _subscriptionGate.WaitAsync(cancellationToken); + + internal void ExitSubscription() => _subscriptionGate.Release(); + + internal bool TryAddReference( + string uri, + object subscriberKey, + Func, Task> announce, + out bool added) + { + lock (_gate) + { + if (_retired) + { + added = false; + return false; + } + + bool hadNoSubscribers = _subscribers.Count == 0; + if (!_subscribers.TryGetValue(subscriberKey, out Subscriber? subscriber)) + { + subscriber = new Subscriber(announce, ReportSubscriberFailure); + _subscribers.Add(subscriberKey, subscriber); + } + + added = subscriber.Resources.TryAdd(uri, 0); + if (hadNoSubscribers) + { + _subscriberAvailable.TrySetResult(); + } + + return true; + } + } + + internal bool TryRemoveReference(string uri, object subscriberKey) + { + lock (_gate) + { + return RemoveReferenceLocked(uri, subscriberKey); + } + } + + /// Drops the resource for every subscriber holding it here. + /// + /// The keyless unsubscribe has no way to name one of several holders, so + /// leaving any behind would keep a callback and a control client alive that + /// no caller can reach. + /// + internal bool RemoveAllReferences(string uri) + { + lock (_gate) + { + object[] holders = [.. _subscribers + .Where(pair => pair.Value.Resources.ContainsKey(uri)) + .Select(static pair => pair.Key)]; + bool removed = false; + foreach (object subscriberKey in holders) + { + removed |= RemoveReferenceLocked(uri, subscriberKey); + } + + return removed; + } + } + + internal async Task EnsureStartedAsync( + Func> startSession, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + Func> retainedFactory; + lock (_gate) + { + if (_retired || _subscribers.Count == 0) + { + return; + } + + _startSession ??= startSession; + retainedFactory = _startSession; + } + + StartOutcome outcome = await EnsureStartedCoreAsync( + retainedFactory, + cancellationToken) + .ConfigureAwait(false); + ObserveStartOutcome(outcome); + EnsureRecoveryStarted(retainedFactory, outcome); + } + + private async Task EnsureStartedCoreAsync( + Func> startSession, + CancellationToken cancellationToken) + { + using CancellationTokenSource startup = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, + _lifetime.Token); + CancellationToken startupToken = startup.Token; + while (true) + { + StartTransition transition; + bool ownsTransition; + lock (_gate) + { + if (_retired || _subscribers.Count == 0) + { + return StartOutcome.Unused; + } + + if (_run is WatchRun current + && Volatile.Read(ref current.Ended) == 0 + && current.Session.IsRunning) + { + return StartOutcome.Started; + } + + if (_transition is StartTransition pending) + { + transition = pending; + ownsTransition = false; + } + else + { + WatchRun? staleRun = _run; + if (staleRun is not null && _subscribers.Count > 0) + { + _invalidationPending = true; + } + + transition = new StartTransition(staleRun); + _run = null; + _transition = transition; + ownsTransition = true; + } + } + + if (!ownsTransition) + { + try + { + StartOutcome completedOutcome = await transition.Completion.Task + .WaitAsync(startupToken) + .ConfigureAwait(false); + if (completedOutcome is not StartOutcome.Started) + { + return completedOutcome; + } + } + catch (OperationCanceledException) + when (!cancellationToken.IsCancellationRequested + && !_lifetime.IsCancellationRequested) + { + continue; + } + + continue; + } + + StartOutcome outcome; + try + { + outcome = await StartTransitionAsync( + transition, + startSession, + startupToken) + .ConfigureAwait(false); + } + catch (Exception error) + { + ClearTransition(transition); + transition.Completion.TrySetException(error); + _ = transition.Completion.Task.Exception; + throw; + } + + ClearTransition(transition); + transition.Completion.TrySetResult(outcome); + return outcome; + } + } + + internal async Task StopIfUnusedAsync() + { + bool cancelLifetime = false; + while (true) + { + StartTransition? transition; + WatchRun? run = null; + Task? recovery = null; + lock (_gate) + { + if (_subscribers.Count > 0) + { + return false; + } + + if (!_retired) + { + _retired = true; + cancelLifetime = true; + } + + transition = _transition; + if (transition is null) + { + run = _run; + _run = null; + recovery = _recovery; + } + } + + if (cancelLifetime) + { + _lifetime.Cancel(); + cancelLifetime = false; + } + + if (transition is not null) + { + try + { + await transition.Completion.Task.ConfigureAwait(false); + } + catch + { + // The subscribing caller owns the startup failure. + } + + continue; + } + + if (run is not null) + { + await DisposeRunAsync(run).ConfigureAwait(false); + await run.Pump.ConfigureAwait(false); + } + + if (recovery is not null) + { + await recovery.ConfigureAwait(false); + } + + return true; + } + } + + public async ValueTask DisposeAsync() + { + lock (_gate) + { + _retired = true; + foreach (Subscriber subscriber in _subscribers.Values) + { + subscriber.Retire(); + } + + _subscribers.Clear(); + } + + _lifetime.Cancel(); + + while (true) + { + StartTransition? transition; + WatchRun? run = null; + Task? recovery = null; + lock (_gate) + { + transition = _transition; + if (transition is null) + { + run = _run; + _run = null; + recovery = _recovery; + } + } + + if (transition is not null) + { + try + { + await transition.Completion.Task.ConfigureAwait(false); + } + catch + { + // The subscribing caller owns the startup failure. + } + + continue; + } + + if (run is not null) + { + await DisposeRunAsync(run).ConfigureAwait(false); + await run.Pump.ConfigureAwait(false); + } + + if (recovery is not null) + { + await recovery.ConfigureAwait(false); + } + + return; + } + } + + private async Task StartTransitionAsync( + StartTransition transition, + Func> startSession, + CancellationToken cancellationToken) + { + if (transition.StaleRun is not null) + { + Volatile.Write(ref transition.StaleRun.Ended, 1); + await ObserveCleanupAsync(transition.StaleRun).ConfigureAwait(false); + } + + lock (_gate) + { + if (_retired || _subscribers.Count == 0) + { + return StartOutcome.Unused; + } + } + + IControlModeSession? starting = null; + try + { + IControlModeSession session = await startSession(cancellationToken) + .ConfigureAwait(false); + starting = session; + await session + .SendAsync("refresh-client -f ignore-size,no-output", cancellationToken) + .ConfigureAwait(false); + + WatchRun run = new(session); + bool keepRun; + lock (_gate) + { + keepRun = !_retired && _subscribers.Count > 0; + if (keepRun) + { + _run = run; + run.Pump = PumpAsync(run); + starting = null; + } + } + + if (!keepRun) + { + starting = null; + await session.DisposeAsync().ConfigureAwait(false); + return StartOutcome.Unused; + } + + return StartOutcome.Started; + } + catch (Exception startupFailure) + { + if (starting is not null) + { + try + { + await starting.DisposeAsync().ConfigureAwait(false); + } + catch (Exception cleanupFailure) + { + startupFailure.Data["LibTmux.ControlModeCleanupFailure"] = cleanupFailure; + } + } + + if (startupFailure is not LibTmuxException error) + { + throw; + } + + if (_logger is not null) + { + Log.ControlClientUnavailable(_logger, error, "hierarchy"); + } + + return StartOutcome.Unavailable; + } + } + + private void ClearTransition(StartTransition transition) + { + lock (_gate) + { + if (ReferenceEquals(_transition, transition)) + { + _transition = null; + } + } + } + + private void EnsureRecoveryStarted( + Func> startSession, + StartOutcome outcome) + { + if (outcome is StartOutcome.Unused) + { + return; + } + + lock (_gate) + { + if (!_retired + && _subscribers.Count > 0 + && (_recovery is null || _recovery.IsCompleted)) + { + int failedAttempts = outcome is StartOutcome.Unavailable ? 1 : 0; + _recovery = RecoverAsync(startSession, failedAttempts); + } + } + } + + private bool ObserveStartOutcome(StartOutcome outcome) + { + bool notify = false; + bool invalidationPending; + lock (_gate) + { + if (outcome is StartOutcome.Unavailable) + { + bool live = _run is WatchRun current + && Volatile.Read(ref current.Ended) == 0 + && current.Session.IsRunning; + if (!live && !_retired && _subscribers.Count > 0) + { + _invalidationPending = true; + } + } + else if (outcome is StartOutcome.Started + && _invalidationPending + && _run is WatchRun current + && Volatile.Read(ref current.Ended) == 0 + && current.Session.IsRunning + && !_retired + && _subscribers.Count > 0) + { + _invalidationPending = false; + notify = true; + } + + invalidationPending = _invalidationPending; + } + + if (notify) + { + Notify(); + } + + return invalidationPending; + } + + private async Task RecoverAsync( + Func> startSession, + int failedAttempts) + { + await Task.Yield(); + while (!_lifetime.IsCancellationRequested) + { + WatchRun? run; + Task? subscriberAvailable = null; + lock (_gate) + { + if (_retired) + { + return; + } + + if (_subscribers.Count == 0) + { + subscriberAvailable = _subscriberAvailable.Task; + } + + run = _run; + } + + if (subscriberAvailable is not null) + { + try + { + await subscriberAvailable.WaitAsync(_lifetime.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (_lifetime.IsCancellationRequested) + { + return; + } + + continue; + } + + if (run is not null + && Volatile.Read(ref run.Ended) == 0 + && run.Session.IsRunning) + { + try + { + await run.Pump.ConfigureAwait(false); + } + catch (Exception) + { + // Recovery owns observing a failed event stream. + } + + if (Interlocked.Exchange(ref run.RecoveryCounted, 1) == 0) + { + failedAttempts = Math.Min(failedAttempts + 1, 6); + } + + continue; + } + + if (run is not null + && Interlocked.Exchange(ref run.RecoveryCounted, 1) == 0) + { + failedAttempts = Math.Min(failedAttempts + 1, 6); + } + + try + { + await _delay(RecoveryDelay(failedAttempts), _lifetime.Token) + .ConfigureAwait(false); + StartOutcome outcome = await EnsureStartedCoreAsync( + startSession, + _lifetime.Token) + .ConfigureAwait(false); + if (_beforeRecoveryOutcome is not null) + { + await _beforeRecoveryOutcome(_lifetime.Token).ConfigureAwait(false); + } + + bool invalidationPending = ObserveStartOutcome(outcome); + _recoveryOutcomeObserved?.Invoke(invalidationPending); + if (outcome is StartOutcome.Unused) + { + continue; + } + + if (outcome is StartOutcome.Unavailable) + { + failedAttempts = Math.Min(failedAttempts + 1, 6); + } + } + catch (OperationCanceledException) when (_lifetime.IsCancellationRequested) + { + return; + } + catch (Exception) + { + failedAttempts = Math.Min(failedAttempts + 1, 6); + } + } + } + + private static TimeSpan RecoveryDelay(int failedAttempts) + { + int exponent = Math.Clamp(failedAttempts - 1, 0, 5); + long ticks = InitialRecoveryDelay.Ticks * (1L << exponent); + return TimeSpan.FromTicks(Math.Min(ticks, MaximumRecoveryDelay.Ticks)); + } + + private async Task PumpAsync(WatchRun run) + { + try + { + await foreach (TmuxEvent observed in run.Session.Events.ConfigureAwait(false)) + { + if (HierarchyWatcher.InvalidatesHierarchy(observed)) + { + Notify(); + } + } + } + catch (Exception error) when (error is LibTmuxException or OperationCanceledException) + { + // The client going away is how this ends. + } + finally + { + lock (_gate) + { + Volatile.Write(ref run.Ended, 1); + if (ReferenceEquals(_run, run) + && !_retired + && _subscribers.Count > 0) + { + _invalidationPending = true; + } + } + + await ObserveCleanupAsync(run).ConfigureAwait(false); + } + } + + private void Notify() + { + SubscriberNotification[] notifications; + lock (_gate) + { + notifications = [.. _subscribers.Values.Select(subscriber => + new SubscriberNotification( + subscriber, + [.. subscriber.Resources.Keys]))]; + } + + foreach (SubscriberNotification notification in notifications) + { + if (notification.Resources.Count == 0) + { + continue; + } + + notification.Subscriber.Enqueue(notification.Resources); + } + } + + private void ReportSubscriberFailure(Exception error) + { + if (_logger is not null) + { + Log.HierarchySubscriberFailed(_logger, error, Key.EndpointFingerprint); + } + } + + private async Task ObserveCleanupAsync(WatchRun run) + { + try + { + await DisposeRunAsync(run).ConfigureAwait(false); + } + catch (Exception error) + { + if (_logger is not null + && Interlocked.Exchange(ref run.CleanupReported, 1) == 0) + { + Log.ControlClientCleanupFailed(_logger, error, "hierarchy"); + } + } + } + + private static async Task DisposeRunAsync(WatchRun run) + { + if (Interlocked.CompareExchange(ref run.DisposalStarted, 1, 0) != 0) + { + await run.Disposal.Task.ConfigureAwait(false); + return; + } + + try + { + await run.Session.DisposeAsync().ConfigureAwait(false); + run.Disposal.TrySetResult(); + } + catch (Exception error) + { + run.Disposal.TrySetException(error); + _ = run.Disposal.Task.Exception; + throw; + } + } + + private bool RemoveReferenceLocked(string uri, object subscriberKey) + { + if (!_subscribers.TryGetValue(subscriberKey, out Subscriber? subscriber) + || !subscriber.Resources.Remove(uri)) + { + return false; + } + + subscriber.RemovePending(uri); + + if (subscriber.Resources.Count == 0) + { + _subscribers.Remove(subscriberKey); + subscriber.Retire(); + if (_subscribers.Count == 0) + { + _subscriberAvailable = NewSignal(); + } + } + + return true; + } + + 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, + IReadOnlyList Resources); + + private sealed class StartTransition(WatchRun? staleRun) + { + internal WatchRun? StaleRun { get; } = staleRun; + + internal TaskCompletionSource Completion { get; } = new( + TaskCreationOptions.RunContinuationsAsynchronously); + } + + private sealed class WatchRun(IControlModeSession session) + { + internal IControlModeSession Session { get; } = session; + + internal Task Pump { get; set; } = Task.CompletedTask; + + internal TaskCompletionSource Disposal { get; } = new( + TaskCreationOptions.RunContinuationsAsynchronously); + + internal int Ended; + internal int DisposalStarted; + internal int CleanupReported; + internal int RecoveryCounted; + } + + private enum StartOutcome + { + Started, + Unavailable, + Unused, + } +} + +/// Identifies one exact endpoint and daemon generation. +internal readonly record struct HierarchyWatchKey( + string EndpointFingerprint, + ServerGeneration Generation) +{ + internal static HierarchyWatchKey From(Server server) + { + string endpointFingerprint = server.Connection?.GetEndpointFingerprint() + ?? throw new InvalidOperationException("The server has no connection identity."); + ServerGeneration generation = server.Generation + ?? throw new InvalidOperationException("The server must be materialized."); + return new HierarchyWatchKey(endpointFingerprint, generation); + } + + internal static HierarchyWatchKey ForTest( + string endpointFingerprint, + ServerGeneration generation) + { + ArgumentException.ThrowIfNullOrEmpty(endpointFingerprint); + return new HierarchyWatchKey(endpointFingerprint, generation); + } +} diff --git a/src/LibTmux.Mcp/Streaming/HierarchyWatcher.cs b/src/LibTmux.Mcp/Streaming/HierarchyWatcher.cs index d92d649..7726e99 100644 --- a/src/LibTmux.Mcp/Streaming/HierarchyWatcher.cs +++ b/src/LibTmux.Mcp/Streaming/HierarchyWatcher.cs @@ -1,4 +1,3 @@ -using System.Collections.Concurrent; using System.Runtime.Versioning; using Microsoft.Extensions.Logging; @@ -13,8 +12,8 @@ namespace LibTmux.Mcp; /// of being re-listed on a timer in case something moved. /// /// -/// One control client for the whole server, started only once somebody -/// subscribes, and stopped when the last subscriber goes. It attaches with +/// One control client per exact server generation, started only once somebody +/// subscribes, and stopped when its last subscriber goes. It attaches with /// no-output as well as ignore-size: this one wants to hear about /// the hierarchy and not about every byte a pane prints, and tmux will keep /// the pane traffic out of the stream if asked. @@ -45,16 +44,34 @@ public sealed class HierarchyWatcher : IAsyncDisposable "client-detached", }; - private readonly ConcurrentDictionary _subscribed = new(StringComparer.Ordinal); - private readonly SemaphoreSlim _gate = new(1, 1); + private readonly object _endpointsGate = new(); + private readonly Dictionary _endpoints = []; private readonly ILogger? _logger; - private IControlModeSession? _session; - private Task? _pump; - private Func, Task>? _announce; + private readonly Func _delay; + private readonly Func? _beforeRecoveryOutcome; + private readonly Action? _recoveryOutcomeObserved; + private Task? _disposeTask; + private bool _disposed; /// Initializes the watcher. /// Records why a control client could not start. - public HierarchyWatcher(ILogger? logger = null) => _logger = logger; + public HierarchyWatcher(ILogger? logger = null) + : this(logger, static (delay, token) => Task.Delay(delay, token), null, null) + { + } + + internal HierarchyWatcher( + ILogger? logger, + Func delay, + Func? beforeRecoveryOutcome = null, + Action? recoveryOutcomeObserved = null) + { + ArgumentNullException.ThrowIfNull(delay); + _logger = logger; + _delay = delay; + _beforeRecoveryOutcome = beforeRecoveryOutcome; + _recoveryOutcomeObserved = recoveryOutcomeObserved; + } /// Gets the resource URIs this watcher will notify about. /// @@ -70,13 +87,25 @@ public sealed class HierarchyWatcher : IAsyncDisposable ]; /// - public async ValueTask DisposeAsync() + public ValueTask DisposeAsync() { - _subscribed.Clear(); - await StopAsync().ConfigureAwait(false); - _gate.Dispose(); + lock (_endpointsGate) + { + if (_disposeTask is null) + { + _disposed = true; + HierarchyEndpointWatch[] endpoints = [.. _endpoints.Values]; + _endpoints.Clear(); + _disposeTask = DisposeEndpointsAsync(endpoints); + } + + return new ValueTask(_disposeTask); + } } + private static Task DisposeEndpointsAsync(HierarchyEndpointWatch[] endpoints) => + Task.WhenAll(endpoints.Select(endpoint => endpoint.DisposeAsync().AsTask())); + /// Starts reporting changes to one resource. /// The resource the client subscribed to. /// @@ -93,124 +122,224 @@ public async Task SubscribeAsync( Server tmux, CancellationToken cancellationToken) { - ArgumentNullException.ThrowIfNull(uri); - ArgumentNullException.ThrowIfNull(announce); - ArgumentNullException.ThrowIfNull(tmux); + await SubscribeAsync(uri, announce, announce, tmux, cancellationToken) + .ConfigureAwait(false); + } - _announce = announce; - _subscribed[uri] = 0; - await EnsureStartedAsync(tmux, cancellationToken).ConfigureAwait(false); + /// Starts one independently owned subscription. + internal async Task SubscribeAsync( + string uri, + object subscriberKey, + Func, Task> announce, + Server tmux, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(tmux); + Server materialized = tmux.IsMaterialized + ? tmux + : await tmux.ConnectAsync(cancellationToken).ConfigureAwait(false); + HierarchyWatchKey key = HierarchyWatchKey.From(materialized); + await SubscribeAsync( + uri, + subscriberKey, + announce, + key, + token => materialized.EnterControlModeAsync(cancellationToken: token), + cancellationToken) + .ConfigureAwait(false); } + /// Starts one subscription with a supplied control-client factory. + internal async Task SubscribeAsync( + string uri, + object subscriberKey, + Func, Task> announce, + Func> startSession, + CancellationToken cancellationToken) => + await SubscribeAsync( + uri, + subscriberKey, + announce, + HierarchyWatchKey.ForTest("default", new ServerGeneration(1, 1)), + startSession, + cancellationToken) + .ConfigureAwait(false); + + /// Starts a test subscription for an exact endpoint generation. + internal async Task SubscribeAsync( + string uri, + object subscriberKey, + Func, Task> announce, + string endpointFingerprint, + ServerGeneration generation, + Func> startSession, + CancellationToken cancellationToken) => + await SubscribeAsync( + uri, + subscriberKey, + announce, + HierarchyWatchKey.ForTest(endpointFingerprint, generation), + startSession, + cancellationToken) + .ConfigureAwait(false); + /// Stops reporting changes to one resource. /// The resource the client unsubscribed from. public async Task UnsubscribeAsync(string uri) { ArgumentNullException.ThrowIfNull(uri); - _subscribed.TryRemove(uri, out _); - if (_subscribed.IsEmpty) + foreach (HierarchyEndpointWatch endpoint in SnapshotEndpoints()) { - await StopAsync().ConfigureAwait(false); + if (!endpoint.RemoveAllReferences(uri)) + { + continue; + } + + await RetireIfUnusedAsync(endpoint).ConfigureAwait(false); } } - private async Task EnsureStartedAsync(Server tmux, CancellationToken cancellationToken) + /// Stops one independently owned subscription. + internal async Task UnsubscribeAsync(string uri, object subscriberKey) { - await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); - try + ArgumentNullException.ThrowIfNull(uri); + ArgumentNullException.ThrowIfNull(subscriberKey); + foreach (HierarchyEndpointWatch endpoint in SnapshotEndpoints()) { - if (_session is not null) + if (!endpoint.TryRemoveReference(uri, subscriberKey)) { - return; + continue; } - IControlModeSession session = await tmux - .EnterControlModeAsync(cancellationToken: cancellationToken) - .ConfigureAwait(false); - await session - .SendAsync("refresh-client -f ignore-size,no-output", cancellationToken) - .ConfigureAwait(false); - - _session = session; - _pump = PumpAsync(session); - } - catch (LibTmuxException error) - { - // Without a control client there are no notifications, and a client - // that subscribed simply never hears one. Reading still works, so - // this costs freshness rather than function. - if (_logger is not null) - { - Log.ControlClientUnavailable(_logger, error, "hierarchy"); - } - } - finally - { - _gate.Release(); + await RetireIfUnusedAsync(endpoint).ConfigureAwait(false); } } - private async Task StopAsync() + /// Answers whether a tmux notification changes what exists. + /// The notification name, without its leading percent. + /// when subscribers should be told. + internal static bool IsStructural(string name) => Structural.Contains(name); + + /// Answers whether an event requires invalidating the hierarchy. + internal static bool InvalidatesHierarchy(TmuxEvent observed) => + observed is TmuxEventsDroppedEvent + || observed is TmuxNotificationEvent notification && IsStructural(notification.Name); + + private async Task SubscribeAsync( + string uri, + object subscriberKey, + Func, Task> announce, + HierarchyWatchKey key, + Func> startSession, + CancellationToken cancellationToken) { - IControlModeSession? session = Interlocked.Exchange(ref _session, null); - if (session is not null) - { - await session.DisposeAsync().ConfigureAwait(false); - } + ArgumentNullException.ThrowIfNull(uri); + ArgumentNullException.ThrowIfNull(subscriberKey); + ArgumentNullException.ThrowIfNull(announce); + ArgumentNullException.ThrowIfNull(startSession); - if (_pump is Task pump) + HierarchyEndpointWatch endpoint; + bool added = false; + while (true) { - await pump.ConfigureAwait(false); - _pump = null; + lock (_endpointsGate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (!_endpoints.TryGetValue(key, out endpoint!)) + { + endpoint = new HierarchyEndpointWatch( + key, + _logger, + _delay, + _beforeRecoveryOutcome, + _recoveryOutcomeObserved); + _endpoints.Add(key, endpoint); + } + } + + await endpoint.EnterSubscriptionAsync(cancellationToken).ConfigureAwait(false); + bool acquired = false; + try + { + lock (_endpointsGate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (_endpoints.TryGetValue(key, out HierarchyEndpointWatch? current) + && ReferenceEquals(current, endpoint)) + { + acquired = endpoint.TryAddReference( + uri, + subscriberKey, + announce, + out added); + if (!acquired) + { + _endpoints.Remove(key); + } + } + } + } + finally + { + if (!acquired) + { + endpoint.ExitSubscription(); + } + } + + if (acquired) + { + break; + } } - } - private async Task PumpAsync(IControlModeSession session) - { try { - await foreach (TmuxEvent observed in session.Events.ConfigureAwait(false)) + await endpoint.EnsureStartedAsync(startSession, cancellationToken) + .ConfigureAwait(false); + lock (_endpointsGate) { - if (observed is TmuxNotificationEvent notification - && Structural.Contains(notification.Name)) - { - await NotifyAsync().ConfigureAwait(false); - } + ObjectDisposedException.ThrowIf(_disposed, this); + } + } + catch + { + if (added) + { + endpoint.TryRemoveReference(uri, subscriberKey); } + + await RetireIfUnusedAsync(endpoint).ConfigureAwait(false); + throw; } - catch (Exception error) when (error is LibTmuxException or OperationCanceledException) + finally { - // The client going away is how this ends. + endpoint.ExitSubscription(); } } - private async Task NotifyAsync() + private HierarchyEndpointWatch[] SnapshotEndpoints() { - if (_announce is not Func, Task> announce) + lock (_endpointsGate) { - return; + return [.. _endpoints.Values]; } + } - string[] changed = [.. _subscribed.Keys]; - if (changed.Length == 0) + private async Task RetireIfUnusedAsync(HierarchyEndpointWatch endpoint) + { + if (!await endpoint.StopIfUnusedAsync().ConfigureAwait(false)) { return; } - try - { - await announce(changed).ConfigureAwait(false); - } - catch (Exception error) when (error is IOException or ObjectDisposedException - or InvalidOperationException) + lock (_endpointsGate) { - // The client hung up. Nothing here is worth failing over, and the - // subscription dies with the session anyway. + if (_endpoints.TryGetValue(endpoint.Key, out HierarchyEndpointWatch? current) + && ReferenceEquals(current, endpoint)) + { + _endpoints.Remove(endpoint.Key); + } } } - - /// Answers whether a tmux notification changes what exists. - /// The notification name, without its leading percent. - /// when subscribers should be told. - internal static bool IsStructural(string name) => Structural.Contains(name); } diff --git a/src/LibTmux.Mcp/Streaming/PaneActivityHub.cs b/src/LibTmux.Mcp/Streaming/PaneActivityHub.cs index 82831b0..344d6d2 100644 --- a/src/LibTmux.Mcp/Streaming/PaneActivityHub.cs +++ b/src/LibTmux.Mcp/Streaming/PaneActivityHub.cs @@ -1,4 +1,5 @@ using System.Collections.Concurrent; +using System.ComponentModel; using System.Runtime.Versioning; using Microsoft.Extensions.Logging; @@ -32,29 +33,37 @@ public sealed class PaneActivityHub : IAsyncDisposable /// How long a poll-based wait sleeps between reads. internal static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(60); - private readonly ConcurrentDictionary _watches = new(StringComparer.Ordinal); - private readonly ConcurrentDictionary _signals = new(StringComparer.Ordinal); + private readonly ConcurrentDictionary _watches = []; private readonly ILogger? _logger; + private readonly Func>? _startPaneSession; private bool _disposed; /// Initializes the hub. /// Records why a control client could not start. public PaneActivityHub(ILogger? logger = null) => _logger = logger; + internal PaneActivityHub( + Func> startPaneSession, + ILogger? logger = null) + { + ArgumentNullException.ThrowIfNull(startPaneSession); + _startPaneSession = startPaneSession; + _logger = logger; + } + /// Gets whether any session is currently watched through control mode. - public bool IsStreaming => !_watches.IsEmpty; + public bool IsStreaming => _watches.Values.Any(watch => watch.IsStreaming); /// public async ValueTask DisposeAsync() { - _disposed = true; - foreach (KeyValuePair entry in _watches) + Volatile.Write(ref _disposed, true); + foreach (KeyValuePair entry in _watches) { await entry.Value.DisposeAsync().ConfigureAwait(false); } _watches.Clear(); - _signals.Clear(); } /// Watches a pane's session for as long as the result is held. @@ -68,27 +77,105 @@ public async ValueTask DisposeAsync() public async Task WatchAsync(Pane pane, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(pane); - if (_disposed) + SessionWatchKey key = SessionWatchKey.From(pane); + return await WatchAsync( + key, + token => StartPaneSessionAsync(pane, key.SessionId, token), + cancellationToken) + .ConfigureAwait(false); + } + + private async Task StartPaneSessionAsync( + Pane pane, + string sessionId, + CancellationToken cancellationToken) + { + try + { + return _startPaneSession is null + ? await pane.Server + .EnterControlModeAsync(sessionId, cancellationToken) + .ConfigureAwait(false) + : await _startPaneSession(pane, cancellationToken).ConfigureAwait(false); + } + catch (Exception error) when (error is Win32Exception + or IOException + or InvalidDataException + or InvalidOperationException + or NotSupportedException) { - return NullLease.Instance; + throw new TmuxTransportException( + "The tmux control client could not attach; polling will be used instead.", + [], + TmuxDispatchState.NotDispatched, + error); } + } - string sessionId = pane.Session.Id.ToString(); - SessionWatch watch = _watches.GetOrAdd( - sessionId, - key => new SessionWatch(key, this)); + /// Watches a session with a supplied control-client factory. + internal async Task WatchAsync( + string sessionId, + Func> startSession, + CancellationToken cancellationToken) => + await WatchAsync( + SessionWatchKey.ForTest("default", sessionId), + startSession, + cancellationToken) + .ConfigureAwait(false); - bool started = await watch.EnsureStartedAsync(pane.Server, cancellationToken) + internal async Task WatchAsync( + string endpointId, + string sessionId, + Func> startSession, + CancellationToken cancellationToken) => + await WatchAsync( + SessionWatchKey.ForTest(endpointId, sessionId), + startSession, + cancellationToken) .ConfigureAwait(false); - if (!started) + + private async Task WatchAsync( + SessionWatchKey key, + Func> startSession, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrEmpty(key.SessionId); + ArgumentNullException.ThrowIfNull(startSession); + while (!Volatile.Read(ref _disposed)) { - _watches.TryRemove(sessionId, out _); - return NullLease.Instance; + SessionWatch watch = _watches.GetOrAdd( + key, + static (created, hub) => new SessionWatch(created, hub), + this); + + LeaseAcquisition acquired = await watch + .AcquireAsync(startSession, cancellationToken) + .ConfigureAwait(false); + if (acquired.Lease is not null) + { + if (Volatile.Read(ref _disposed)) + { + await acquired.Lease.DisposeAsync().ConfigureAwait(false); + return NullLease.Instance; + } + + return acquired.Lease; + } + + RemoveWatch(key, watch); + if (!acquired.Retry) + { + return NullLease.Instance; + } } - return watch.Lease(); + return NullLease.Instance; } + private void RemoveWatch(SessionWatchKey key, SessionWatch watch) => + ((ICollection>)_watches) + .Remove(new KeyValuePair(key, watch)); + /// Waits until a pane prints something, or the time runs out. /// The pane to wait on. /// @@ -106,7 +193,7 @@ public async Task WaitForActivityAsync( CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(paneId); - if (_disposed || timeout <= TimeSpan.Zero) + if (Volatile.Read(ref _disposed) || timeout <= TimeSpan.Zero) { return false; } @@ -120,15 +207,15 @@ public async Task WaitForActivityAsync( return false; } - Task expiry = Task.Delay(timeout, cancellationToken); - Task finished = await Task.WhenAny(wake, expiry).ConfigureAwait(false); - if (finished == expiry) + try + { + await wake.WaitAsync(timeout, cancellationToken).ConfigureAwait(false); + return true; + } + catch (TimeoutException) { - cancellationToken.ThrowIfCancellationRequested(); return false; } - - return true; } /// Takes the token that a later wait on this pane will wake from. @@ -142,19 +229,29 @@ public async Task WaitForActivityAsync( public object? CaptureSignal(string paneId) { ArgumentNullException.ThrowIfNull(paneId); - return IsStreaming - ? _signals.GetOrAdd(paneId, _ => new PaneSignal()).Current - : null; + SessionWatch[] streaming = [.. _watches.Values + .Where(watch => watch.IsStreaming) + .Take(2)]; + return streaming.Length == 1 ? streaming[0].CaptureSignal(paneId) : null; } - private void OnPaneOutput(string paneId) + /// Takes the exact endpoint/session token for a pane about to be read. + /// The pane about to be read. + /// The token, or null when that pane's session is not streaming. + public object? CaptureSignal(Pane pane) { - if (_signals.TryGetValue(paneId, out PaneSignal? signal)) - { - signal.Fire(); - } + ArgumentNullException.ThrowIfNull(pane); + return CaptureSignal(SessionWatchKey.From(pane), pane.Id.ToString()); } + internal Task? CaptureSignal(string endpointId, string sessionId, string paneId) => + CaptureSignal(SessionWatchKey.ForTest(endpointId, sessionId), paneId); + + private Task? CaptureSignal(SessionWatchKey key, string paneId) => + _watches.TryGetValue(key, out SessionWatch? watch) + ? watch.CaptureSignal(paneId) + : null; + /// One pane's "something happened" bell. /// /// The completion source is replaced rather than reset, so a waiter that @@ -176,48 +273,93 @@ internal void Fire() => } /// One session's control client, and how many waits need it. - private sealed class SessionWatch(string sessionId, PaneActivityHub hub) : IAsyncDisposable + private sealed class SessionWatch(SessionWatchKey key, PaneActivityHub hub) : IAsyncDisposable { private readonly SemaphoreSlim _gate = new(1, 1); - private IControlModeSession? _session; - private Task? _pump; + private readonly Dictionary _signals = new(StringComparer.Ordinal); + private readonly object _signalGate = new(); + private WatchRun? _run; + private bool _retired; private int _leases; - internal async Task EnsureStartedAsync( - Server server, + internal bool IsStreaming + { + get + { + WatchRun? run = Volatile.Read(ref _run); + return run is not null + && Volatile.Read(ref run.Ended) == 0 + && run.Session.IsRunning; + } + } + + internal async Task AcquireAsync( + Func> startSession, CancellationToken cancellationToken) { await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + IControlModeSession? starting = null; try { - if (_session is not null) + if (_retired) { - return true; + return LeaseAcquisition.RetryRequired; } - IControlModeSession session = await server - .EnterControlModeAsync(sessionId, cancellationToken) - .ConfigureAwait(false); + if (_run is WatchRun current) + { + if (Volatile.Read(ref current.Ended) == 0 && current.Session.IsRunning) + { + _leases = checked(_leases + 1); + return new LeaseAcquisition(new Release(this), Retry: false); + } + + Volatile.Write(ref current.Ended, 1); + _run = null; + StopSignaling(); + await ObserveCleanupAsync(current).ConfigureAwait(false); + } - // A client with a size would drag the session's windows down to - // it. This one exists to listen, so it opts out of the size - // calculation entirely rather than relying on never having sent - // one. The flag has been available since tmux 3.2. - await session.SendAsync("refresh-client -f ignore-size", cancellationToken) + starting = await startSession(cancellationToken).ConfigureAwait(false); + + // 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) .ConfigureAwait(false); - _session = session; - _pump = PumpAsync(session); - return true; + WatchRun run = new(starting); + _run = run; + run.Pump = PumpAsync(run); + starting = null; + _leases = checked(_leases + 1); + return new LeaseAcquisition(new Release(this), Retry: false); } - catch (LibTmuxException error) + catch (Exception startupFailure) { + if (starting is not null) + { + try + { + await starting.DisposeAsync().ConfigureAwait(false); + } + catch (Exception cleanupFailure) + { + startupFailure.Data["LibTmux.ControlModeCleanupFailure"] = cleanupFailure; + } + } + + if (startupFailure is not LibTmuxException error) + { + throw; + } + if (hub._logger is not null) { - Log.ControlClientUnavailable(hub._logger, error, sessionId); + Log.ControlClientUnavailable(hub._logger, error, key.SessionId); } - return false; + _retired = true; + return LeaseAcquisition.Unavailable; } finally { @@ -225,41 +367,43 @@ await session.SendAsync("refresh-client -f ignore-size", cancellationToken) } } - internal IAsyncDisposable Lease() - { - Interlocked.Increment(ref _leases); - return new Release(this); - } - public async ValueTask DisposeAsync() { - IControlModeSession? session = Interlocked.Exchange(ref _session, null); - if (session is not null) + await _gate.WaitAsync().ConfigureAwait(false); + WatchRun? run; + try + { + _retired = true; + run = _run; + _run = null; + } + finally { - await session.DisposeAsync().ConfigureAwait(false); + _gate.Release(); } - if (_pump is Task pump) + if (run is not null) { - await pump.ConfigureAwait(false); + StopSignaling(); + await DisposeRunAsync(run).ConfigureAwait(false); + await run.Pump.ConfigureAwait(false); } - _gate.Dispose(); } - private async Task PumpAsync(IControlModeSession session) + private async Task PumpAsync(WatchRun run) { try { - await foreach (TmuxEvent observed in session.Events.ConfigureAwait(false)) + await foreach (TmuxEvent observed in run.Session.Events.ConfigureAwait(false)) { switch (observed) { case TmuxOutputEvent output: - hub.OnPaneOutput(output.PaneId); + OnPaneOutput(output.PaneId); break; case TmuxExitEvent exit when hub._logger is not null: - Log.ControlClientEnded(hub._logger, sessionId, exit.Reason); + Log.ControlClientEnded(hub._logger, key.SessionId, exit.Reason); break; default: break; @@ -272,17 +416,140 @@ private async Task PumpAsync(IControlModeSession session) // their own timeout, which is why losing the stream degrades // cost rather than correctness. } + finally + { + await MarkEndedAsync(run).ConfigureAwait(false); + await ObserveCleanupAsync(run).ConfigureAwait(false); + } } - private async ValueTask ReleaseOneAsync() + private async Task MarkEndedAsync(WatchRun run) { - if (Interlocked.Decrement(ref _leases) > 0) + await _gate.WaitAsync().ConfigureAwait(false); + try + { + Volatile.Write(ref run.Ended, 1); + StopSignaling(); + } + finally { + _gate.Release(); + } + } + + private async Task ObserveCleanupAsync(WatchRun run) + { + try + { + await DisposeRunAsync(run).ConfigureAwait(false); + } + catch (Exception error) + { + if (hub._logger is not null + && Interlocked.Exchange(ref run.CleanupReported, 1) == 0) + { + Log.ControlClientCleanupFailed(hub._logger, error, key.SessionId); + } + } + } + + private static async Task DisposeRunAsync(WatchRun run) + { + if (Interlocked.CompareExchange(ref run.DisposalStarted, 1, 0) != 0) + { + await run.Disposal.Task.ConfigureAwait(false); return; } - hub._watches.TryRemove(sessionId, out _); - await DisposeAsync().ConfigureAwait(false); + try + { + await run.Session.DisposeAsync().ConfigureAwait(false); + run.Disposal.TrySetResult(); + } + catch (Exception error) + { + run.Disposal.TrySetException(error); + _ = run.Disposal.Task.Exception; + throw; + } + } + + private async ValueTask ReleaseOneAsync() + { + await _gate.WaitAsync().ConfigureAwait(false); + WatchRun? run = null; + try + { + if (_retired) + { + return; + } + + _leases--; + if (_leases > 0) + { + return; + } + + _retired = true; + run = _run; + _run = null; + } + finally + { + _gate.Release(); + } + + hub.RemoveWatch(key, this); + if (run is not null) + { + StopSignaling(); + await DisposeRunAsync(run).ConfigureAwait(false); + await run.Pump.ConfigureAwait(false); + } + } + + internal Task? CaptureSignal(string paneId) + { + lock (_signalGate) + { + if (!IsStreaming) + { + return null; + } + + if (!_signals.TryGetValue(paneId, out PaneSignal? signal)) + { + signal = new PaneSignal(); + _signals.Add(paneId, signal); + } + + return signal.Current; + } + } + + private void OnPaneOutput(string paneId) + { + lock (_signalGate) + { + if (_signals.TryGetValue(paneId, out PaneSignal? signal)) + { + signal.Fire(); + } + } + } + + private void StopSignaling() + { + lock (_signalGate) + { + foreach (PaneSignal signal in _signals.Values) + { + signal.Fire(); + } + + _signals.Clear(); + } } private sealed class Release(SessionWatch watch) : IAsyncDisposable @@ -294,6 +561,20 @@ public ValueTask DisposeAsync() => ? watch.ReleaseOneAsync() : ValueTask.CompletedTask; } + + private sealed class WatchRun(IControlModeSession session) + { + internal IControlModeSession Session { get; } = session; + + internal Task Pump { get; set; } = Task.CompletedTask; + + internal TaskCompletionSource Disposal { get; } = new( + TaskCreationOptions.RunContinuationsAsynchronously); + + internal int Ended; + internal int DisposalStarted; + internal int CleanupReported; + } } private sealed class NullLease : IAsyncDisposable @@ -302,4 +583,24 @@ private sealed class NullLease : IAsyncDisposable public ValueTask DisposeAsync() => ValueTask.CompletedTask; } + + private readonly record struct LeaseAcquisition(IAsyncDisposable? Lease, bool Retry) + { + internal static LeaseAcquisition RetryRequired { get; } = new(null, Retry: true); + + internal static LeaseAcquisition Unavailable { get; } = new(null, Retry: false); + } + + private readonly record struct SessionWatchKey( + Server? Server, + ServerGeneration? Generation, + string? TestEndpoint, + string SessionId) + { + internal static SessionWatchKey From(Pane pane) => + new(pane.Server, pane.Generation, TestEndpoint: null, pane.Session.Id.ToString()); + + internal static SessionWatchKey ForTest(string endpointId, string sessionId) => + new(Server: null, Generation: null, endpointId, sessionId); + } } diff --git a/src/LibTmux.Mcp/Streaming/PaneReader.cs b/src/LibTmux.Mcp/Streaming/PaneReader.cs index 564177e..b906f6c 100644 --- a/src/LibTmux.Mcp/Streaming/PaneReader.cs +++ b/src/LibTmux.Mcp/Streaming/PaneReader.cs @@ -62,33 +62,19 @@ internal static async Task ReadVisibleAsync( IReadOnlyList lines = await CaptureAsync(pane, null, cancellationToken) .ConfigureAwait(false); - IReadOnlyList cursorRows = await CaptureCursorRowsAsync( - pane, - before, - cancellationToken) - .ConfigureAwait(false); PaneGridState after = await RequireStateAsync(pane, cancellationToken) .ConfigureAwait(false); if (before == after) { + IReadOnlyList cursorRows = CursorRowsFromCapture(lines, 0, after); return new PaneRead(after, lines, cursorRows, false, baselinePid is not null); } } - // A pane printing without pause never gives two matching samples. The - // last read is still usable text; what it is not is a position anything - // later can be measured from, so the caller is told the anchor is gone. - PaneGridState settled = await RequireStateAsync(pane, cancellationToken) - .ConfigureAwait(false); - IReadOnlyList busy = await CaptureAsync(pane, null, cancellationToken) - .ConfigureAwait(false); - IReadOnlyList busyCursor = await CaptureCursorRowsAsync( - pane, - settled, - cancellationToken) - .ConfigureAwait(false); - return new PaneRead(settled, busy, busyCursor, false, true); + throw new McpException( + $"Pane {pane.Id} changed during every snapshot attempt. Try again when " + + "its output is less busy."); } /// Reads what a pane has printed since a cursor was issued. @@ -115,18 +101,16 @@ internal static async Task ReadSinceAsync( } bool trimRisk = TrimRisk(cursor, before); - int start = cursor.AnchorAbsolute - before.HistorySize; - IReadOnlyList rows = trimRisk + int previousStart = cursor.AnchorAbsolute - before.HistorySize; + int captureStart = trimRisk + ? -before.HistorySize + : Math.Min(previousStart, before.CursorY); + IReadOnlyList capturedRows = trimRisk ? await CaptureAsync(pane, int.MinValue, cancellationToken).ConfigureAwait(false) - : start >= before.PaneHeight + : captureStart >= before.PaneHeight ? [] - : await CaptureAsync(pane, start, cancellationToken).ConfigureAwait(false); + : await CaptureAsync(pane, captureStart, cancellationToken).ConfigureAwait(false); - IReadOnlyList cursorRows = await CaptureCursorRowsAsync( - pane, - before, - cancellationToken) - .ConfigureAwait(false); PaneGridState after = await RequireStateAsync(pane, cancellationToken) .ConfigureAwait(false); RaiseIfPaneReplaced(pane, after, cursor); @@ -136,9 +120,10 @@ internal static async Task ReadSinceAsync( continue; } + int previousOffset; if (trimRisk) { - int? match = FindUniqueAnchor(rows, cursor); + int? match = FindUniqueAnchor(capturedRows, cursor, cancellationToken); if (match is null) { PaneRead missed = await ReadVisibleAsync(pane, cursor.PanePid, cancellationToken) @@ -146,10 +131,22 @@ internal static async Task ReadSinceAsync( return missed with { LinesMissed = true, AnchorLost = true }; } - rows = [.. rows.Skip(match.Value)]; + previousOffset = match.Value; + } + else + { + previousOffset = checked(previousStart - captureStart); } - return new PaneRead(after, DropAlreadySeen(rows, cursor), cursorRows, false, false); + int cursorOffset = checked(after.CursorY - captureStart); + List reported = ReportRows( + capturedRows, + previousOffset, + cursorOffset, + cursor); + IReadOnlyList cursorRows = RowsFromOffset(capturedRows, cursorOffset); + + return new PaneRead(after, reported, cursorRows, false, false); } PaneRead busy = await ReadVisibleAsync(pane, cursor.PanePid, cancellationToken) @@ -174,23 +171,52 @@ internal static async Task> CaptureAsync( CapturePaneRequest? request = start switch { null => null, - int.MinValue => new CapturePaneRequest(startLine: new CapturePanePosition(-32768)), + int.MinValue => new CapturePaneRequest( + startLine: CapturePanePosition.BeginningOfHistory), int value => new CapturePaneRequest(startLine: new CapturePanePosition(value)), }; return await pane.CaptureAsync(request, cancellationToken).ConfigureAwait(false); } - private static async Task> CaptureCursorRowsAsync( - Pane pane, - PaneGridState state, - CancellationToken cancellationToken) + private static IReadOnlyList CursorRowsFromCapture( + IReadOnlyList rows, + int captureStart, + PaneGridState state) { if (state.CursorY >= state.PaneHeight) { return []; } - return await CaptureAsync(pane, state.CursorY, cancellationToken).ConfigureAwait(false); + long offset = (long)state.CursorY - captureStart; + return offset is >= 0 and <= int.MaxValue + ? RowsFromOffset(rows, (int)offset) + : []; + } + + private static IReadOnlyList RowsFromOffset( + IReadOnlyList rows, + int offset) => + offset >= 0 && offset < rows.Count ? [.. rows.Skip(offset)] : []; + + private static List ReportRows( + IReadOnlyList capturedRows, + int previousOffset, + int cursorOffset, + TailCursor cursor) + { + IReadOnlyList previousRows = RowsFromOffset(capturedRows, previousOffset); + List reported = DropAlreadySeen(previousRows, cursor); + // Rows above the previous anchor carry no recorded digest, so a cursor + // that moved up reports them rather than risk dropping a rewrite. + if (cursorOffset < previousOffset) + { + reported.InsertRange( + 0, + capturedRows.Skip(cursorOffset).Take(previousOffset - cursorOffset)); + } + + return reported; } private static async Task RequireStateAsync( @@ -247,36 +273,35 @@ private static bool TrimRisk(TailCursor cursor, PaneGridState state) return cursor.HistorySize >= floor || state.HistorySize >= floor; } - private static int? FindUniqueAnchor(IReadOnlyList rows, TailCursor cursor) + internal static int? FindUniqueAnchor( + IReadOnlyList rows, + TailCursor cursor, + CancellationToken cancellationToken) { if (cursor.AnchorHash is null) { return null; } - string[] fingerprint = [cursor.AnchorHash, .. cursor.BelowHashes]; - if (rows.Count < fingerprint.Length) + int fingerprintLength = checked(cursor.BelowCount + 1); + if (rows.Count < fingerprintLength) { return null; } int? match = null; - for (int index = 0; index + fingerprint.Length <= rows.Count; index++) + for (int index = 0; index + fingerprintLength <= rows.Count; index++) { - bool same = true; - for (int offset = 0; offset < fingerprint.Length; offset++) - { - if (!string.Equals( - TailCursor.HashLine(rows[index + offset]), - fingerprint[offset], - StringComparison.Ordinal)) - { - same = false; - break; - } - } - - if (!same) + cancellationToken.ThrowIfCancellationRequested(); + if (!string.Equals( + TailCursor.HashLine(rows[index]), + cursor.AnchorHash, + StringComparison.Ordinal) + || (cursor.BelowCount > 0 + && !string.Equals( + TailCursor.HashRows(rows, index + 1, cursor.BelowCount), + cursor.BelowHash, + StringComparison.Ordinal))) { continue; } @@ -294,7 +319,7 @@ private static bool TrimRisk(TailCursor cursor, PaneGridState state) return match; } - private static List DropAlreadySeen( + internal static List DropAlreadySeen( IReadOnlyList rows, TailCursor cursor) { @@ -304,7 +329,6 @@ private static List DropAlreadySeen( } List kept = []; - int index = 0; if (cursor.AnchorHash is null || !string.Equals(TailCursor.HashLine(rows[0]), cursor.AnchorHash, StringComparison.Ordinal)) { @@ -312,19 +336,37 @@ private static List DropAlreadySeen( kept.Add(rows[0]); } - index = 1; - int matched = 0; - while (matched < cursor.BelowHashes.Count - && index + matched < rows.Count + int index = 1; + if (cursor.SuffixCount > 0 + && rows.Count - index >= cursor.SuffixCount && string.Equals( - TailCursor.HashLine(rows[index + matched]), - cursor.BelowHashes[matched], + TailCursor.HashRows(rows, index, cursor.SuffixCount), + cursor.SuffixHash, StringComparison.Ordinal)) { - matched++; + return Report(kept, rows, index + cursor.SuffixCount); } - for (int row = index + matched; row < rows.Count; row++) + // A pane below the cursor is redrawn a row at a time, so comparing the + // block as a whole would replay every row beside the one that changed. + byte[]? digests = cursor.TrackedRowDigests(); + int tracked = digests is null + ? 0 + : Math.Min(cursor.BelowCount, Math.Max(rows.Count - index, 0)); + for (int row = 0; row < tracked; row++) + { + if (!TailCursor.TrackedRowUnchanged(digests!, row, rows[index + row])) + { + kept.Add(rows[index + row]); + } + } + + return Report(kept, rows, index + tracked); + } + + private static List Report(List kept, IReadOnlyList rows, int from) + { + for (int row = from; row < rows.Count; row++) { kept.Add(rows[row]); } diff --git a/src/LibTmux.Mcp/Streaming/SubscriptionAdmission.cs b/src/LibTmux.Mcp/Streaming/SubscriptionAdmission.cs new file mode 100644 index 0000000..f353264 --- /dev/null +++ b/src/LibTmux.Mcp/Streaming/SubscriptionAdmission.cs @@ -0,0 +1,75 @@ +using ModelContextProtocol; + +namespace LibTmux.Mcp; + +/// Bounds the long-lived subscription streams owned by one server. +internal sealed class SubscriptionAdmission +{ + internal const int ConcurrentListenLimit = 8; + + private readonly object _gate = new(); + private readonly int _capacity; + private int _active; + + internal SubscriptionAdmission() + : this(ConcurrentListenLimit) + { + } + + internal SubscriptionAdmission(int capacity) + { + ArgumentOutOfRangeException.ThrowIfLessThan(capacity, 1); + _capacity = capacity; + } + + internal int ActiveCount + { + get + { + lock (_gate) + { + return _active; + } + } + } + + internal Lease Acquire(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + lock (_gate) + { + if (_active >= _capacity) + { + throw new McpException( + $"At most {_capacity} subscription listeners may be active at once. " + + "Cancel an existing subscriptions/listen request before opening another."); + } + + _active++; + } + + return new Lease(this); + } + + private void Release() + { + lock (_gate) + { + if (_active <= 0) + { + throw new InvalidOperationException("No subscription admission is active."); + } + + _active--; + } + } + + internal sealed class Lease : IDisposable + { + private SubscriptionAdmission? _owner; + + internal Lease(SubscriptionAdmission owner) => _owner = owner; + + public void Dispose() => Interlocked.Exchange(ref _owner, null)?.Release(); + } +} diff --git a/src/LibTmux.Mcp/Streaming/SubscriptionStream.cs b/src/LibTmux.Mcp/Streaming/SubscriptionStream.cs index a3de3ce..59525f5 100644 --- a/src/LibTmux.Mcp/Streaming/SubscriptionStream.cs +++ b/src/LibTmux.Mcp/Streaming/SubscriptionStream.cs @@ -1,6 +1,8 @@ using System.Runtime.Versioning; +using System.Text.Json; using System.Text.Json.Nodes; using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; @@ -27,6 +29,8 @@ namespace LibTmux.Mcp; [UnsupportedOSPlatform("windows")] internal static class SubscriptionStream { + internal const int SubscriptionIdMaxEncodedBytes = 256; + /// Builds the handler that owns subscriptions/listen. /// The handler. internal static McpRequestHandler Create() => @@ -35,92 +39,191 @@ internal static McpRequestHandler SubscriptionsListenNotifications requested = request.Params?.Notifications ?? new SubscriptionsListenNotifications(); + IReadOnlyList watched = Canonicalize(requested.ResourceSubscriptions); + if (request.Services is not IServiceProvider services) + { + throw new InvalidOperationException("Subscription services are unavailable."); + } + + // Admission precedes the subscriber key and delivery callback. A + // rejected stream therefore cannot leave detached watcher state. + using SubscriptionAdmission.Lease admission = services + .GetRequiredService() + .Acquire(cancellationToken); + + return await ListenAsync(request, requested, watched, services, cancellationToken) + .ConfigureAwait(false); + }; + + /// Returns the distinct resource subscriptions this server can deliver. + internal static IReadOnlyList Canonicalize(IEnumerable? requested) + { + if (requested is null) + { + return []; + } + + bool[] found = new bool[HierarchyWatcher.Watchable.Count]; + int remaining = found.Length; + foreach (string candidate in requested) + { + for (int index = 0; index < HierarchyWatcher.Watchable.Count; index++) + { + if (found[index] + || !string.Equals( + candidate, + HierarchyWatcher.Watchable[index], + StringComparison.Ordinal)) + { + continue; + } + + found[index] = true; + remaining--; + break; + } + + if (remaining == 0) + { + break; + } + } + + List canonical = new(found.Length); + for (int index = 0; index < found.Length; index++) + { + if (found[index]) + { + canonical.Add(HierarchyWatcher.Watchable[index]); + } + } + + return canonical; + } + + /// Rejects a stream identifier too large to echo on every event. + internal static RequestId ValidateSubscriptionId(RequestId subscriptionId) + { + if (subscriptionId.Id is not (string or long)) + { + throw new McpException("The subscription request requires a JSON-RPC id."); + } + + if (subscriptionId.Id is string text + && (text.Length > SubscriptionIdMaxEncodedBytes + || JsonEncodedText.Encode(text).EncodedUtf8Bytes.Length + > SubscriptionIdMaxEncodedBytes)) + { + throw new McpException( + $"The subscription request id exceeds {SubscriptionIdMaxEncodedBytes} " + + "JSON-encoded bytes. Use a shorter JSON-RPC id."); + } + + return subscriptionId; + } + + private static async Task ListenAsync( + RequestContext request, + SubscriptionsListenNotifications requested, + IReadOnlyList watched, + IServiceProvider services, + CancellationToken cancellationToken) + { // Only what this server can actually deliver is granted. Claiming a // subscription and never sending it is worse than declining it: the // client waits instead of falling back to reading. - List watched = [.. (requested.ResourceSubscriptions ?? []) - .Where(HierarchyWatcher.Watchable.Contains)]; SubscriptionsListenNotifications granted = new() { ToolsListChanged = requested.ToolsListChanged, PromptsListChanged = requested.PromptsListChanged, ResourcesListChanged = requested.ResourcesListChanged, - ResourceSubscriptions = watched.Count > 0 ? watched : null, + ResourceSubscriptions = watched.Count > 0 ? [.. watched] : null, }; - string subscriptionId = request.JsonRpcRequest.Id.ToString(); + RequestId subscriptionId = ValidateSubscriptionId(request.JsonRpcRequest.Id); McpServer server = request.Server; - - // The acknowledgement goes first, before any event, and says what was - // granted rather than what was asked for. - await SendAsync( - server, - NotificationMethods.SubscriptionsAcknowledgedNotification, - new JsonObject - { - ["notifications"] = Describe(granted), - }, - subscriptionId, - cancellationToken) - .ConfigureAwait(false); - HierarchyWatcher? watcher = watched.Count > 0 - ? request.Services?.GetService() + ? services.GetService() : null; + object? subscriberKey = null; + List subscribed = []; + TaskCompletionSource deliveryEnabled = new( + TaskCreationOptions.RunContinuationsAsynchronously); - if (watcher is not null && request.Services is IServiceProvider scope) + try { - Server tmux = await scope.GetRequiredService() - .GetAsync(cancellationToken: cancellationToken) - .ConfigureAwait(false); - - foreach (string uri in watched) + if (watcher is not null) { - await watcher.SubscribeAsync( - uri, - async changed => - { - foreach (string each in changed) - { - await SendAsync( - server, - NotificationMethods.ResourceUpdatedNotification, - new JsonObject { ["uri"] = each }, - subscriptionId, - CancellationToken.None) - .ConfigureAwait(false); - } - }, - tmux, - cancellationToken) + Server tmux = await services.GetRequiredService() + .GetAsync(cancellationToken: cancellationToken) .ConfigureAwait(false); + subscriberKey = new object(); + Func, Task> announce = async changed => + { + await deliveryEnabled.Task.ConfigureAwait(false); + foreach (string each in changed) + { + await SendAsync( + server, + NotificationMethods.ResourceUpdatedNotification, + new JsonObject { ["uri"] = each }, + subscriptionId, + CancellationToken.None, + tolerateClosedTransport: true) + .ConfigureAwait(false); + } + }; + + foreach (string uri in watched) + { + await watcher.SubscribeAsync( + uri, + subscriberKey, + announce, + tmux, + cancellationToken) + .ConfigureAwait(false); + subscribed.Add(uri); + } } - } - try - { + // The watcher is ready before the grant, while its delivery gate + // keeps the acknowledgement first on the wire. + await SendAsync( + server, + NotificationMethods.SubscriptionsAcknowledgedNotification, + new JsonObject + { + ["notifications"] = Describe(granted), + }, + subscriptionId, + cancellationToken) + .ConfigureAwait(false); + deliveryEnabled.TrySetResult(); + // The response is the stream. Returning early would close it and // take the subscription with it, so this waits out the request. await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken).ConfigureAwait(false); } - catch (OperationCanceledException) + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { // The client stopped listening, which is how a listen ends. } finally { - if (watcher is not null) + deliveryEnabled.TrySetCanceled(cancellationToken); + if (watcher is not null && subscriberKey is not null) { - foreach (string uri in watched) + foreach (string uri in subscribed) { - await watcher.UnsubscribeAsync(uri).ConfigureAwait(false); + await watcher.UnsubscribeAsync(uri, subscriberKey).ConfigureAwait(false); } } } return new EmptyResult(); - }; + } /// Describes what was granted, omitting what was not. private static JsonObject Describe(SubscriptionsListenNotifications granted) @@ -159,12 +262,18 @@ private static async Task SendAsync( McpServer server, string method, JsonObject parameters, - string subscriptionId, - CancellationToken cancellationToken) + RequestId subscriptionId, + CancellationToken cancellationToken, + bool tolerateClosedTransport = false) { parameters["_meta"] = new JsonObject { - [MetaKeys.SubscriptionId] = subscriptionId, + [MetaKeys.SubscriptionId] = subscriptionId.Id switch + { + string text => JsonValue.Create(text), + long number => JsonValue.Create(number), + _ => throw new InvalidOperationException("The subscription id is invalid."), + }, }; try @@ -172,8 +281,9 @@ private static async Task SendAsync( await server.SendNotificationAsync(method, parameters, cancellationToken: cancellationToken) .ConfigureAwait(false); } - catch (Exception error) when (error is IOException or ObjectDisposedException - or InvalidOperationException or OperationCanceledException) + catch (Exception error) when (tolerateClosedTransport + && error is (IOException or ObjectDisposedException + or InvalidOperationException or OperationCanceledException)) { // The client hung up mid-stream. The subscription dies with it. } diff --git a/src/LibTmux.Mcp/Streaming/TailCursor.cs b/src/LibTmux.Mcp/Streaming/TailCursor.cs index 83b1e3b..020dceb 100644 --- a/src/LibTmux.Mcp/Streaming/TailCursor.cs +++ b/src/LibTmux.Mcp/Streaming/TailCursor.cs @@ -1,3 +1,5 @@ +using System.Buffers.Binary; +using System.Globalization; using System.Runtime.Versioning; using System.Security.Cryptography; using System.Text; @@ -9,28 +11,37 @@ namespace LibTmux.Mcp; /// Where a reader of a pane left off. /// -/// -/// A pane is a grid tmux rewrites in place, not a log that only grows, so -/// "since last time" cannot be a line number alone. The anchor is an absolute -/// position and a fingerprint of the rows at it: the position finds -/// the place quickly, and the fingerprint proves it is still the same place. -/// -/// -/// Opaque to callers on purpose. A cursor built by hand would encode -/// assumptions about a grid that tmux is free to change underneath it. -/// +/// The token is authenticated with a process-local key and bound to the exact +/// endpoint, server generation, and pane that issued it. Restarting this MCP +/// server invalidates its cursors instead of accepting unauthenticated state +/// from an earlier process. /// [UnsupportedOSPlatform("windows")] internal sealed record TailCursor( + int Version, + string EndpointFingerprint, + int ServerProcessId, + long ServerStartTime, string PaneId, string PanePid, int HistorySize, int PaneHeight, int AnchorAbsolute, string? AnchorHash, - IReadOnlyList BelowHashes) + int BelowCount, + string? BelowHash, + int SuffixCount, + string? SuffixHash, + string? RowHashes) { - private const string Prefix = "tmux-tail-v1:"; + private const int CurrentVersion = 3; + private const int DigestHexLength = 64; + private const int MaximumBelowRows = 32; + private const int RowDigestBytes = 8; + private const int MaximumPayloadBytes = 1536; + private const int MaximumTokenCharacters = 2048; + private const string Prefix = "tmux-tail-v3:"; + private static readonly byte[] AuthenticationKey = RandomNumberGenerator.GetBytes(32); /// Fingerprints one row. /// The row's text. @@ -41,81 +52,376 @@ internal static string HashLine(string line) return Convert.ToHexString(digest).ToLowerInvariant(); } + /// Fingerprints each row of a window, one truncated digest per row. + /// + /// A tail result carries its cursor twice, so the window is packed rather + /// than written as hex: every byte saved here is two bytes of a response. + /// + internal static string HashRowWindow(IReadOnlyList rows, int start, int count) + { + ArgumentNullException.ThrowIfNull(rows); + ArgumentOutOfRangeException.ThrowIfNegative(start); + ArgumentOutOfRangeException.ThrowIfNegative(count); + ArgumentOutOfRangeException.ThrowIfGreaterThan(count, rows.Count); + ArgumentOutOfRangeException.ThrowIfGreaterThan(start, rows.Count - count); + + Span window = stackalloc byte[MaximumBelowRows * RowDigestBytes]; + for (int index = 0; index < count; index++) + { + RowDigest(rows[start + index]) + .CopyTo(window[(index * RowDigestBytes)..]); + } + + return ToBase64Url(window[..(count * RowDigestBytes)]); + } + + /// The recorded digest of each tracked row, or null when none was. + /// The packed digests, one RowDigestBytes run per row. + internal byte[]? TrackedRowDigests() => + RowHashes is null ? null : FromBase64Url(RowHashes); + + /// Answers whether a tracked row still holds the text it was seen with. + /// The digests returned. + /// The row's position within the tracked window. + /// The row's text now. + /// when the row is unchanged. + internal static bool TrackedRowUnchanged(byte[] digests, int index, string line) + { + ArgumentNullException.ThrowIfNull(digests); + ArgumentOutOfRangeException.ThrowIfNegative(index); + ArgumentNullException.ThrowIfNull(line); + return RowDigest(line) + .SequenceEqual(digests.AsSpan(index * RowDigestBytes, RowDigestBytes)); + } + + private static ReadOnlySpan RowDigest(string line) => + SHA256.HashData(Encoding.UTF8.GetBytes(line)).AsSpan(0, RowDigestBytes); + + /// Fingerprints an ordered row sequence without retaining every row hash. + internal static string HashRows(IReadOnlyList rows, int start, int count) + { + ArgumentNullException.ThrowIfNull(rows); + ArgumentOutOfRangeException.ThrowIfNegative(start); + ArgumentOutOfRangeException.ThrowIfNegative(count); + ArgumentOutOfRangeException.ThrowIfGreaterThan(count, rows.Count); + ArgumentOutOfRangeException.ThrowIfGreaterThan(start, rows.Count - count); + + using IncrementalHash digest = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); + Span length = stackalloc byte[sizeof(int)]; + for (int index = start; index < start + count; index++) + { + byte[] text = Encoding.UTF8.GetBytes(rows[index]); + BinaryPrimitives.WriteInt32BigEndian(length, text.Length); + digest.AppendData(length); + digest.AppendData(text); + } + + return Convert.ToHexString(digest.GetHashAndReset()).ToLowerInvariant(); + } + /// Builds a cursor for where a read finished. - /// The pane that was read. + /// The pane and exact server endpoint that were read. /// The grid state the read saw. /// The rows from the cursor row to the visible bottom. /// The cursor. internal static TailCursor Build( - string paneId, + Pane pane, PaneGridState state, - IReadOnlyList cursorRows) => - new( - PaneId: paneId, + IReadOnlyList cursorRows) + { + ArgumentNullException.ThrowIfNull(pane); + ArgumentNullException.ThrowIfNull(state); + ArgumentNullException.ThrowIfNull(cursorRows); + + ServerGeneration generation = pane.Generation; + string endpoint = pane.Server.Connection?.GetEndpointFingerprint() + ?? throw new IncompleteSnapshotException("connection", SnapshotDepth.Server); + // The cap keeps fallback anchor scans linear even on a repetitive, very tall pane. + int suffixCount = Math.Max(cursorRows.Count - 1, 0); + int belowCount = Math.Min(suffixCount, MaximumBelowRows); + var cursor = new TailCursor( + Version: CurrentVersion, + EndpointFingerprint: endpoint, + ServerProcessId: generation.ProcessId, + ServerStartTime: generation.StartTime, + PaneId: pane.Id.ToString(), PanePid: state.PanePid, HistorySize: state.HistorySize, PaneHeight: state.PaneHeight, AnchorAbsolute: state.CursorAbsolute, AnchorHash: cursorRows.Count > 0 ? HashLine(cursorRows[0]) : null, - BelowHashes: [.. cursorRows.Skip(1).Select(HashLine)]); + BelowCount: belowCount, + BelowHash: belowCount > 0 ? HashRows(cursorRows, 1, belowCount) : null, + SuffixCount: suffixCount, + SuffixHash: suffixCount > 0 ? HashRows(cursorRows, 1, suffixCount) : null, + RowHashes: belowCount > 0 ? HashRowWindow(cursorRows, 1, belowCount) : null); + cursor.Validate(); + return cursor; + } /// Renders the cursor as the opaque token a caller passes back. - /// The token. + /// The authenticated token. public string Encode() { - byte[] json = JsonSerializer.SerializeToUtf8Bytes(this, TailCursorJson.Default.TailCursor); - return Prefix + ToBase64Url(json); + byte[] payload = JsonSerializer.SerializeToUtf8Bytes(this, TailCursorJson.Default.TailCursor); + byte[] signature = HMACSHA256.HashData(AuthenticationKey, payload); + return Prefix + ToBase64Url(payload) + "." + ToBase64Url(signature); } - // Hand-rolled rather than System.Buffers.Text.Base64Url, which arrived in - // .NET 9 and this tool still targets .NET 8. - private static string ToBase64Url(byte[] value) => - Convert.ToBase64String(value) - .TrimEnd('=') - .Replace('+', '-') - .Replace('/', '_'); - - private static byte[] FromBase64Url(string value) - { - string padded = value.Replace('-', '+').Replace('_', '/'); - return Convert.FromBase64String(padded.PadRight((padded.Length + 3) / 4 * 4, '=')); - } - - /// Reads a token a caller passed back. + /// Reads and binds a token a caller passed back. /// The token, or null when the caller sent none. + /// The exact pane the token must have been issued for. /// The cursor, or null when there was no token. - /// The token was not one this server issued. - public static TailCursor? Decode(string? token) + /// The token is invalid or belongs elsewhere. + public static TailCursor? Decode(string? token, Pane pane) { - if (string.IsNullOrWhiteSpace(token)) + ArgumentNullException.ThrowIfNull(pane); + if (token is null) { return null; } - string trimmed = token.Trim(); - if (!trimmed.StartsWith(Prefix, StringComparison.Ordinal)) + string value = token; + if (value.Length > MaximumTokenCharacters + || !value.StartsWith(Prefix, StringComparison.Ordinal)) { - throw new McpException( - "That is not a tmux_tail_pane cursor. Pass back the cursor from the " - + "previous call, or omit it to start from what is on screen now."); + throw InvalidCursor(); + } + + ReadOnlySpan body = value.AsSpan(Prefix.Length); + int separator = body.IndexOf('.'); + if (separator <= 0 || separator != body.LastIndexOf('.')) + { + throw InvalidCursor(); } try { - byte[] json = FromBase64Url(trimmed[Prefix.Length..]); - return JsonSerializer.Deserialize(json, TailCursorJson.Default.TailCursor) - ?? throw new McpException("That tmux_tail_pane cursor is empty."); + byte[] payload = FromBase64Url(body[..separator]); + byte[] signature = FromBase64Url(body[(separator + 1)..]); + if (payload.Length is 0 or > MaximumPayloadBytes + || signature.Length != HMACSHA256.HashSizeInBytes) + { + throw InvalidCursor(); + } + + byte[] expectedSignature = HMACSHA256.HashData(AuthenticationKey, payload); + if (!CryptographicOperations.FixedTimeEquals(signature, expectedSignature)) + { + throw InvalidCursor(); + } + + ValidateJsonShape(payload); + TailCursor cursor = JsonSerializer.Deserialize( + payload, + TailCursorJson.Default.TailCursor) + ?? throw InvalidCursor(); + cursor.Validate(); + cursor.ValidateBinding(pane); + return cursor; + } + catch (Exception error) when (error is FormatException + or JsonException + or OverflowException + or ArgumentException) + { + throw InvalidCursor(); } - catch (Exception error) when (error is FormatException or JsonException) + } + + private void Validate() + { + bool canonicalPaneId = LibTmux.PaneId.TryParse(PaneId, out LibTmux.PaneId parsedPane) + && string.Equals(parsedPane.ToString(), PaneId, StringComparison.Ordinal); + bool canonicalPanePid = int.TryParse( + PanePid, + NumberStyles.None, + CultureInfo.InvariantCulture, + out int panePid) + && panePid > 0 + && string.Equals( + panePid.ToString(CultureInfo.InvariantCulture), + PanePid, + StringComparison.Ordinal); + long lastRow = (long)HistorySize + PaneHeight - 1; + + if (Version != CurrentVersion + || !IsDigest(EndpointFingerprint) + || ServerProcessId <= 0 + || ServerStartTime <= 0 + || !canonicalPaneId + || !canonicalPanePid + || HistorySize < 0 + || PaneHeight <= 0 + || AnchorAbsolute < HistorySize + || AnchorAbsolute > lastRow + (AnchorHash is null ? 1 : 0) + || (AnchorHash is not null && !IsDigest(AnchorHash)) + || BelowCount < 0 + || BelowCount > MaximumBelowRows + || BelowCount >= PaneHeight + || BelowCount > Math.Max(lastRow - AnchorAbsolute, 0) + || (BelowHash is not null && !IsDigest(BelowHash)) + || (BelowCount == 0) != (BelowHash is null) + || SuffixCount < 0 + || SuffixCount >= PaneHeight + || SuffixCount > Math.Max(lastRow - AnchorAbsolute, 0) + || BelowCount != Math.Min(SuffixCount, MaximumBelowRows) + || (SuffixHash is not null && !IsDigest(SuffixHash)) + || (SuffixCount == 0) != (SuffixHash is null) + || (SuffixCount == BelowCount + && !string.Equals(SuffixHash, BelowHash, StringComparison.Ordinal)) + || (BelowCount == 0) != (RowHashes is null) + || (RowHashes is not null && !IsRowWindow(RowHashes, BelowCount)) + || (AnchorHash is null + && (BelowCount != 0 + || BelowHash is not null + || SuffixCount != 0 + || SuffixHash is not null))) + { + throw InvalidCursor(); + } + } + + private void ValidateBinding(Pane pane) + { + ServerGeneration generation = pane.Generation; + string endpoint = pane.Server.Connection?.GetEndpointFingerprint() + ?? throw InvalidCursor(); + if (!string.Equals(PaneId, pane.Id.ToString(), StringComparison.Ordinal) + || ServerProcessId != generation.ProcessId + || ServerStartTime != generation.StartTime + || !CryptographicOperations.FixedTimeEquals( + Encoding.ASCII.GetBytes(EndpointFingerprint), + Encoding.ASCII.GetBytes(endpoint))) { throw new McpException( - "That tmux_tail_pane cursor is damaged. Omit it to start from what is " - + "on screen now."); + "That tmux_tail_pane cursor belongs to a different pane or tmux server. " + + "Call tmux_tail_pane without a cursor to start again here."); + } + } + + private static bool IsDigest(string? value) => + value is { Length: DigestHexLength } + && value.All(static character => + character is >= '0' and <= '9' or >= 'a' and <= 'f'); + + private static bool IsRowWindow(string value, int count) + { + try + { + return FromBase64Url(value).Length == count * RowDigestBytes; + } + catch (Exception error) when (error is FormatException or McpException) + { + return false; + } + } + + private static void ValidateJsonShape(ReadOnlySpan payload) + { + var reader = new Utf8JsonReader( + payload, + new JsonReaderOptions + { + AllowTrailingCommas = false, + CommentHandling = JsonCommentHandling.Disallow, + MaxDepth = 2, + }); + if (!reader.Read() || reader.TokenType != JsonTokenType.StartObject) + { + throw InvalidCursor(); + } + + var seen = new HashSet(StringComparer.Ordinal); + while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) + { + if (reader.TokenType != JsonTokenType.PropertyName) + { + throw InvalidCursor(); + } + + string property = reader.GetString() ?? throw InvalidCursor(); + if (!KnownProperties.Contains(property) || !seen.Add(property) || !reader.Read()) + { + throw InvalidCursor(); + } + + if (reader.TokenType is JsonTokenType.StartArray + or JsonTokenType.StartObject + or JsonTokenType.EndArray + or JsonTokenType.EndObject + or JsonTokenType.PropertyName) + { + throw InvalidCursor(); + } + } + + if (reader.TokenType != JsonTokenType.EndObject + || reader.Read() + || seen.Count != KnownProperties.Count) + { + throw InvalidCursor(); } } + + private static readonly HashSet KnownProperties = new( + [ + "version", + "endpointFingerprint", + "serverProcessId", + "serverStartTime", + "paneId", + "panePid", + "historySize", + "paneHeight", + "anchorAbsolute", + "anchorHash", + "belowCount", + "belowHash", + "suffixCount", + "suffixHash", + "rowHashes", + ], + StringComparer.Ordinal); + + private static string ToBase64Url(ReadOnlySpan value) => + Convert.ToBase64String(value) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + + private static byte[] FromBase64Url(ReadOnlySpan value) + { + if (value.IsEmpty + || value.Length % 4 == 1 + || value.Contains('=') + || value.Contains('+') + || value.Contains('/') + || value.IndexOfAnyExcept( + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_".AsSpan()) >= 0) + { + throw InvalidCursor(); + } + + string encoded = value.ToString().Replace('-', '+').Replace('_', '/'); + byte[] decoded = Convert.FromBase64String( + encoded.PadRight((encoded.Length + 3) / 4 * 4, '=')); + if (!ToBase64Url(decoded).AsSpan().SequenceEqual(value)) + { + throw InvalidCursor(); + } + + return decoded; + } + + private static McpException InvalidCursor() => new( + "That tmux_tail_pane cursor is invalid or is not one this server issued. " + + "Omit it to start from what is on screen now."); } -/// Serializes cursors without reflection, so the tool can be trimmed. +/// Serializes cursors without reflection. [JsonSerializable(typeof(TailCursor))] -[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +[JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow)] internal sealed partial class TailCursorJson : JsonSerializerContext; diff --git a/src/LibTmux.Mcp/Tasks/BoundedMcpTaskStore.cs b/src/LibTmux.Mcp/Tasks/BoundedMcpTaskStore.cs new file mode 100644 index 0000000..08e5a67 --- /dev/null +++ b/src/LibTmux.Mcp/Tasks/BoundedMcpTaskStore.cs @@ -0,0 +1,377 @@ +using System.Collections.Immutable; +using System.Text.Json; +using Microsoft.Extensions.Options; +using ModelContextProtocol; +using ModelContextProtocol.Extensions.Tasks; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +#pragma warning disable MCPEXP002 + +namespace LibTmux.Mcp; + +/// Retains a bounded set of task results and admits bounded work. +/// +/// The Tasks SDK calls before it +/// queues the tool with Task.Run. Refusing here therefore bounds both +/// remembered tasks and background executions, rather than merely limiting +/// work after an unbounded queue already exists. +/// +internal sealed class BoundedMcpTaskStore : IMcpTaskStore +{ + internal const int DefaultMaximumActiveTasks = 8; + internal const int DefaultMaximumRetainedTasks = 256; + internal const long DefaultPollIntervalMilliseconds = 1_000; + internal static readonly TimeSpan DefaultTimeToLive = TimeSpan.FromMinutes(15); + + private readonly object _gate = new(); + private readonly Dictionary _tasks = new(StringComparer.Ordinal); + private readonly HashSet _active = new(StringComparer.Ordinal); + private readonly AsyncLocal _clientCancellation = new(); + private readonly TimeProvider _timeProvider; + private readonly int _maximumActiveTasks; + private readonly int _maximumRetainedTasks; + private readonly TimeSpan _timeToLive; + private readonly long _pollIntervalMilliseconds; + + internal BoundedMcpTaskStore( + int maximumActiveTasks = DefaultMaximumActiveTasks, + int maximumRetainedTasks = DefaultMaximumRetainedTasks, + TimeSpan? timeToLive = null, + long pollIntervalMilliseconds = DefaultPollIntervalMilliseconds, + TimeProvider? timeProvider = null) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maximumActiveTasks); + ArgumentOutOfRangeException.ThrowIfLessThan( + maximumRetainedTasks, + maximumActiveTasks); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(pollIntervalMilliseconds); + + TimeSpan retention = timeToLive ?? DefaultTimeToLive; + if (retention <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException( + nameof(timeToLive), + retention, + "Task time-to-live must be positive."); + } + + _maximumActiveTasks = maximumActiveTasks; + _maximumRetainedTasks = maximumRetainedTasks; + _timeToLive = retention; + _pollIntervalMilliseconds = pollIntervalMilliseconds; + _timeProvider = timeProvider ?? TimeProvider.System; + } + + public event Action? InputResponseReceived; + + public Task CreateTaskAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + McpTaskInfo created; + lock (_gate) + { + DateTimeOffset now = _timeProvider.GetUtcNow(); + SweepExpired(now); + if (_active.Count >= _maximumActiveTasks) + { + throw AtCapacity( + $"This server already has {_maximumActiveTasks} active MCP tasks. " + + "Wait for one to finish, or cancel one and wait for it to stop, " + + "before starting another."); + } + + if (_tasks.Count >= _maximumRetainedTasks) + { + throw AtCapacity( + $"This server is retaining {_maximumRetainedTasks} MCP tasks. " + + $"They expire after {_timeToLive.TotalMinutes:g} minutes; retry " + + "after one expires, or restart this in-memory server."); + } + + string taskId = Guid.NewGuid().ToString("N"); + created = new McpTaskInfo( + taskId, + McpTaskStatus.Working, + now, + now, + _timeToLive, + _pollIntervalMilliseconds); + _tasks.Add(taskId, created); + _active.Add(taskId); + } + + return Task.FromResult(created); + } + + public Task GetTaskAsync( + string taskId, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(taskId); + cancellationToken.ThrowIfCancellationRequested(); + lock (_gate) + { + SweepExpired(_timeProvider.GetUtcNow()); + return Task.FromResult(_tasks.GetValueOrDefault(taskId)); + } + } + + public Task SetCompletedAsync( + string taskId, + JsonElement result, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + UpdateTerminal(taskId, McpTaskStatus.Completed, result, null); + return Task.CompletedTask; + } + + public Task SetFailedAsync( + string taskId, + JsonElement error, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + UpdateTerminal(taskId, McpTaskStatus.Failed, null, error); + return Task.CompletedTask; + } + + public Task SetCancelledAsync( + string taskId, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(taskId); + cancellationToken.ThrowIfCancellationRequested(); + lock (_gate) + { + SweepExpired(_timeProvider.GetUtcNow()); + if (!_tasks.TryGetValue(taskId, out McpTaskInfo? current)) + { + return Task.FromResult(false); + } + + bool clientCancellation = string.Equals( + _clientCancellation.Value, + taskId, + StringComparison.Ordinal); + if (IsTerminal(current.Status)) + { + if (!clientCancellation && current.Status == McpTaskStatus.Cancelled) + { + _active.Remove(taskId); + } + + return Task.FromResult(false); + } + + _tasks[taskId] = current with + { + Status = McpTaskStatus.Cancelled, + LastUpdatedAt = _timeProvider.GetUtcNow(), + }; + if (!clientCancellation) + { + _active.Remove(taskId); + } + + return Task.FromResult(true); + } + } + + public Task ResolveInputRequestsAsync( + string taskId, + IDictionary inputResponses, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(taskId); + ArgumentNullException.ThrowIfNull(inputResponses); + cancellationToken.ThrowIfCancellationRequested(); + bool notify = false; + lock (_gate) + { + McpTaskInfo current = Require(taskId); + if (!IsTerminal(current.Status)) + { + ImmutableDictionary requests = current.InputRequests + ?.ToImmutableDictionary(StringComparer.Ordinal) + ?? ImmutableDictionary.Empty + .WithComparers(StringComparer.Ordinal); + foreach (string requestId in inputResponses.Keys) + { + requests = requests.Remove(requestId); + } + + _tasks[taskId] = current with + { + InputRequests = requests, + Status = requests.IsEmpty + ? McpTaskStatus.Working + : McpTaskStatus.InputRequired, + LastUpdatedAt = _timeProvider.GetUtcNow(), + }; + notify = true; + } + } + + if (notify) + { + foreach ((string requestId, InputResponse response) in inputResponses) + { + InputResponseReceived?.Invoke(new InputResponseReceivedEventArgs + { + TaskId = taskId, + RequestId = requestId, + Response = response, + }); + } + } + + return Task.CompletedTask; + } + + public Task SetInputRequestsAsync( + string taskId, + IDictionary inputRequests, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(taskId); + ArgumentNullException.ThrowIfNull(inputRequests); + cancellationToken.ThrowIfCancellationRequested(); + lock (_gate) + { + McpTaskInfo current = Require(taskId); + if (!IsTerminal(current.Status)) + { + ImmutableDictionary requests = current.InputRequests + ?.ToImmutableDictionary(StringComparer.Ordinal) + ?? ImmutableDictionary.Empty + .WithComparers(StringComparer.Ordinal); + foreach ((string requestId, InputRequest request) in inputRequests) + { + requests = requests.SetItem(requestId, request); + } + + _tasks[taskId] = current with + { + InputRequests = requests, + Status = McpTaskStatus.InputRequired, + LastUpdatedAt = _timeProvider.GetUtcNow(), + }; + } + } + + return Task.CompletedTask; + } + + private static bool IsTerminal(McpTaskStatus status) => + status is McpTaskStatus.Completed or McpTaskStatus.Failed or McpTaskStatus.Cancelled; + + private void UpdateTerminal( + string taskId, + McpTaskStatus status, + JsonElement? result, + JsonElement? error) + { + ArgumentException.ThrowIfNullOrWhiteSpace(taskId); + lock (_gate) + { + McpTaskInfo current = Require(taskId); + if (!IsTerminal(current.Status)) + { + _tasks[taskId] = current with + { + Status = status, + Result = result, + Error = error, + LastUpdatedAt = _timeProvider.GetUtcNow(), + }; + } + + // SDK background finalization reaches these setters even when a + // client cancellation already made the record terminal-visible. + _active.Remove(taskId); + } + } + + private McpTaskInfo Require(string taskId) + { + SweepExpired(_timeProvider.GetUtcNow()); + return _tasks.TryGetValue(taskId, out McpTaskInfo? task) + ? task + : throw new InvalidOperationException($"Task '{taskId}' not found."); + } + + private void SweepExpired(DateTimeOffset now) + { + foreach ((string taskId, McpTaskInfo task) in _tasks.ToArray()) + { + if (now - task.CreatedAt < _timeToLive) + { + continue; + } + + if (!_active.Contains(taskId)) + { + _tasks.Remove(taskId); + } + } + } + + internal IDisposable EnterClientCancellation(string taskId) + { + string? previous = _clientCancellation.Value; + _clientCancellation.Value = taskId; + return new CancellationScope(_clientCancellation, previous); + } + + private static McpProtocolException AtCapacity(string message) => + new(message, McpErrorCode.InvalidRequest); + + private sealed class CancellationScope( + AsyncLocal current, + string? previous) : IDisposable + { + public void Dispose() => current.Value = previous; + } +} + +/// Marks calls made by the SDK's client-cancellation handler. +internal sealed class BoundedMcpTaskCancellationOptions( + BoundedMcpTaskStore store) : IConfigureOptions +{ + private const string CancelMethod = "tasks/cancel"; + + public void Configure(McpServerOptions options) + { + ArgumentNullException.ThrowIfNull(options); + IList handlers = options.RequestHandlers + ?? throw MissingHandler(); + int[] matches = handlers + .Select((handler, index) => (handler, index)) + .Where(static candidate => candidate.handler.Method == CancelMethod) + .Select(static candidate => candidate.index) + .ToArray(); + if (matches.Length != 1) + { + throw MissingHandler(); + } + + int index = matches[0]; + McpServerRequestHandler inner = handlers[index]; + handlers[index] = new McpServerRequestHandler + { + Method = inner.Method, + RoutingNameParameter = inner.RoutingNameParameter, + Handler = async (request, cancellationToken) => + { + string taskId = request.Params?["taskId"]?.GetValue() ?? string.Empty; + using IDisposable scope = store.EnterClientCancellation(taskId); + return await inner.Handler(request, cancellationToken).ConfigureAwait(false); + }, + }; + } + + private static InvalidOperationException MissingHandler() => + new("The MCP Tasks SDK did not register exactly one tasks/cancel handler."); +} diff --git a/src/LibTmux.Mcp/Tools/DestructiveTools.cs b/src/LibTmux.Mcp/Tools/DestructiveTools.cs index 6f6a11c..dacdeff 100644 --- a/src/LibTmux.Mcp/Tools/DestructiveTools.cs +++ b/src/LibTmux.Mcp/Tools/DestructiveTools.cs @@ -101,9 +101,8 @@ public async Task KillSessionAsync( Session target = await TmuxTargets.SessionAsync(server, session, cancellationToken) .ConfigureAwait(false); string id = target.Id.ToString(); - string name = target.Name; await target.KillAsync(cancellationToken: cancellationToken).ConfigureAwait(false); - return new ActionResult($"Killed session {id} ({name})."); + return new ActionResult($"Killed session {id}."); } /// Kills the whole server. @@ -131,7 +130,6 @@ public async Task KillServerAsync( .ConfigureAwait(false); await server.KillAsync(cancellationToken).ConfigureAwait(false); return new ActionResult( - $"Killed the tmux server on socket '{socketName ?? _connection.DefaultSocketName ?? "default"}' " - + $"and the {sessions.Count} session(s) it held."); + $"Killed the tmux server and the {sessions.Count} session(s) it held."); } } diff --git a/src/LibTmux.Mcp/Tools/ReadTools.Hierarchy.cs b/src/LibTmux.Mcp/Tools/ReadTools.Hierarchy.cs index 511b3fb..aaa77c4 100644 --- a/src/LibTmux.Mcp/Tools/ReadTools.Hierarchy.cs +++ b/src/LibTmux.Mcp/Tools/ReadTools.Hierarchy.cs @@ -16,7 +16,7 @@ public sealed partial class ReadTools [Description( "Read every tmux session, window and pane at once. Start here when you do not " + "know what exists. Each entity names its parent, and the pane marked " - + "is_caller is the one this server runs in. For one level only, the " + + "isCaller is the one this server runs in. For one level only, the " + "tmux_list_* tools are cheaper.")] public async Task HierarchyAsync( [Description("The tmux socket to read. Omit for the default server.")] @@ -137,7 +137,7 @@ public async Task> ListWindowsAsync( [McpServerTool(Name = "tmux_list_panes", ReadOnly = true, Idempotent = true, OpenWorld = false, UseStructuredContent = true)] [Description( "List tmux panes, optionally within one session or window. Filter for " - + "is_caller=true to answer 'which pane am I in?'. This reads sizes and " + + "isCaller=true to answer 'which pane am I in?'. This reads sizes and " + "running commands, not terminal text — for that use tmux_search_panes.")] public async Task> ListPanesAsync( [Description("A session id such as $0, or its name. Omit for every session.")] diff --git a/src/LibTmux.Mcp/Tools/ReadTools.Inspect.cs b/src/LibTmux.Mcp/Tools/ReadTools.Inspect.cs index 2551297..444a99d 100644 --- a/src/LibTmux.Mcp/Tools/ReadTools.Inspect.cs +++ b/src/LibTmux.Mcp/Tools/ReadTools.Inspect.cs @@ -21,7 +21,7 @@ public sealed partial class ReadTools [Description( "Find the tmux servers running for this user, by socket. Use when a session " + "you expect is missing: it is usually on a different socket. Every other " - + "tool takes a socket_name to reach one of these.")] + + "tool takes a socketName to reach one of these.")] public async Task> ListServersAsync( CancellationToken cancellationToken = default) { @@ -299,7 +299,7 @@ private static async Task HooksForAsync( } /// A tmux socket found on this machine. -/// The name to pass as socket_name. +/// The name to pass as socketName. /// Where the socket file is. /// Whether a server actually answered on it. /// How many sessions it holds. diff --git a/src/LibTmux.Mcp/Tools/ReadTools.Pane.cs b/src/LibTmux.Mcp/Tools/ReadTools.Pane.cs index ddc6424..a55df57 100644 --- a/src/LibTmux.Mcp/Tools/ReadTools.Pane.cs +++ b/src/LibTmux.Mcp/Tools/ReadTools.Pane.cs @@ -10,6 +10,8 @@ namespace LibTmux.Mcp; [UnsupportedOSPlatform("windows")] public sealed partial class ReadTools { + private const int MaximumSearchPatternBytes = 4_096; + /// Reads a pane's content and screen state together. /// The pane, or null for the active one. /// The most lines to answer, or null for the server default. @@ -37,16 +39,23 @@ public async Task SnapshotPaneAsync( PaneRead read = await PaneReader.ReadVisibleAsync(pane, null, cancellationToken) .ConfigureAwait(false); - return new PaneSnapshot( - Pane: PaneInfo.From(pane, TmuxTargets.CallerPaneId()), - Content: BoundedText.Fit( - PaneText.Scrub(read.Lines, pane.Width), - maxLines ?? _policy.MaxLines, - _policy.MaxBytes), - CursorX: await TmuxTargets.DisplayNumberAsync(pane, "#{cursor_x}", cancellationToken) - .ConfigureAwait(false), - CursorY: read.State.CursorY, - AlternateScreen: read.State.AlternateScreen); + PaneInfo paneInfo = PaneInfo.From(pane, TmuxTargets.CallerPaneId()); + int? cursorX = await TmuxTargets.DisplayNumberAsync( + pane, + "#{cursor_x}", + cancellationToken) + .ConfigureAwait(false); + return StructuredTextResultBudget.Fit( + PaneText.Scrub(read.Lines, pane.Width), + maxLines ?? _policy.MaxLines, + _policy.MaxBytes, + content => new PaneSnapshot( + paneInfo, + content, + cursorX, + read.State.CursorY, + read.State.AlternateScreen), + "pane snapshot"); } /// Reads what a pane is showing. @@ -88,17 +97,18 @@ public async Task CapturePaneAsync( .ConfigureAwait(false); CapturePaneRequest request = new( - startLine: includeHistory ? new CapturePanePosition(-32768) : null, + startLine: includeHistory ? CapturePanePosition.BeginningOfHistory : null, joinWrappedLines: joinWrappedLines); IReadOnlyList lines = await pane.CaptureAsync(request, cancellationToken) .ConfigureAwait(false); - return new CaptureResult( - pane.Id.ToString(), - BoundedText.Fit( - PaneText.Scrub(lines, pane.Width), - maxLines ?? _policy.MaxLines, - _policy.MaxBytes)); + string id = pane.Id.ToString(); + return StructuredTextResultBudget.Fit( + PaneText.Scrub(lines, pane.Width), + maxLines ?? _policy.MaxLines, + _policy.MaxBytes, + content => new CaptureResult(id, content), + "pane capture"); } /// Reads what a pane has printed since the last read. @@ -130,7 +140,7 @@ public async Task TailPaneAsync( .ConfigureAwait(false); string id = pane.Id.ToString(); - TailCursor? previous = TailCursor.Decode(cursor); + TailCursor? previous = TailCursor.Decode(cursor, pane); PaneRead read = previous is null ? await PaneReader.ReadVisibleAsync(pane, null, cancellationToken).ConfigureAwait(false) : await PaneReader.ReadSinceAsync(pane, previous, cancellationToken).ConfigureAwait(false); @@ -139,15 +149,18 @@ public async Task TailPaneAsync( // budget on a screenful they did not ask for. IReadOnlyList lines = previous is null ? [] : read.Lines; - return new TailResult( - PaneId: id, - Content: BoundedText.Fit( - PaneText.Scrub(lines, pane.Width), - maxLines ?? _policy.MaxLines, - _policy.MaxBytes), - Cursor: TailCursor.Build(id, read.State, read.CursorRows).Encode(), - LinesMissed: read.LinesMissed, - AnchorLost: previous is not null && read.AnchorLost); + string nextCursor = TailCursor.Build(pane, read.State, read.CursorRows).Encode(); + return StructuredTextResultBudget.Fit( + PaneText.Scrub(lines, pane.Width), + maxLines ?? _policy.MaxLines, + _policy.MaxBytes, + content => new TailResult( + id, + content, + nextCursor, + read.LinesMissed, + previous is not null && read.AnchorLost), + "pane tail"); } /// Searches what panes are showing. @@ -166,7 +179,8 @@ public async Task TailPaneAsync( + "question about what a pane CONTAINS — the tmux_list_* tools only see names " + "and sizes.")] public async Task SearchPanesAsync( - [Description("A .NET regular expression to look for.")] string pattern, + [Description("A .NET regular expression to look for, at most 4096 UTF-8 bytes.")] + string pattern, [Description("A session id such as $0, or its name. Omit to search every session.")] string? session = null, [Description("Search scrollback as well as the visible screen. Slower.")] @@ -179,6 +193,9 @@ public async Task SearchPanesAsync( CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(pattern); + ValidateSearchMatchLimit(maxMatchesPerPane, _policy.MaxLines); + ValidateSearchPatternBudget(pattern, _policy.MaxBytes); + Regex regex = CompilePattern(pattern, ignoreCase); Server server = await ServerAsync(socketName, cancellationToken).ConfigureAwait(false); @@ -190,10 +207,15 @@ public async Task SearchPanesAsync( .ConfigureAwait(false); CapturePaneRequest request = new( - startLine: includeHistory ? new CapturePanePosition(-32768) : null, + startLine: includeHistory ? CapturePanePosition.BeginningOfHistory : null, joinWrappedLines: true); - List hits = []; + var budget = new SearchResultBudget( + pattern, + panes.Count, + _policy.MaxLines, + _policy.MaxBytes); + int panesSearched = 0; bool truncated = false; foreach (Pane pane in panes) { @@ -210,35 +232,26 @@ public async Task SearchPanesAsync( continue; } - List matched = []; + panesSearched++; int visibleTop = lines.Count - pane.Height; - for (int index = 0; index < lines.Count; index++) - { - if (!regex.IsMatch(lines[index])) - { - continue; - } - - if (matched.Count >= maxMatchesPerPane) - { - truncated = true; - break; - } - - matched.Add(new MatchedLine(index - Math.Max(visibleTop, 0), lines[index])); - } - - if (matched.Count > 0) + SearchPaneBudgetOutcome outcome = AddSearchMatches( + budget, + pane.Id.ToString(), + pane.Window.Id.ToString(), + pane.Session.Id.ToString(), + lines, + Math.Max(visibleTop, 0), + regex, + maxMatchesPerPane, + cancellationToken); + truncated |= outcome != SearchPaneBudgetOutcome.Complete; + if (outcome == SearchPaneBudgetOutcome.GlobalLimit) { - hits.Add(new PaneMatch( - pane.Id.ToString(), - pane.Window.Id.ToString(), - pane.Session.Id.ToString(), - matched)); + break; } } - return new SearchResult(pattern, panes.Count, hits, truncated); + return budget.Build(panesSearched, truncated); } /// Compiles a caller's pattern, refusing one that cannot be run safely. @@ -260,4 +273,113 @@ internal static Regex CompilePattern(string pattern, bool ignoreCase) throw new McpException($"'{pattern}' is not a valid regular expression: {error.Message}"); } } + + /// Validates a per-pane match budget against the server-wide ceiling. + internal static void ValidateSearchMatchLimit(int requested, int maximum) + { + if (requested < 1 || requested > maximum) + { + throw new McpException( + $"maxMatchesPerPane must be between 1 and {maximum}, inclusive."); + } + } + + /// Rejects a pattern too large to compile or report within policy. + internal static void ValidateSearchPatternBudget(string pattern, int resultMaxBytes) + { + int patternMaxBytes = Math.Min(MaximumSearchPatternBytes, resultMaxBytes); + int patternBytes = System.Text.Encoding.UTF8.GetByteCount(pattern); + if (patternBytes > patternMaxBytes) + { + throw new McpException( + $"pattern is {patternBytes} UTF-8 bytes; the limit is {patternMaxBytes}. " + + "Use a shorter regular expression."); + } + + _ = new SearchResultBudget(pattern, int.MaxValue, 1, resultMaxBytes); + } + + /// Adds one pane's matches and distinguishes its local cap from exhaustion. + internal static SearchPaneBudgetOutcome AddSearchMatches( + SearchResultBudget budget, + string paneId, + string windowId, + string sessionId, + IReadOnlyList lines, + int visibleTop, + Regex regex, + int maxMatchesPerPane, + CancellationToken cancellationToken = default) + { + List matched = []; + SearchPaneBudgetOutcome outcome = SearchPaneBudgetOutcome.Complete; + for (int index = 0; index < lines.Count; index++) + { + cancellationToken.ThrowIfCancellationRequested(); + bool matches; + try + { + matches = regex.IsMatch(lines[index]); + } + catch (RegexMatchTimeoutException) + { + throw new McpException( + $"The pattern '{regex}' took too long to match. Simplify it — " + + "nested quantifiers such as (a+)+ backtrack badly on terminal text."); + } + + if (!matches) + { + continue; + } + + if (matched.Count >= maxMatchesPerPane) + { + outcome = SearchPaneBudgetOutcome.PerPaneLimit; + break; + } + + SearchMatchBudgetOutcome added = budget.TryAdd( + paneId, + windowId, + sessionId, + matched, + new MatchedLine(index - visibleTop, lines[index])); + if (added == SearchMatchBudgetOutcome.GlobalLimit) + { + outcome = SearchPaneBudgetOutcome.GlobalLimit; + break; + } + + if (added == SearchMatchBudgetOutcome.PaneCannotFit) + { + outcome = SearchPaneBudgetOutcome.OversizedMatchSkipped; + break; + } + + if (added == SearchMatchBudgetOutcome.ItemTooLarge) + { + outcome = SearchPaneBudgetOutcome.OversizedMatchSkipped; + } + } + + budget.Commit(paneId, windowId, sessionId, matched); + return outcome; + } +} + +/// Why adding one pane's search matches stopped. +internal enum SearchPaneBudgetOutcome +{ + /// Every matching line fit. + Complete = 0, + + /// This pane reached the caller's local cap. + PerPaneLimit = 1, + + /// The server-wide line budget was exhausted. + GlobalLimit = 2, + + /// At least one matching line was too large for the remaining budget. + OversizedMatchSkipped = 3, } diff --git a/src/LibTmux.Mcp/Tools/ReadTools.Wait.cs b/src/LibTmux.Mcp/Tools/ReadTools.Wait.cs index 0b0a2c9..cf648ef 100644 --- a/src/LibTmux.Mcp/Tools/ReadTools.Wait.cs +++ b/src/LibTmux.Mcp/Tools/ReadTools.Wait.cs @@ -1,6 +1,7 @@ using System.ComponentModel; using System.Diagnostics; using System.Runtime.Versioning; +using System.Text; using System.Text.RegularExpressions; using ModelContextProtocol; using ModelContextProtocol.Server; @@ -40,15 +41,17 @@ public async Task WaitForTextAsync( string? paneId = null, [Description( "Regular expressions to wait for. Omit or pass an empty list to return as " - + "soon as the pane prints anything new.")] + + "soon as the pane prints anything new. Across both pattern lists: at most " + + "32 entries and 16384 UTF-8 bytes; each entry is at most 4096 bytes.")] IReadOnlyList? patterns = null, [Description( "Regular expressions meaning the thing you are waiting for will never " - + "come, such as an error line. Matching one ends the wait as 'stopped'.")] + + "come, such as an error line. Matching one ends the wait as 'stopped'. " + + "It shares the patterns count and byte limits.")] IReadOnlyList? stopPatterns = null, [Description( "Seconds to wait. Lowered to the server's ceiling; read " - + "effective_timeout_seconds for the value actually used.")] + + "effectiveTimeoutSeconds for the value actually used.")] double? timeoutSeconds = null, [Description("Ignore case when matching.")] bool ignoreCase = true, [Description("The tmux socket to read. Omit for the default server.")] @@ -56,13 +59,13 @@ public async Task WaitForTextAsync( IProgress? progress = null, CancellationToken cancellationToken = default) { + ValidateWaitPatterns(patterns, stopPatterns, _policy.MaxBytes); + Regex[] wanted = Compile(patterns, ignoreCase); + Regex[] stops = Compile(stopPatterns, ignoreCase); Server server = await ServerAsync(socketName, cancellationToken).ConfigureAwait(false); Pane pane = await TmuxTargets.PaneAsync(server, paneId, cancellationToken) .ConfigureAwait(false); string id = pane.Id.ToString(); - - Regex[] wanted = Compile(patterns, ignoreCase); - Regex[] stops = Compile(stopPatterns, ignoreCase); TimeSpan budget = _policy.EffectiveTimeout( timeoutSeconds is double seconds ? TimeSpan.FromSeconds(seconds) : null); @@ -75,7 +78,7 @@ public async Task WaitForTextAsync( PaneRead first = await PaneReader.ReadVisibleAsync(pane, null, cancellationToken) .ConfigureAwait(false); - TailCursor cursor = TailCursor.Build(id, first.State, first.CursorRows); + TailCursor cursor = TailCursor.Build(pane, first.State, first.CursorRows); bool alternate = first.State.AlternateScreen; while (true) @@ -96,15 +99,15 @@ public async Task WaitForTextAsync( // Taken before the read, so output arriving during the read wakes // the next wait instead of being slept through. - object? signal = _activity.CaptureSignal(id); + object? signal = _activity.CaptureSignal(pane); PaneRead read = await PaneReader.ReadSinceAsync(pane, cursor, cancellationToken) .ConfigureAwait(false); - cursor = TailCursor.Build(id, read.State, read.CursorRows); + cursor = TailCursor.Build(pane, read.State, read.CursorRows); if (read.Lines.Count > 0) { - if (Match(stops, read.Lines) is string stopped) + if (Match(stops, read.Lines, cancellationToken) is string stopped) { return await FinishAsync( pane, @@ -130,7 +133,7 @@ public async Task WaitForTextAsync( .ConfigureAwait(false); } - if (Match(wanted, read.Lines) is string hit) + if (Match(wanted, read.Lines, cancellationToken) is string hit) { return await FinishAsync( pane, @@ -209,7 +212,7 @@ await _activity.WaitForActivityAsync( + "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.")] string channel, + [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.")] @@ -217,6 +220,7 @@ public async Task WaitForChannelAsync( 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); @@ -267,12 +271,105 @@ patterns is null .Where(each => !string.IsNullOrEmpty(each)) .Select(each => CompilePattern(each, ignoreCase))]; - private static string? Match(Regex[] patterns, IReadOnlyList lines) + internal static void ValidateWaitPatterns( + IReadOnlyList? patterns, + IReadOnlyList? stopPatterns, + int resultMaxBytes) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(resultMaxBytes); + long count = (patterns?.Count ?? 0L) + (stopPatterns?.Count ?? 0L); + if (count > MaximumWaitPatterns) + { + throw new McpException( + $"A pane wait accepts at most {MaximumWaitPatterns} patterns across both lists."); + } + + int totalBytes = 0; + foreach (IReadOnlyList? list in new[] { patterns, stopPatterns }) + { + if (list is null) + { + continue; + } + + foreach (string? pattern in list) + { + if (string.IsNullOrEmpty(pattern)) + { + continue; + } + + if (pattern.Length > MaximumWaitPatternBytes) + { + throw PatternBudgetError(); + } + + int bytes = Encoding.UTF8.GetByteCount(pattern); + if (bytes > MaximumWaitPatternBytes + || bytes > MaximumWaitPatternBytesTotal - totalBytes) + { + throw PatternBudgetError(); + } + + totalBytes += bytes; + var probe = new WaitResult( + "%18446744073709551615", + WaitOutcome.Matched, + pattern, + BoundedText.Empty, + double.MaxValue, + double.MaxValue); + if (Utf8JsonBudget.GetStructuredToolResultByteCount(probe, ToolJson.Options) + > resultMaxBytes) + { + throw new McpException( + "A wait pattern cannot fit in the configured result byte ceiling. " + + $"Use a shorter pattern or raise {ServerPolicy.MaxBytesVariable}."); + } + } + } + + static McpException PatternBudgetError() => new( + $"Pane-wait patterns may use at most {MaximumWaitPatternBytes} UTF-8 bytes each " + + $"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, + IReadOnlyList lines, + CancellationToken cancellationToken = default) { foreach (Regex pattern in patterns) { foreach (string line in lines) { + cancellationToken.ThrowIfCancellationRequested(); try { if (pattern.IsMatch(line)) @@ -303,13 +400,19 @@ private async Task FinishAsync( { IReadOnlyList tail = await PaneReader.CaptureAsync(pane, null, cancellationToken) .ConfigureAwait(false); - return new WaitResult( - PaneId: paneId, - Outcome: outcome, - MatchedPattern: matched, - Tail: BoundedText.Fit(PaneText.Scrub(tail, pane.Width), TailLines, _policy.MaxBytes), - ElapsedSeconds: Math.Round(elapsed.Elapsed.TotalSeconds, 3), - EffectiveTimeoutSeconds: budget.TotalSeconds); + double elapsedSeconds = Math.Round(elapsed.Elapsed.TotalSeconds, 3); + return StructuredTextResultBudget.Fit( + PaneText.Scrub(tail, pane.Width), + TailLines, + _policy.MaxBytes, + content => new WaitResult( + paneId, + outcome, + matched, + content, + elapsedSeconds, + budget.TotalSeconds), + "pane wait"); } /// How much of the pane a wait reports back when it ends. @@ -318,4 +421,8 @@ private async Task FinishAsync( /// wants the pane can read it; a caller who does not should not pay for it. /// private const int TailLines = 20; + 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/ReadTools.cs b/src/LibTmux.Mcp/Tools/ReadTools.cs index 3348553..dad7d23 100644 --- a/src/LibTmux.Mcp/Tools/ReadTools.cs +++ b/src/LibTmux.Mcp/Tools/ReadTools.cs @@ -12,9 +12,9 @@ namespace LibTmux.Mcp; /// tier never reaches the model's list to be called by name. /// /// -/// Every tool answers a record rather than prose, and each is annotated -/// ReadOnly so a client that gates on the hint does not prompt for a -/// listing. +/// Shaped tools answer records rather than prose; tmux_display_message +/// returns the raw format expansion it was asked for. Each tool is annotated +/// ReadOnly so a client that gates on the hint does not prompt for a listing. /// /// [McpServerToolType] diff --git a/src/LibTmux.Mcp/Tools/ToolJson.cs b/src/LibTmux.Mcp/Tools/ToolJson.cs new file mode 100644 index 0000000..9b46b99 --- /dev/null +++ b/src/LibTmux.Mcp/Tools/ToolJson.cs @@ -0,0 +1,15 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using ModelContextProtocol; + +namespace LibTmux.Mcp; + +/// Keeps structured tool results faithful to their advertised schemas. +internal static class ToolJson +{ + /// Serializes required nullable properties instead of dropping them. + internal static JsonSerializerOptions Options { get; } = new(McpJsonUtilities.DefaultOptions) + { + DefaultIgnoreCondition = JsonIgnoreCondition.Never, + }; +} diff --git a/src/LibTmux.Mcp/Tools/WriteTools.Input.cs b/src/LibTmux.Mcp/Tools/WriteTools.Input.cs index e2b30fb..828edf5 100644 --- a/src/LibTmux.Mcp/Tools/WriteTools.Input.cs +++ b/src/LibTmux.Mcp/Tools/WriteTools.Input.cs @@ -1,5 +1,8 @@ using System.ComponentModel; using System.Runtime.Versioning; +using System.Text; +using LibTmux.Internal; +using ModelContextProtocol; using ModelContextProtocol.Server; namespace LibTmux.Mcp; @@ -8,6 +11,16 @@ namespace LibTmux.Mcp; [UnsupportedOSPlatform("windows")] public sealed partial class WriteTools { + internal const string PasteBufferCleanupFailureDataKey = + "LibTmux.Mcp.PasteBufferCleanupFailure"; + internal const string PasteBufferCleanupBufferDataKey = + "LibTmux.Mcp.PasteBufferCleanupBuffer"; + + private static readonly TimeSpan PasteBufferCleanupTimeout = TimeSpan.FromSeconds(5); + private const int MaximumBatchSteps = 64; + private const int MaximumBatchKeyBytes = 65_536; + private const int MaximumStepDelayMilliseconds = 2_000; + /// Sends keys to a pane. /// The text or key name to send. /// The pane, or null for the active one. @@ -22,7 +35,7 @@ public sealed partial class WriteTools /// the point: it is for driving a program's interface. A shell command /// whose result matters belongs in tmux_run. /// - [McpServerTool(Name = "tmux_send_keys", Destructive = false, OpenWorld = true, UseStructuredContent = true)] + [McpServerTool(Name = "tmux_send_keys", Destructive = true, OpenWorld = true, UseStructuredContent = true)] [Description( "Send raw keystrokes to a pane and return immediately. Use for driving an " + "interactive program — a key in vim, a menu choice, Ctrl-C. Set literal=false " @@ -71,13 +84,18 @@ await pane.SendKeysAsync( /// The tmux socket, or null for the default. /// Cancels the tmux commands. /// What was sent. - [McpServerTool(Name = "tmux_send_keys_batch", Destructive = false, OpenWorld = true, UseStructuredContent = true)] + [McpServerTool(Name = "tmux_send_keys_batch", Destructive = true, OpenWorld = true, UseStructuredContent = true)] [Description( "Send several keystrokes to one pane in order, in a single call. Use for a " + "short interactive sequence — open a file, move, type, save — instead of " - + "one call per key.")] + + "one call per key. A batch has at most 64 steps and 64 KiB of UTF-8 text " + + "(or the lower server byte limit). Each delay is 0–2000 ms and all delays " + + "together must fit the server wait ceiling.")] public async Task SendKeysBatchAsync( - [Description("The keystrokes to send, in order.")] IReadOnlyList steps, + [Description( + "The keystrokes to send, in order: 1–64 steps, with no null step or keys. " + + "Combined text is limited to min(LIBTMUX_MCP_MAX_BYTES, 65536) UTF-8 bytes.")] + IReadOnlyList steps, [Description("The pane id, such as %1. Omit for the active pane.")] string? paneId = null, [Description("The tmux socket to use. Omit for the default server.")] @@ -85,24 +103,33 @@ public async Task SendKeysBatchAsync( CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(steps); + ValidateBatch(steps, _policy); Server server = await ServerAsync(socketName, cancellationToken).ConfigureAwait(false); Pane pane = await TmuxTargets.PaneAsync(server, paneId, cancellationToken) .ConfigureAwait(false); - foreach (KeyStep step in steps) + var sequence = new TmuxMutationSequence( + "An earlier key batch step succeeded, but a later step failed. The pane " + + "may already have acted on those keys; do not retry the whole batch."); + for (int index = 0; index < steps.Count; index++) { - cancellationToken.ThrowIfCancellationRequested(); - await pane.SendKeysAsync( - new SendKeysRequest( - text: step.Keys, - enter: step.Enter, - literal: step.Literal), - cancellationToken) + KeyStep step = steps[index]; + await MutateAsync( + sequence, + () => pane.SendKeysAsync( + new SendKeysRequest( + text: step.Keys, + enter: step.Enter, + literal: step.Literal), + cancellationToken), + $"Key batch step {index + 1} may have reached tmux. The pane may " + + "already have acted on it; do not retry the whole batch.") .ConfigureAwait(false); if (step.DelayMilliseconds is int delay and > 0) { - await Task.Delay(Math.Min(delay, 2000), cancellationToken).ConfigureAwait(false); + await sequence.ObserveAsync(() => Task.Delay(delay, cancellationToken)) + .ConfigureAwait(false); } } @@ -111,6 +138,67 @@ await pane.SendKeysAsync( PaneId: pane.Id.ToString()); } + internal static void ValidateBatch(IReadOnlyList steps, ServerPolicy policy) + { + ArgumentNullException.ThrowIfNull(steps); + ArgumentNullException.ThrowIfNull(policy); + if (steps.Count is 0 or > MaximumBatchSteps) + { + throw new McpException( + $"A key batch must contain between 1 and {MaximumBatchSteps} steps."); + } + + int maximumBytes = Math.Min(policy.MaxBytes, MaximumBatchKeyBytes); + int totalBytes = 0; + long totalDelay = 0; + for (int index = 0; index < steps.Count; index++) + { + KeyStep? step = steps[index]; + if (step is null) + { + throw new McpException($"Key batch step {index + 1} is null."); + } + + if (step.Keys is null) + { + throw new McpException($"Key batch step {index + 1} has null keys."); + } + + if (step.Keys.Length > maximumBytes - totalBytes) + { + throw BatchKeysTooLarge(maximumBytes); + } + + int bytes = Encoding.UTF8.GetByteCount(step.Keys); + if (bytes > maximumBytes - totalBytes) + { + throw BatchKeysTooLarge(maximumBytes); + } + + totalBytes += bytes; + int delay = step.DelayMilliseconds ?? 0; + if (delay is < 0 or > MaximumStepDelayMilliseconds) + { + throw new McpException( + $"Key batch step {index + 1} delay must be between 0 and " + + $"{MaximumStepDelayMilliseconds} milliseconds."); + } + + totalDelay += delay; + } + + long maximumDelay = checked((long)Math.Floor(policy.WaitCeiling.TotalMilliseconds)); + if (totalDelay > maximumDelay) + { + throw new McpException( + $"Key batch delays total {totalDelay} milliseconds; this server allows " + + $"at most {maximumDelay} milliseconds per call."); + } + } + + private static McpException BatchKeysTooLarge(int maximumBytes) => + new($"Key batch text may use at most {maximumBytes} UTF-8 bytes in one call."); + /// Pastes text into a pane without the shell reading it as keys. /// The text to paste. /// The pane, or null for the active one. @@ -122,11 +210,12 @@ await pane.SendKeysAsync( /// Bracketed paste tells the program the text was pasted rather than typed, /// which is what stops an editor auto-indenting every line of it. /// - [McpServerTool(Name = "tmux_paste_text", Destructive = false, OpenWorld = true, UseStructuredContent = true)] + [McpServerTool(Name = "tmux_paste_text", Destructive = true, OpenWorld = true, UseStructuredContent = true)] [Description( "Paste a block of text into a pane through a tmux buffer. Use for multi-line " + "text, or anything an editor would mangle if typed — bracketed paste stops " - + "auto-indent. The buffer is deleted afterwards.")] + + "auto-indent. The tool attempts to delete its temporary buffer afterwards; " + + "if cleanup fails, the completed-paste result identifies what remains.")] public async Task PasteTextAsync( [Description("The text to paste.")] string text, [Description("The pane id, such as %1. Omit for the active pane.")] @@ -145,42 +234,94 @@ public async Task PasteTextAsync( .ConfigureAwait(false); string buffer = $"libtmux_mcp_{Guid.NewGuid():N}"[..24]; - await server.SetBufferAsync(text, buffer, cancellationToken: cancellationToken) - .ConfigureAwait(false); + Exception? primaryFailure = null; + Exception? cleanupFailure = null; + bool bufferMayExist = false; try { + cancellationToken.ThrowIfCancellationRequested(); + try + { + await server.SetBufferAsync(text, buffer, cancellationToken: cancellationToken) + .ConfigureAwait(false); + bufferMayExist = true; + } + catch (TmuxOperationCanceledException error) + { + bufferMayExist = error.CommandMayHaveExecuted; + throw; + } + catch (LibTmuxException error) + { + bufferMayExist = error.Dispatch != TmuxDispatchState.NotDispatched; + throw; + } + await pane.PasteBufferAsync( new PasteBufferRequest(name: buffer, bracketed: bracketed), cancellationToken) .ConfigureAwait(false); } + catch (Exception error) + { + primaryFailure = error; + throw; + } finally { - // The buffer is this tool's litter, not the user's clipboard - // history, so it goes whether the paste worked or not. - try - { - await server.DeleteBufferAsync(buffer, cancellationToken).ConfigureAwait(false); - } - catch (LibTmuxException) + if (bufferMayExist) { - // Already gone, or the server went away. Neither is worth - // replacing the caller's real result with. + cleanupFailure = await CleanupPasteBufferAsync(server, buffer, primaryFailure) + .ConfigureAwait(false); } } + if (cleanupFailure is not null) + { + return new ActionResult( + $"Pasted {text.Length} characters into {pane.Id}, but cleanup failed and " + + $"temporary buffer {buffer} may remain. Do not retry the paste. Inspect " + + "with tmux_list_buffers, then remove it manually with " + + $"tmux delete-buffer -b {buffer}.", + PaneId: pane.Id.ToString()); + } + return new ActionResult( $"Pasted {text.Length} characters into {pane.Id}.", PaneId: pane.Id.ToString()); } + private static async Task CleanupPasteBufferAsync( + Server server, + string buffer, + Exception? primaryFailure) + { + using var cleanup = new CancellationTokenSource(PasteBufferCleanupTimeout); + try + { + await server.DeleteBufferAsync(buffer, cleanup.Token).ConfigureAwait(false); + return null; + } + catch (Exception cleanupFailure) + { + if (primaryFailure is null) + { + return cleanupFailure; + } + + primaryFailure.Data[PasteBufferCleanupFailureDataKey] = cleanupFailure; + primaryFailure.Data[PasteBufferCleanupBufferDataKey] = buffer; + return null; + } + } + /// Clears a pane's screen, and optionally its scrollback. /// The pane, or null for the active one. /// Whether scrollback goes too. /// The tmux socket, or null for the default. /// Cancels the tmux commands. /// What was cleared. - [McpServerTool(Name = "tmux_clear_pane", Destructive = false, OpenWorld = false, UseStructuredContent = true)] + [McpServerTool(Name = "tmux_clear_pane", Destructive = true, OpenWorld = false, UseStructuredContent = true)] [Description( "Clear a pane's visible screen, and optionally its scrollback too. Useful " + "before running something whose output you want to read on its own. " @@ -199,10 +340,26 @@ public async Task ClearPaneAsync( Pane pane = await TmuxTargets.PaneAsync(server, paneId, cancellationToken) .ConfigureAwait(false); - await pane.ClearAsync(cancellationToken).ConfigureAwait(false); + var sequence = new TmuxMutationSequence( + "The pane was cleared, but clearing its history failed. The screen may " + + "already have changed; do not retry the whole operation."); + await MutateAsync( + sequence, + async () => + { + _ = await pane.ClearAsync(cancellationToken).ConfigureAwait(false); + }, + "Clearing the pane may have reached tmux. The screen may already have " + + "changed; do not retry until you inspect it.") + .ConfigureAwait(false); if (includeHistory) { - await pane.ClearHistoryAsync(cancellationToken: cancellationToken).ConfigureAwait(false); + await MutateAsync( + sequence, + () => pane.ClearHistoryAsync(cancellationToken: cancellationToken), + "Clearing pane history may have reached tmux. The screen may already " + + "have changed; do not retry until you inspect it.") + .ConfigureAwait(false); } return new ActionResult( diff --git a/src/LibTmux.Mcp/Tools/WriteTools.Jobs.cs b/src/LibTmux.Mcp/Tools/WriteTools.Jobs.cs index a7cd1dd..09f7e3b 100644 --- a/src/LibTmux.Mcp/Tools/WriteTools.Jobs.cs +++ b/src/LibTmux.Mcp/Tools/WriteTools.Jobs.cs @@ -16,13 +16,14 @@ public sealed partial class WriteTools /// The tmux socket, or null for the default. /// Cancels sending the command. /// The handle to collect it with. - [McpServerTool(Name = "tmux_start_job", Destructive = false, OpenWorld = true, UseStructuredContent = true)] + [McpServerTool(Name = "tmux_start_job", Destructive = true, OpenWorld = true, UseStructuredContent = true)] [Description( "Start a shell command in a pane and return a job handle IMMEDIATELY, without " + "waiting. Use for anything that may run longer than a few seconds — a build, " + "a test suite, a deploy — so you can do other work and collect the result " + "later with tmux_job. The command keeps running in the pane regardless of " - + "what you do next.")] + + "what you do next. If cancellation races dispatch, call tmux_list_jobs: " + + "a possibly started command keeps a recoverable handle.")] public async Task StartJobAsync( [Description("The shell command to run.")] string command, [Description("The pane id, such as %1. Omit for the active pane.")] @@ -36,7 +37,13 @@ public async Task StartJobAsync( Server server = await ServerAsync(socketName, cancellationToken).ConfigureAwait(false); Pane pane = await TmuxTargets.PaneAsync(server, paneId, cancellationToken) .ConfigureAwait(false); - return await _jobs.StartAsync(server, pane, command, suppressHistory, cancellationToken) + return await _jobs.StartAsync( + server, + pane, + command, + suppressHistory, + _policy.MaxBytes, + cancellationToken) .ConfigureAwait(false); } @@ -67,15 +74,16 @@ public async Task JobAsync( double? waitSeconds = null, [Description("The most output lines to return, newest kept.")] int? maxLines = null, - [Description("The tmux socket to use. Omit for the default server.")] + [Description( + "The originating tmux socket. Omit to use the endpoint recorded by " + + "tmux_start_job; a supplied socket must match it.")] string? socketName = null, IProgress? progress = null, CancellationToken cancellationToken = default) { - JobInfo job = _jobs.Get(jobId); - Server server = await ServerAsync(socketName, cancellationToken).ConfigureAwait(false); - Pane pane = await TmuxTargets.PaneAsync(server, job.PaneId, cancellationToken) - .ConfigureAwait(false); + JobStore.StoredJob stored = _jobs.Resolve(jobId, socketName); + JobInfo job = stored.Describe(); + Pane pane = stored.Pane; if (waitSeconds is double seconds && job.State == JobState.Running) { @@ -84,29 +92,31 @@ public async Task JobAsync( .WatchAsync(pane, cancellationToken) .ConfigureAwait(false); await WaitForFinishAsync( - jobId, - pane.Id.ToString(), + stored, + pane, budget, progress, cancellationToken) .ConfigureAwait(false); - job = _jobs.Get(jobId); + job = stored.Describe(); } - TailCursor? cursor = TailCursor.Decode(_jobs.CursorFor(jobId)); + using JobStore.StoredJob.OutputLease output = await stored + .AcquireOutputAsync(cancellationToken) + .ConfigureAwait(false); + TailCursor? cursor = TailCursor.Decode(output.Cursor, pane); PaneRead read = cursor is null ? await PaneReader.ReadVisibleAsync(pane, null, cancellationToken).ConfigureAwait(false) : await PaneReader.ReadSinceAsync(pane, cursor, cancellationToken).ConfigureAwait(false); - _jobs.Advance(jobId, TailCursor.Build(pane.Id.ToString(), read.State, read.CursorRows).Encode()); - - return new JobReport( - job, - BoundedText.Fit( - PaneText.Scrub(read.Lines, pane.Width), - maxLines ?? _policy.MaxLines, - _policy.MaxBytes), + JobReport report = FitJobReport( + stored.Describe(), + PaneText.Scrub(read.Lines, pane.Width), + maxLines ?? _policy.MaxLines, read.LinesMissed); + string nextCursor = TailCursor.Build(pane, read.State, read.CursorRows).Encode(); + output.Advance(nextCursor); + return report; } /// Lists the jobs this server still remembers. @@ -116,64 +126,121 @@ await WaitForFinishAsync( "List the background jobs this server started and still remembers, newest " + "first. A job is forgotten when the server restarts, but its command keeps " + "running in its pane.")] - public IReadOnlyList ListJobs() => _jobs.List(); + public JobList ListJobs() => _jobs.List(_policy.MaxBytes); /// Interrupts a job. /// The handle. /// The tmux socket, or null for the default. /// Cancels sending the interrupt. /// The job. - [McpServerTool(Name = "tmux_cancel_job", Destructive = false, OpenWorld = false, UseStructuredContent = true)] + [McpServerTool(Name = "tmux_cancel_job", Destructive = true, OpenWorld = false, UseStructuredContent = true)] [Description( "Interrupt a background job by sending its pane Ctrl-C. This is a request, not " + "a guarantee: a program that ignores SIGINT keeps running. Check the pane's " - + "current_command afterwards to see whether it actually stopped.")] + + "currentCommand afterwards to see whether it actually stopped.")] public async Task CancelJobAsync( [Description("The job handle from tmux_start_job.")] string jobId, - [Description("The tmux socket to use. Omit for the default server.")] + [Description( + "The originating tmux socket. Omit to use the endpoint recorded by " + + "tmux_start_job; a supplied socket must match it.")] string? socketName = null, CancellationToken cancellationToken = default) - { - JobInfo job = _jobs.Get(jobId); - Server server = await ServerAsync(socketName, cancellationToken).ConfigureAwait(false); - Pane pane = await TmuxTargets.PaneAsync(server, job.PaneId, cancellationToken) - .ConfigureAwait(false); - return await _jobs.CancelAsync(pane, jobId, cancellationToken).ConfigureAwait(false); - } + => await _jobs.CancelAsync(jobId, socketName, cancellationToken).ConfigureAwait(false); private async Task WaitForFinishAsync( - string jobId, - string paneId, + JobStore.StoredJob job, + Pane pane, TimeSpan budget, IProgress? progress, CancellationToken cancellationToken) { + string paneId = pane.Id.ToString(); DateTimeOffset started = DateTimeOffset.UtcNow; DateTimeOffset deadline = started + budget; while (DateTimeOffset.UtcNow < deadline) { + if (job.State != JobState.Running) + { + return; + } + ReadTools.Report( progress, DateTimeOffset.UtcNow - started, budget, - $"job {jobId} still running in {paneId}"); - if (_jobs.Get(jobId).State != JobState.Running) + $"job {job.JobId} still running in {paneId}"); + object? signal = _activity.CaptureSignal(pane); + if (job.State != JobState.Running) { return; } - object? signal = _activity.CaptureSignal(paneId); - if (_jobs.Get(jobId).State != JobState.Running) + TimeSpan remaining = deadline - DateTimeOffset.UtcNow; + if (remaining <= TimeSpan.Zero) { return; } - await _activity.WaitForActivityAsync( - paneId, - signal, - deadline - DateTimeOffset.UtcNow, + if (await WaitForTerminalOrActivityAsync( + job, + token => _activity.WaitForActivityAsync( + paneId, + signal, + remaining, + token), cancellationToken) - .ConfigureAwait(false); + .ConfigureAwait(false)) + { + return; + } + } + } + + internal static async Task WaitForTerminalOrActivityAsync( + JobStore.StoredJob job, + Func> waitForActivity, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(job); + ArgumentNullException.ThrowIfNull(waitForActivity); + cancellationToken.ThrowIfCancellationRequested(); + if (job.State != JobState.Running) + { + return true; + } + + using CancellationTokenSource activityCancellation = + CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + Task activity = waitForActivity(activityCancellation.Token); + Task completed = await Task.WhenAny(job.Terminal, activity).ConfigureAwait(false); + if (completed == job.Terminal) + { + await activityCancellation.CancelAsync().ConfigureAwait(false); + try + { + _ = await activity.ConfigureAwait(false); + } + catch (OperationCanceledException) when (activityCancellation.IsCancellationRequested) + { + } + + cancellationToken.ThrowIfCancellationRequested(); + return true; } + + _ = await activity.ConfigureAwait(false); + return job.State != JobState.Running; } + + private JobReport FitJobReport( + JobInfo job, + IReadOnlyList lines, + int maxLines, + bool linesMissed) => + StructuredTextResultBudget.Fit( + lines, + maxLines, + _policy.MaxBytes, + output => new JobReport(job, output, linesMissed), + "job result"); } diff --git a/src/LibTmux.Mcp/Tools/WriteTools.Layout.cs b/src/LibTmux.Mcp/Tools/WriteTools.Layout.cs index cfc50e7..7291b79 100644 --- a/src/LibTmux.Mcp/Tools/WriteTools.Layout.cs +++ b/src/LibTmux.Mcp/Tools/WriteTools.Layout.cs @@ -18,7 +18,7 @@ public sealed partial class WriteTools /// The tmux socket, or null for the default. /// Cancels the tmux command. /// What was created. - [McpServerTool(Name = "tmux_create_session", Destructive = false, OpenWorld = false, UseStructuredContent = true)] + [McpServerTool(Name = "tmux_create_session", Destructive = true, OpenWorld = true, UseStructuredContent = true)] [Description( "Create a detached tmux session and return its ids. Give a width and height " + "when nothing will attach to it: a session with no client keeps tmux's " @@ -50,7 +50,7 @@ public async Task CreateSessionAsync( Pane? active = session.ActivePane; return new ActionResult( - $"Created session {session.Id} named {session.Name}.", + $"Created session {session.Id}.", PaneId: active?.Id.ToString(), WindowId: session.ActiveWindow?.Id.ToString(), SessionId: session.Id.ToString()); @@ -71,7 +71,7 @@ public async Task CreateSessionAsync( /// The tmux socket, or null for the default. /// Cancels the tmux command. /// What was created. - [McpServerTool(Name = "tmux_create_window", Destructive = false, OpenWorld = false, UseStructuredContent = true)] + [McpServerTool(Name = "tmux_create_window", Destructive = true, OpenWorld = true, UseStructuredContent = true)] [Description("Create a window in a tmux session and return its ids.")] public async Task CreateWindowAsync( [Description("A session id such as $0, or its name. Omit for the first session.")] @@ -95,7 +95,7 @@ public async Task CreateWindowAsync( .ConfigureAwait(false); return new ActionResult( - $"Created window {window.Id} named {window.Name} in {owner.Id}.", + $"Created window {window.Id} in {owner.Id}.", PaneId: window.ActivePane?.Id.ToString(), WindowId: window.Id.ToString(), SessionId: owner.Id.ToString()); @@ -110,7 +110,7 @@ public async Task CreateWindowAsync( /// The tmux socket, or null for the default. /// Cancels the tmux command. /// The new pane. - [McpServerTool(Name = "tmux_split_pane", Destructive = false, OpenWorld = false, UseStructuredContent = true)] + [McpServerTool(Name = "tmux_split_pane", Destructive = true, OpenWorld = true, UseStructuredContent = true)] [Description( "Split a pane and return the NEW pane's id. Use that id for what you put in " + "it — pane ids stay valid across layout changes, where window names and " @@ -153,7 +153,7 @@ public async Task SplitPaneAsync( /// The tmux socket, or null for the default. /// Cancels the tmux command. /// What changed. - [McpServerTool(Name = "tmux_select_pane", Destructive = false, Idempotent = true, OpenWorld = false, UseStructuredContent = true)] + [McpServerTool(Name = "tmux_select_pane", Destructive = true, Idempotent = true, OpenWorld = false, UseStructuredContent = true)] [Description( "Make a pane the active one in its window. This changes what a watching human " + "sees; targeting a pane by id does not require selecting it first.")] @@ -175,7 +175,7 @@ public async Task SelectPaneAsync( /// The tmux socket, or null for the default. /// Cancels the tmux command. /// What changed. - [McpServerTool(Name = "tmux_select_window", Destructive = false, Idempotent = true, OpenWorld = false, UseStructuredContent = true)] + [McpServerTool(Name = "tmux_select_window", Destructive = true, Idempotent = true, OpenWorld = false, UseStructuredContent = true)] [Description("Make a window the current one in its session.")] public async Task SelectWindowAsync( [Description("The window id, such as @1.")] string windowId, @@ -200,7 +200,7 @@ public async Task SelectWindowAsync( /// The tmux socket, or null for the default. /// Cancels the tmux command. /// What changed. - [McpServerTool(Name = "tmux_resize_pane", Destructive = false, OpenWorld = false, UseStructuredContent = true)] + [McpServerTool(Name = "tmux_resize_pane", Destructive = true, OpenWorld = false, UseStructuredContent = true)] [Description( "Resize a pane, or zoom it to fill its window. Widening a pane before reading " + "it is the fix for output that comes back wrapped across rows.")] @@ -238,7 +238,7 @@ public async Task ResizePaneAsync( /// The tmux socket, or null for the default. /// Cancels the tmux command. /// What changed. - [McpServerTool(Name = "tmux_select_layout", Destructive = false, OpenWorld = false, UseStructuredContent = true)] + [McpServerTool(Name = "tmux_select_layout", Destructive = true, OpenWorld = false, UseStructuredContent = true)] [Description( "Arrange a window's panes with a named layout — even-horizontal, " + "even-vertical, main-horizontal, main-vertical, tiled — or a layout string " @@ -260,7 +260,7 @@ public async Task SelectLayoutAsync( cancellationToken) .ConfigureAwait(false); return new ActionResult( - $"Arranged {arranged.Id} as {layout ?? "its current layout"}.", + $"Arranged window {arranged.Id}.", WindowId: arranged.Id.ToString()); } @@ -270,7 +270,7 @@ public async Task SelectLayoutAsync( /// The tmux socket, or null for the default. /// Cancels the tmux command. /// What changed. - [McpServerTool(Name = "tmux_rename_session", Destructive = false, Idempotent = true, OpenWorld = false, UseStructuredContent = true)] + [McpServerTool(Name = "tmux_rename_session", Destructive = true, Idempotent = true, OpenWorld = false, UseStructuredContent = true)] [Description("Rename a tmux session. Its id does not change, so anything holding one still works.")] public async Task RenameSessionAsync( [Description("The new name. It cannot contain a colon or a full stop.")] string name, @@ -286,7 +286,7 @@ public async Task RenameSessionAsync( .ConfigureAwait(false); Session renamed = await target.RenameAsync(name, cancellationToken).ConfigureAwait(false); return new ActionResult( - $"{renamed.Id} is now named {renamed.Name}.", + $"Renamed session {renamed.Id}.", SessionId: renamed.Id.ToString()); } @@ -296,7 +296,7 @@ public async Task RenameSessionAsync( /// The tmux socket, or null for the default. /// Cancels the tmux command. /// What changed. - [McpServerTool(Name = "tmux_rename_window", Destructive = false, Idempotent = true, OpenWorld = false, UseStructuredContent = true)] + [McpServerTool(Name = "tmux_rename_window", Destructive = true, Idempotent = true, OpenWorld = false, UseStructuredContent = true)] [Description("Rename a tmux window. Its id does not change.")] public async Task RenameWindowAsync( [Description("The new name.")] string name, @@ -312,7 +312,7 @@ public async Task RenameWindowAsync( .ConfigureAwait(false); Window renamed = await window.RenameAsync(name, cancellationToken).ConfigureAwait(false); return new ActionResult( - $"{renamed.Id} is now named {renamed.Name}.", + $"Renamed window {renamed.Id}.", WindowId: renamed.Id.ToString()); } @@ -322,7 +322,7 @@ public async Task RenameWindowAsync( /// The tmux socket, or null for the default. /// Cancels the tmux command. /// What changed. - [McpServerTool(Name = "tmux_set_pane_title", Destructive = false, Idempotent = true, OpenWorld = false, UseStructuredContent = true)] + [McpServerTool(Name = "tmux_set_pane_title", Destructive = true, Idempotent = true, OpenWorld = false, UseStructuredContent = true)] [Description( "Set a pane's title. Useful for labelling panes you created so a human " + "watching can tell which is which.")] @@ -339,7 +339,7 @@ public async Task SetPaneTitleAsync( Pane pane = await TmuxTargets.PaneAsync(server, paneId, cancellationToken) .ConfigureAwait(false); Pane titled = await pane.SetTitleAsync(title, cancellationToken).ConfigureAwait(false); - return new ActionResult($"{titled.Id} is now titled {title}.", PaneId: titled.Id.ToString()); + return new ActionResult($"Set the title of {titled.Id}.", PaneId: titled.Id.ToString()); } /// Restarts the program in a pane. @@ -348,7 +348,7 @@ public async Task SetPaneTitleAsync( /// The tmux socket, or null for the default. /// Cancels the tmux command. /// What changed. - [McpServerTool(Name = "tmux_respawn_pane", Destructive = false, OpenWorld = true, UseStructuredContent = true)] + [McpServerTool(Name = "tmux_respawn_pane", Destructive = true, OpenWorld = true, UseStructuredContent = true)] [Description( "Restart the program in a pane, keeping the pane and its id. Use to bring back " + "a pane whose program exited, or to restart a server in place. Any " diff --git a/src/LibTmux.Mcp/Tools/WriteTools.Mutation.cs b/src/LibTmux.Mcp/Tools/WriteTools.Mutation.cs new file mode 100644 index 0000000..6eaeef5 --- /dev/null +++ b/src/LibTmux.Mcp/Tools/WriteTools.Mutation.cs @@ -0,0 +1,27 @@ +using System.Runtime.Versioning; +using LibTmux.Internal; + +namespace LibTmux.Mcp; + +/// Failure semantics shared by composite MCP mutations. +[UnsupportedOSPlatform("windows")] +public sealed partial class WriteTools +{ + private static async Task MutateAsync( + TmuxMutationSequence sequence, + Func mutation, + string ambiguousMessage) + { + try + { + await sequence.MutateAsync(mutation).ConfigureAwait(false); + } + catch (TmuxOperationCanceledException error) when (error.CommandMayHaveExecuted) + { + throw new LibTmuxException( + ambiguousMessage, + TmuxDispatchState.Unknown, + error); + } + } +} diff --git a/src/LibTmux.Mcp/Tools/WriteTools.Run.cs b/src/LibTmux.Mcp/Tools/WriteTools.Run.cs index 4ee5471..f7deda1 100644 --- a/src/LibTmux.Mcp/Tools/WriteTools.Run.cs +++ b/src/LibTmux.Mcp/Tools/WriteTools.Run.cs @@ -2,6 +2,7 @@ using System.Diagnostics; using System.Globalization; using System.Runtime.Versioning; +using LibTmux.Internal; using ModelContextProtocol; using ModelContextProtocol.Server; @@ -11,6 +12,10 @@ namespace LibTmux.Mcp; [UnsupportedOSPlatform("windows")] public sealed partial class WriteTools { + private static readonly TimeSpan StatusCleanupTimeout = TimeSpan.FromSeconds(5); + private static readonly TimeSpan StatusCleanupMargin = TimeSpan.FromMinutes(1); + internal static readonly TimeSpan JobStatusMarkerLifetime = TimeSpan.FromMinutes(11); + /// Runs a command in a pane and waits for it to finish. /// The shell command. /// The pane, or null for the active one. @@ -27,21 +32,26 @@ public sealed partial class WriteTools /// $?, so "it finished" and "it exited 1" are facts rather than /// readings of a prompt this tool would have to recognise. /// - [McpServerTool(Name = "tmux_run", Destructive = false, OpenWorld = true, UseStructuredContent = true)] + [McpServerTool(Name = "tmux_run", Destructive = true, OpenWorld = true, UseStructuredContent = true)] [Description( "Run a shell command in a pane, wait for it to finish, and report its real " + "exit status and output. This is the tool for 'run X and tell me if it " + "worked'. Do NOT send keys and then poll a capture in a loop — this waits " + "deterministically and costs one call. The command runs in a subshell, so " - + "cd and export do not persist. If it may outlast the timeout, use " - + "tmux_start_job instead and collect it later.")] + + "cd and export do not persist. Output starts at an authenticated position " + + "captured before dispatch; check linesMissed and anchorLost. If it may " + + "outlast the timeout, use tmux_start_job instead and collect it later. A " + + "timed-out command MAY STILL BE RUNNING; inspect it and do not retry it.")] public async Task RunAsync( - [Description("The shell command to run.")] string command, + [Description( + "The shell command to run, at most LIBTMUX_MCP_MAX_BYTES UTF-8 bytes. " + + "Put longer scripts in a file and run that file.")] + string command, [Description("The pane id, such as %1. Omit for the active pane.")] string? paneId = null, [Description( "Seconds to wait. Lowered to the server's ceiling; read " - + "effective_timeout_seconds for the value actually used.")] + + "effectiveTimeoutSeconds for the value actually used.")] double? timeoutSeconds = null, [Description("The most output lines to return, newest kept.")] int? maxLines = null, @@ -56,46 +66,105 @@ public async Task RunAsync( CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(command); + ValidateRunCommand(command, _policy.MaxBytes); Server server = await ServerAsync(socketName, cancellationToken).ConfigureAwait(false); Pane pane = await TmuxTargets.PaneAsync(server, paneId, cancellationToken) .ConfigureAwait(false); TimeSpan budget = _policy.EffectiveTimeout( timeoutSeconds is double seconds ? TimeSpan.FromSeconds(seconds) : null); + PaneRead baselineRead = await PaneReader + .ReadVisibleAsync(pane, null, cancellationToken) + .ConfigureAwait(false); + string baselineToken = TailCursor + .Build(pane, baselineRead.State, baselineRead.CursorRows) + .Encode(); + TailCursor baseline = TailCursor.Decode(baselineToken, pane)!; RunToken token = RunToken.Create(); Stopwatch elapsed = Stopwatch.StartNew(); - await SendRunPayloadAsync(server, pane, command, token, suppressHistory, cancellationToken) - .ConfigureAwait(false); - - bool timedOut = !await TickWhileAsync( - AwaitChannelAsync(server, token.Channel, budget, cancellationToken), - progress, - elapsed, - budget, - $"running in {pane.Id}", - cancellationToken) - .ConfigureAwait(false); - elapsed.Stop(); + var sequence = new TmuxMutationSequence( + "The command was sent, but observing its result failed. It may still be " + + "running or may already have finished; do not retry until you inspect the pane."); + bool payloadMayHaveReachedTmux = false; + try + { + try + { + await sequence.MutateAsync( + () => SendRunPayloadAsync( + server, + pane, + command, + token, + suppressHistory, + _policy.WaitCeiling + StatusCleanupMargin, + cancellationToken)) + .ConfigureAwait(false); + payloadMayHaveReachedTmux = true; + } + catch (TmuxOperationCanceledException error) when (error.CommandMayHaveExecuted) + { + payloadMayHaveReachedTmux = true; + throw new LibTmuxException( + "The command may have reached tmux before cancellation. Do not retry " + + "until you inspect the pane.", + TmuxDispatchState.Unknown, + error); + } + catch (LibTmuxException error) + when (error.Dispatch != TmuxDispatchState.NotDispatched) + { + payloadMayHaveReachedTmux = true; + throw; + } - int? status = timedOut - ? null - : await ReadStatusAsync(pane, token, cancellationToken).ConfigureAwait(false); + bool timedOut = !await sequence.ObserveAsync(() => TickWhileAsync( + AwaitChannelAsync(server, token.Channel, budget, cancellationToken), + progress, + elapsed, + budget, + $"running in {pane.Id}", + cancellationToken)) + .ConfigureAwait(false); + elapsed.Stop(); - IReadOnlyList lines = await pane.CaptureAsync( - new CapturePaneRequest(joinWrappedLines: true), - cancellationToken) - .ConfigureAwait(false); + int? status = timedOut + ? null + : await sequence + .ObserveAsync(() => ReadStatusAsync(pane, token, cancellationToken)) + .ConfigureAwait(false); + PaneRead read = await sequence + .ObserveAsync(() => PaneReader.ReadSinceAsync( + pane, + baseline, + cancellationToken)) + .ConfigureAwait(false); - return new RunResult( - PaneId: pane.Id.ToString(), - ExitStatus: status, - TimedOut: timedOut, - Output: BoundedText.Fit( - PaneText.Scrub(lines, pane.Width), + string id = pane.Id.ToString(); + double elapsedSeconds = Math.Round(elapsed.Elapsed.TotalSeconds, 3); + return sequence.Observe(() => StructuredTextResultBudget.Fit( + PaneText.Scrub(read.Lines, pane.Width), maxLines ?? _policy.MaxLines, - _policy.MaxBytes), - ElapsedSeconds: Math.Round(elapsed.Elapsed.TotalSeconds, 3), - EffectiveTimeoutSeconds: budget.TotalSeconds); + _policy.MaxBytes, + content => new RunResult( + id, + status, + timedOut, + content, + elapsedSeconds, + budget.TotalSeconds, + read.LinesMissed, + read.AnchorLost), + "command result")); + } + finally + { + elapsed.Stop(); + if (payloadMayHaveReachedTmux) + { + await CleanupStatusMarkerAsync(pane, token).ConfigureAwait(false); + } + } } /// Names the private channel and option one run uses. @@ -124,8 +193,12 @@ internal static async Task SendRunPayloadAsync( string command, RunToken token, bool suppressHistory, + TimeSpan statusMarkerLifetime, CancellationToken cancellationToken) { + ArgumentOutOfRangeException.ThrowIfLessThanOrEqual( + statusMarkerLifetime, + TimeSpan.Zero); string statusCommand = TmuxCommandLine( server, "set-option", @@ -134,11 +207,27 @@ internal static async Task SendRunPayloadAsync( pane.Id.ToString(), token.StatusOption); string signalCommand = TmuxCommandLine(server, "wait-for", "-S", token.Channel); + string unsetStatusCommand = TmuxCommandLine( + server, + "set-option", + "-p", + "-u", + "-q", + "-t", + pane.Id.ToString(), + token.StatusOption); + string cleanupDelay = ((long)Math.Ceiling(statusMarkerLifetime.TotalSeconds)) + .ToString(CultureInfo.InvariantCulture); + string scheduleCleanupCommand = TmuxCommandLine( + server, + "run-shell", + "-b", + "-d", + cleanupDelay, + unsetStatusCommand); - // The command goes in a subshell so that its own syntax cannot run into - // the bookkeeping after it: an unbalanced quote or a trailing operator - // would otherwise swallow the status capture and the rendezvous, and the - // wait would hang for the whole budget with nothing to show. + // The subshell isolates command syntax from status capture and rendezvous; + // otherwise a trailing operator can swallow both and leave the wait hanging. string payload = string.Concat( suppressHistory ? " " : string.Empty, "(\n", @@ -146,6 +235,8 @@ internal static async Task SendRunPayloadAsync( "\n); __lt=$?; ", statusCommand, " \"$__lt\"; ", + scheduleCleanupCommand, + "; ", signalCommand); await pane.SendKeysAsync( @@ -154,6 +245,28 @@ await pane.SendKeysAsync( .ConfigureAwait(false); } + internal static void ValidateRunCommand(string command, int maximumBytes) + { + ArgumentException.ThrowIfNullOrWhiteSpace(command); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maximumBytes); + if (command.Length > maximumBytes) + { + throw RunCommandTooLarge( + $"The command is more than {maximumBytes} UTF-8 bytes"); + } + + int commandBytes = System.Text.Encoding.UTF8.GetByteCount(command); + if (commandBytes > maximumBytes) + { + throw RunCommandTooLarge( + $"The command is {commandBytes} UTF-8 bytes; the input ceiling is " + + maximumBytes.ToString(CultureInfo.InvariantCulture)); + } + } + + private static McpException RunCommandTooLarge(string size) => + new(size + ". Put a longer script in a file and run that file instead."); + /// Reports progress on a beat while one wait runs. /// The wait to watch. /// Where to report, or null when the client asked for none. @@ -221,31 +334,41 @@ await server.WaitForAsync(new WaitForRequest(channel, TmuxWaitMode.Wait), expiry RunToken token, CancellationToken cancellationToken) { - IReadOnlyList options = await pane.Options - .GetAsync(new GetOptionRequest(token.StatusOption, quiet: true), cancellationToken) - .ConfigureAwait(false); + try + { + IReadOnlyList options = await pane.Options + .GetAsync(new GetOptionRequest(token.StatusOption, quiet: true), cancellationToken) + .ConfigureAwait(false); - int? status = options.Count > 0 - && int.TryParse( - options[0].Value.Raw, - NumberStyles.Integer, - CultureInfo.InvariantCulture, - out int parsed) - ? parsed - : null; + return options.Count > 0 + && int.TryParse( + options[0].Value.Raw, + NumberStyles.Integer, + CultureInfo.InvariantCulture, + out int parsed) + ? parsed + : null; + } + finally + { + await CleanupStatusMarkerAsync(pane, token).ConfigureAwait(false); + } + } + private static async Task CleanupStatusMarkerAsync(Pane pane, RunToken token) + { + using var cleanup = new CancellationTokenSource(StatusCleanupTimeout); try { await pane.Options - .UnsetAsync(new UnsetOptionRequest(token.StatusOption, quiet: true), cancellationToken) + .UnsetAsync( + new UnsetOptionRequest(token.StatusOption, quiet: true), + cleanup.Token) .ConfigureAwait(false); } - catch (LibTmuxException) + catch (Exception) { - // The option is scoped to a pane that may already be gone. Leaving - // one behind is untidy; failing the call over it would be worse. + // The payload also schedules a bounded cleanup inside tmux. } - - return status; } } diff --git a/src/LibTmux.Mcp/packages.lock.json b/src/LibTmux.Mcp/packages.lock.json index 1f4e3cb..a7a55a7 100644 --- a/src/LibTmux.Mcp/packages.lock.json +++ b/src/LibTmux.Mcp/packages.lock.json @@ -698,12 +698,12 @@ "libtmux": { "type": "Project", "dependencies": { - "Microsoft.Extensions.Logging.Abstractions": "[10.0.11, )" + "Microsoft.Extensions.Logging.Abstractions": "[8.0.0, )" } }, "Microsoft.Extensions.Logging.Abstractions": { "type": "CentralTransitive", - "requested": "[10.0.11, )", + "requested": "[8.0.0, )", "resolved": "10.0.11", "contentHash": "Ljd0Uxoq5XpScD2Bg0nM/r3mwx7Ao5Uq24eo2ARxbGvqJ7Zht6rt2cJtwVRH4Cv+1ZVMdXz6TB43KbpmsxRrvQ==", "dependencies": { diff --git a/src/LibTmux.Query.Json/packages.lock.json b/src/LibTmux.Query.Json/packages.lock.json index aca819d..c4a371b 100644 --- a/src/LibTmux.Query.Json/packages.lock.json +++ b/src/LibTmux.Query.Json/packages.lock.json @@ -50,28 +50,22 @@ }, "Microsoft.Extensions.DependencyInjection.Abstractions": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "/a1aJz4m7ylhEDf25ugQChLQoN5XwoGjWw/BoR/ZWWKsO1v4DdJElS1uyngahz4B/eOzjFk1KNTkarRLE5wsIg==" - }, - "System.Diagnostics.DiagnosticSource": { - "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "QoXcAdDhBudxorzYF1F5Uo7FY0CSWgmTjqVRJjcQaYAkbO3dCPXDJajJwyApTGHCJ+T5PfVyKZqXEKZ8PH+UWQ==" + "resolved": "8.0.0", + "contentHash": "cjWrLkJXK0rs4zofsK4bSdg+jhDLTaxrkXu4gS6Y7MAlCvRyNNgwY/lJi5RDlQOnSZweHqoyvgvbdvQsRIW+hg==" }, "libtmux": { "type": "Project", "dependencies": { - "Microsoft.Extensions.Logging.Abstractions": "[10.0.11, )" + "Microsoft.Extensions.Logging.Abstractions": "[8.0.0, )" } }, "Microsoft.Extensions.Logging.Abstractions": { "type": "CentralTransitive", - "requested": "[10.0.11, )", - "resolved": "10.0.11", - "contentHash": "Ljd0Uxoq5XpScD2Bg0nM/r3mwx7Ao5Uq24eo2ARxbGvqJ7Zht6rt2cJtwVRH4Cv+1ZVMdXz6TB43KbpmsxRrvQ==", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "arDBqTgFCyS0EvRV7O3MZturChstm50OJ0y9bDJvAcmEPJm0FFpFyjU/JLYyStNGGey081DvnQYlncNX5SJJGA==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", - "System.Diagnostics.DiagnosticSource": "10.0.11" + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0" } } } diff --git a/src/LibTmux.Workspace/packages.lock.json b/src/LibTmux.Workspace/packages.lock.json index 00dbb57..11dad05 100644 --- a/src/LibTmux.Workspace/packages.lock.json +++ b/src/LibTmux.Workspace/packages.lock.json @@ -38,28 +38,22 @@ }, "Microsoft.Extensions.DependencyInjection.Abstractions": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "/a1aJz4m7ylhEDf25ugQChLQoN5XwoGjWw/BoR/ZWWKsO1v4DdJElS1uyngahz4B/eOzjFk1KNTkarRLE5wsIg==" - }, - "System.Diagnostics.DiagnosticSource": { - "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "QoXcAdDhBudxorzYF1F5Uo7FY0CSWgmTjqVRJjcQaYAkbO3dCPXDJajJwyApTGHCJ+T5PfVyKZqXEKZ8PH+UWQ==" + "resolved": "8.0.0", + "contentHash": "cjWrLkJXK0rs4zofsK4bSdg+jhDLTaxrkXu4gS6Y7MAlCvRyNNgwY/lJi5RDlQOnSZweHqoyvgvbdvQsRIW+hg==" }, "libtmux": { "type": "Project", "dependencies": { - "Microsoft.Extensions.Logging.Abstractions": "[10.0.11, )" + "Microsoft.Extensions.Logging.Abstractions": "[8.0.0, )" } }, "Microsoft.Extensions.Logging.Abstractions": { "type": "CentralTransitive", - "requested": "[10.0.11, )", - "resolved": "10.0.11", - "contentHash": "Ljd0Uxoq5XpScD2Bg0nM/r3mwx7Ao5Uq24eo2ARxbGvqJ7Zht6rt2cJtwVRH4Cv+1ZVMdXz6TB43KbpmsxRrvQ==", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "arDBqTgFCyS0EvRV7O3MZturChstm50OJ0y9bDJvAcmEPJm0FFpFyjU/JLYyStNGGey081DvnQYlncNX5SJJGA==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", - "System.Diagnostics.DiagnosticSource": "10.0.11" + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0" } } } diff --git a/src/LibTmux/Connection/TmuxConnection.cs b/src/LibTmux/Connection/TmuxConnection.cs index a857049..a609d08 100644 --- a/src/LibTmux/Connection/TmuxConnection.cs +++ b/src/LibTmux/Connection/TmuxConnection.cs @@ -1,9 +1,5 @@ -using System.Collections.ObjectModel; using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; using System.Globalization; -using System.Runtime.Versioning; -using System.Text; using Microsoft.Extensions.Logging; namespace LibTmux.Internal; @@ -11,79 +7,114 @@ namespace LibTmux.Internal; internal sealed class TmuxConnection { private const string GenerationFormat = "#{pid}:#{start_time}"; - private const string DefaultSocketRoot = "/tmp"; private readonly Func> _execute; - private readonly EndpointIdentity _endpointIdentity; - private readonly Func _markerFactory; + private readonly Func> + _executeVersion; + 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; - [SuppressMessage( - "Interoperability", - "CA1416:Validate platform compatibility", - Justification = "Stored delegates are invoked only by guarded process-backed members.")] internal TmuxConnection(ServerConnectionOptions options) - : this(Resolve(options), execute: null, markerFactory: null) + : this(TmuxConnectionEndpoint.Resolve(options), execute: null, markerFactory: null) { } internal TmuxConnection( ServerConnectionOptions options, Func> execute, - Func? markerFactory = null) - : this(Resolve(options), execute, markerFactory) + Func? markerFactory = null, + TmuxImplementation implementation = TmuxImplementation.Tmux) + : this(TmuxConnectionEndpoint.Resolve(options), execute, markerFactory, implementation) { } - [SuppressMessage( - "Interoperability", - "CA1416:Validate platform compatibility", - Justification = "Stored delegates are invoked only by guarded process-backed members.")] private TmuxConnection( - ResolvedConnection resolved, + ResolvedTmuxConnection resolved, Func>? execute, - Func? markerFactory) + Func? markerFactory, + TmuxImplementation implementation = TmuxImplementation.Unknown) { 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: startInfo => - { - ApplyChildEnvironment(startInfo, resolved.ChildEnvironment); - return Process.Start(startInfo) - ?? throw new InvalidOperationException("The tmux client process did not start."); - }); + launcher: Launch, + beforeStart: VerifyBeforeStartAsync); + var versionTransport = new TmuxProcessTransport( + Options.TmuxBinaryPath, + launcher: Launch, + beforeStart: VerifyBeforeStartAsync); _execute = (request, cancellationToken) => - { - PlatformGuard.ThrowIfWindows(); - return transport.ExecuteAsync(request, cancellationToken); - }; + transport.ExecuteAsync(request, cancellationToken); + _executeVersion = (request, cancellationToken) => + versionTransport.ExecuteAsync(request, cancellationToken); } else { _execute = execute; + _executeVersion = execute; } + _psmuxRouter = new PsmuxSessionRouter(ExecuteRawSingleAsync); + _entityLookup = new TmuxEntityLookup(ExecuteSingleAsync); + CommandContext = Options.Logger is ILogger logger ? new TmuxCommandContext(logger, Options.SocketName ?? Options.SocketPath) : null; ServerDispatcher = new TmuxCommandDispatcher( - (arguments, cancellationToken) => - { - PlatformGuard.ThrowIfWindows(); - return ExecuteSingleAsync(arguments, cancellationToken); - }, + ExecuteSingleAsync, CommandContext, - (commands, cancellationToken) => - { - PlatformGuard.ThrowIfWindows(); - return _execute(TmuxCommandRequest.Group([.. commands]), cancellationToken); - }); - _markerFactory = markerFactory ?? (static () => $"libtmux_stale_{Guid.NewGuid():N}"); + ExecuteGroupAsync); + _generationGuard = new TmuxGenerationGuard( + _execute, + markerFactory ?? (static () => $"libtmux_stale_{Guid.NewGuid():N}")); } internal ServerConnectionOptions Options { get; } @@ -94,6 +125,11 @@ private TmuxConnection( internal TmuxCommandContext? CommandContext { get; } + internal bool IsPsmux => CurrentImplementation is TmuxImplementation.Psmux; + + private TmuxImplementation CurrentImplementation => + (TmuxImplementation)Volatile.Read(ref _implementation); + internal bool HasSameEndpoint(TmuxConnection other) { ArgumentNullException.ThrowIfNull(other); @@ -102,14 +138,34 @@ internal bool HasSameEndpoint(TmuxConnection other) internal int GetEndpointHashCode() => _endpointIdentity.GetHashCode(); - [UnsupportedOSPlatform("windows")] + internal string GetEndpointFingerprint() => _endpointIdentity.Fingerprint(); + + /// The socket this connection resolved to, not what was asked for. + /// + /// A name factory or LIBTMUX_SOCKET_NAME leaves the options empty, so + /// anything that records or asserts an endpoint has to read it from here. + /// + internal (string? SocketName, string? SocketPath) ResolvedSocket => + (_resolvedSocketName, _resolvedSocketPath); + internal async Task<(ServerGeneration Generation, string RawVersion)> DiscoverAsync( CancellationToken cancellationToken) { - PlatformGuard.ThrowIfWindows(); - TmuxCommandResult generationResult = await ExecuteSingleAsync( - ["display-message", "-p", GenerationFormat], - cancellationToken).ConfigureAwait(false); + 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) { @@ -117,104 +173,39 @@ internal bool HasSameEndpoint(TmuxConnection other) } ServerGeneration generation = ParseGeneration(generationResult.StandardOutputLines[0]); - TmuxCommandResult versionResult = await ExecuteSingleAsync( - ["-V"], - cancellationToken).ConfigureAwait(false); - EnsureSuccessful(versionResult, "tmux version discovery"); - if (versionResult.StandardOutputLines.Count != 1) + string? rawVersion = Volatile.Read(ref _detectedVersionLine); + if (rawVersion is null) { - throw new InvalidDataException("tmux did not report exactly one version line."); - } - - return (generation, versionResult.StandardOutputLines[0]); - } - - [UnsupportedOSPlatform("windows")] - internal async Task<(ServerGeneration Generation, SessionId Id)?> FindSessionAsync( - SessionId id, - CancellationToken cancellationToken) - { - PlatformGuard.ThrowIfWindows(); - TmuxCommandResult result = await ExecuteSingleAsync( - ["list-sessions", "-F", $"{GenerationFormat}\t#{{session_id}}"], - cancellationToken).ConfigureAwait(false); - EnsureSuccessful(result, "session lookup"); - foreach (string line in result.StandardOutputLines) - { - (ServerGeneration Generation, string Text) fields = ParseIdentityRow(line, "session"); - if (!SessionId.TryParse(fields.Text, out SessionId candidate)) + (TmuxImplementation detected, rawVersion) = await DetectImplementationAsync( + cancellationToken) + .ConfigureAwait(false); + if (detected != implementation) { - throw new InvalidDataException("tmux reported a malformed session identifier."); + throw new InvalidDataException( + "The selected multiplexer changed implementation during discovery."); } - if (candidate == id) - { - return (fields.Generation, candidate); - } + PublishImplementation(detected, rawVersion); } - return null; + return (generation, rawVersion); } - [UnsupportedOSPlatform("windows")] - internal async Task<(ServerGeneration Generation, WindowId Id)?> FindWindowAsync( - WindowId id, - CancellationToken cancellationToken) - { - PlatformGuard.ThrowIfWindows(); - TmuxCommandResult result = await ExecuteSingleAsync( - ["list-windows", "-a", "-F", $"{GenerationFormat}\t#{{window_id}}"], - cancellationToken).ConfigureAwait(false); - EnsureSuccessful(result, "window lookup"); - var seen = new HashSet<(ServerGeneration Generation, WindowId Id)>(); - foreach (string line in result.StandardOutputLines) - { - (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; - } - } + internal Task<(ServerGeneration Generation, SessionId Id)?> FindSessionAsync( + SessionId id, + CancellationToken cancellationToken) => + _entityLookup.FindSessionAsync(id, cancellationToken); - return null; - } + internal Task<(ServerGeneration Generation, WindowId Id)?> FindWindowAsync( + WindowId id, + CancellationToken cancellationToken) => + _entityLookup.FindWindowAsync(id, cancellationToken); - [UnsupportedOSPlatform("windows")] - internal async Task<(ServerGeneration Generation, PaneId Id)?> FindPaneAsync( + internal Task<(ServerGeneration Generation, PaneId Id)?> FindPaneAsync( PaneId id, - CancellationToken cancellationToken) - { - PlatformGuard.ThrowIfWindows(); - TmuxCommandResult result = await ExecuteSingleAsync( - ["list-panes", "-a", "-F", $"{GenerationFormat}\t#{{pane_id}}"], - cancellationToken).ConfigureAwait(false); - EnsureSuccessful(result, "pane lookup"); - var seen = new HashSet<(ServerGeneration Generation, PaneId Id)>(); - foreach (string line in result.StandardOutputLines) - { - (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; - } + CancellationToken cancellationToken) => + _entityLookup.FindPaneAsync(id, cancellationToken); - [UnsupportedOSPlatform("windows")] internal TmuxCommandDispatcher CreateEntityDispatcher(ServerGeneration generation) { ValidateLiveGeneration(generation); @@ -248,368 +239,269 @@ internal static ServerGeneration ParseGeneration(string text) internal static void ApplyChildEnvironment( ProcessStartInfo startInfo, - IReadOnlyDictionary? childEnvironment) - { - ArgumentNullException.ThrowIfNull(startInfo); - startInfo.Environment.Remove("TMUX"); - if (childEnvironment is null) - { - return; - } + IReadOnlyDictionary? childEnvironment, + bool forwardPsmuxDataDirectoryThroughWsl = false) => + PsmuxProcessEnvironment.Apply( + startInfo, + childEnvironment, + forwardPsmuxDataDirectoryThroughWsl); - foreach ((string key, string? value) in childEnvironment) - { - ArgumentException.ThrowIfNullOrWhiteSpace(key); - if (value is null) - { - startInfo.Environment.Remove(key); - } - else - { - startInfo.Environment[key] = value; - } - } - } + /// Runs one command under a generation guard. + private Task ExecuteGuardedAsync( + ServerGeneration expected, + IReadOnlyList logicalArguments, + CancellationToken cancellationToken) => + ExecuteGuardedGroupAsync(expected, [logicalArguments], cancellationToken); - private static string[] BuildPrefixArguments( - ServerConnectionOptions options, - string? socketPath, - string? socketName) + /// 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) { - var arguments = new List(); - switch (options.ColorMode) + ValidateLiveGeneration(expected); + ArgumentNullException.ThrowIfNull(commands); + if (commands.Count == 0) { - case TmuxColorMode.Default: - break; - case TmuxColorMode.Colors256: - arguments.Add("-2"); - break; - case TmuxColorMode.TrueColor: - arguments.Add("-T"); - arguments.Add("RGB"); - break; - default: - throw new ArgumentOutOfRangeException( - nameof(options), - options.ColorMode, - "The tmux color mode is not defined."); + throw new InvalidOperationException("A guarded run needs at least one command."); } - if (options.ConfigurationFile is not null) + foreach (IReadOnlyList command in commands) { - arguments.Add("-f"); - arguments.Add(options.ConfigurationFile); + TmuxCommandDispatcher.ValidateArguments(command); } - if (socketPath is not null) + TmuxImplementation implementation = await EnsureImplementationAsync(cancellationToken) + .ConfigureAwait(false); + if (implementation is TmuxImplementation.Psmux) { - arguments.Add("-S"); - arguments.Add(socketPath); - } - else if (socketName is not null) - { - arguments.Add("-L"); - arguments.Add(socketName); + return await _psmuxRouter.ExecuteGuardedAsync( + expected, + commands, + cancellationToken) + .ConfigureAwait(false); } - return [.. arguments]; + return await _generationGuard.ExecuteAsync(expected, commands, cancellationToken) + .ConfigureAwait(false); } - private static ResolvedConnection Resolve(ServerConnectionOptions options) + private static void ValidateLiveGeneration(ServerGeneration generation) { - ArgumentNullException.ThrowIfNull(options); - - bool chosen = options.SocketPath is not null - || options.SocketName is not null - || options.SocketNameFactory is not null; - - string? socketPath = options.SocketPath is not null - ? Path.GetFullPath(options.SocketPath) - : NormalizeSocketPath( - chosen ? null : ReadVariable(options.ChildEnvironment, SocketPathVariable)); - string? socketName = null; - IReadOnlyDictionary? childEnvironment = options.ChildEnvironment; - EndpointIdentity endpointIdentity; - if (socketPath is null) - { - socketName = options.SocketName; - if (socketName is null && options.SocketNameFactory is not null) - { - socketName = options.SocketNameFactory(); - if (string.IsNullOrWhiteSpace(socketName)) - { - throw new InvalidOperationException( - "The selected socket-name factory returned no usable name."); - } - } - - socketName ??= chosen - ? null - : ReadVariable(options.ChildEnvironment, SocketNameVariable); - socketName ??= "default"; - ResolvedSocketRoot socketRoot = ResolveSocketRoot(options.ChildEnvironment); - childEnvironment = FreezeSocketRoot( - options.ChildEnvironment, - socketRoot.EnvironmentValue); - endpointIdentity = EndpointIdentity.ForName(socketRoot.Identity, socketName); - } - else + if (generation.ProcessId <= 0 || generation.StartTime <= 0) { - endpointIdentity = EndpointIdentity.ForPath(socketPath); + throw new ArgumentException("A live handle requires a positive server generation.", nameof(generation)); } - - return new ResolvedConnection( - options, - BuildPrefixArguments(options, socketPath, socketName), - endpointIdentity, - childEnvironment); } - /// Names the socket every unqualified connection should use. - private const string SocketNameVariable = "LIBTMUX_SOCKET_NAME"; - - /// Locates the socket every unqualified connection should use. - private const string SocketPathVariable = "LIBTMUX_SOCKET_PATH"; - - /// Reads a variable the child would see, falling back to this process. - /// The child environment is what this connection's clients run with. - private static string? ReadVariable( - IReadOnlyDictionary? childEnvironment, - string name) + private async Task ExecuteSingleAsync( + IReadOnlyList arguments, + CancellationToken cancellationToken) { - string? value; - if (childEnvironment is null || !childEnvironment.TryGetValue(name, out value)) + TmuxImplementation implementation = await EnsureImplementationAsync(cancellationToken) + .ConfigureAwait(false); + if (implementation is not TmuxImplementation.Psmux) { - value = Environment.GetEnvironmentVariable(name); + return await ExecuteRawSingleAsync(arguments, cancellationToken).ConfigureAwait(false); } - return string.IsNullOrWhiteSpace(value) ? null : value; + return await _psmuxRouter.ExecuteSingleAsync(arguments, cancellationToken) + .ConfigureAwait(false); } - private static string? NormalizeSocketPath(string? socketPath) => - socketPath is null ? null : Path.GetFullPath(socketPath); - - private static ResolvedSocketRoot ResolveSocketRoot( - IReadOnlyDictionary? childEnvironment) + private async Task ExecuteGroupAsync( + IReadOnlyList> commands, + CancellationToken cancellationToken) { - string? configuredRoot = ReadVariable(childEnvironment, "TMUX_TMPDIR"); - if (string.IsNullOrEmpty(configuredRoot)) + TmuxImplementation implementation = await EnsureImplementationAsync(cancellationToken) + .ConfigureAwait(false); + if (implementation is TmuxImplementation.Psmux) { - return new ResolvedSocketRoot( - NormalizeSocketRoot(DefaultSocketRoot), - EnvironmentValue: null); - } - - string normalizedRoot = NormalizeSocketRoot(configuredRoot); - return new ResolvedSocketRoot(normalizedRoot, normalizedRoot); - } - - private static ReadOnlyDictionary FreezeSocketRoot( - IReadOnlyDictionary? childEnvironment, - string? socketRoot) - { - var frozen = childEnvironment is null - ? new Dictionary(StringComparer.Ordinal) - : new Dictionary(childEnvironment, StringComparer.Ordinal); - frozen["TMUX_TMPDIR"] = socketRoot; - return new ReadOnlyDictionary(frozen); - } - - private static string NormalizeSocketRoot(string socketRoot) => - Path.TrimEndingDirectorySeparator(Path.GetFullPath(socketRoot)); + if (commands.Count != 1) + { + throw new NotSupportedException( + "psmux does not preserve tmux grouped-command semantics."); + } - private static (ServerGeneration Generation, string Text) ParseIdentityRow( - string line, - string kind) - { - string[] fields = line.Split('\t'); - if (fields.Length != 2) - { - throw new InvalidDataException($"tmux reported a malformed {kind} identity row."); + return await ExecuteSingleAsync(commands[0], cancellationToken).ConfigureAwait(false); } - return (ParseGeneration(fields[0]), fields[1]); + return await _execute(TmuxCommandRequest.Group([.. commands]), cancellationToken) + .ConfigureAwait(false); } - /// Runs one command under a generation guard. - [UnsupportedOSPlatform("windows")] - private Task ExecuteGuardedAsync( - ServerGeneration expected, - IReadOnlyList logicalArguments, - CancellationToken cancellationToken) => - ExecuteGuardedGroupAsync(expected, [logicalArguments], cancellationToken); - - /// Runs several commands under one generation guard. - /// Guards the whole batch once, in the same invocation: a - /// per-command check could race with a server change between them. - [UnsupportedOSPlatform("windows")] - internal async Task ExecuteGuardedGroupAsync( - ServerGeneration expected, - IReadOnlyList> commands, + private async Task EnsureImplementationAsync( CancellationToken cancellationToken) { - PlatformGuard.ThrowIfWindows(); - ValidateLiveGeneration(expected); - ArgumentNullException.ThrowIfNull(commands); - if (commands.Count == 0) + if (Options.PsmuxPreview is not null) { - throw new InvalidOperationException("A guarded run needs at least one command."); + ValidatePsmuxConnection(); } - - foreach (IReadOnlyList command in commands) + else if (_processBacked + && (OperatingSystem.IsWindows() + || string.Equals( + Path.GetExtension(Options.TmuxBinaryPath), + ".exe", + StringComparison.OrdinalIgnoreCase))) { - TmuxCommandDispatcher.ValidateArguments(command); - } - - // Exceptions report the caller's own commands, not the guard probe - // wrapped around them for the generation check. - IReadOnlyList logicalArguments = [.. commands.SelectMany(static command => command)]; - string marker = _markerFactory(); - ArgumentException.ThrowIfNullOrWhiteSpace(marker); - string generationText = $"{expected.ProcessId.ToString(CultureInfo.InvariantCulture)}:{expected.StartTime.ToString(CultureInfo.InvariantCulture)}"; - IReadOnlyList[] guarded = - [ - ["display-message", "-p", GenerationFormat], - ["if-shell", "-F", $"#{{==:{GenerationFormat},{generationText}}}", string.Empty, marker], - .. commands, - ]; - TmuxCommandRequest request = TmuxCommandRequest.Group(guarded); - - TmuxCommandResult grouped; - try - { - grouped = await _execute(request, cancellationToken).ConfigureAwait(false); - } - catch (TmuxTransportException error) - { - throw new TmuxTransportException( - error.Message, - logicalArguments, - error.InnerException); + throw new PlatformNotSupportedException( + "Windows executables require the explicit PsmuxServer query facade."); } - if (!TryStripGenerationPrefix( - grouped.StandardOutput.Span, - out ServerGeneration actual, - out byte[] remainingOutput)) + TmuxImplementation implementation = CurrentImplementation; + if (implementation is not TmuxImplementation.Unknown) { - bool exactMarkerFailure = grouped.ExitCode == 1 - && IsExactMarkerFailure(grouped.StandardError.Span, marker); - if (grouped.ExitCode != 0 && !exactMarkerFailure) + if (implementation is TmuxImplementation.Psmux) { - return RemapResult(grouped, logicalArguments, grouped.StandardOutput); + ValidatePsmuxConnection(); } - throw new InvalidDataException( - "tmux did not return a valid leading generation line."); - } - - if (grouped.ExitCode == 1 && IsExactMarkerFailure(grouped.StandardError.Span, marker)) - { - throw new StaleServerGenerationException( - $"The tmux server generation changed from {generationText} to {actual.ProcessId.ToString(CultureInfo.InvariantCulture)}:{actual.StartTime.ToString(CultureInfo.InvariantCulture)}.", - expected, - actual); + return implementation; } - return RemapResult(grouped, logicalArguments, remainingOutput); + (implementation, string rawVersion) = await DetectImplementationAsync(cancellationToken) + .ConfigureAwait(false); + PublishImplementation(implementation, rawVersion); + return implementation; } - private static bool TryStripGenerationPrefix( - ReadOnlySpan standardOutput, - out ServerGeneration generation, - out byte[] remainingOutput) + private async Task<(TmuxImplementation Implementation, string RawVersion)> + DetectImplementationAsync(CancellationToken cancellationToken) { - int lineEnd = standardOutput.IndexOf((byte)'\n'); - if (lineEnd < 0) + TmuxCommandResult result = await _executeVersion( + TmuxCommandRequest.Single(["-V"]), + cancellationToken) + .ConfigureAwait(false); + EnsureSuccessful(result, "multiplexer version discovery"); + if (!TmuxVersionBannerParser.TryParse( + result.StandardOutputLines, + out TmuxVersionBanner banner)) { - generation = default; - remainingOutput = []; - return false; + throw new InvalidDataException( + "The multiplexer did not report a recognized version banner."); } - ReadOnlySpan generationBytes = standardOutput[..lineEnd]; - if (!generationBytes.IsEmpty && generationBytes[^1] == '\r') + if (banner.Implementation is TmuxImplementation.Psmux) { - generationBytes = generationBytes[..^1]; - } + if (Options.PsmuxPreview is null) + { + throw new NotSupportedException( + "psmux requires the explicit PsmuxServer query facade."); + } - try - { - generation = ParseGeneration(Encoding.UTF8.GetString(generationBytes)); + 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(); } - catch (InvalidDataException) + else if (Options.PsmuxPreview is not null) { - generation = default; - remainingOutput = []; - return false; + throw new NotSupportedException( + "The trusted psmux preview executable reported a tmux banner."); } - remainingOutput = standardOutput[(lineEnd + 1)..].ToArray(); - return true; + return (banner.Implementation, banner.RawVersion); } - private static TmuxCommandResult RemapResult( - TmuxCommandResult grouped, - IReadOnlyList logicalArguments, - ReadOnlyMemory standardOutput) => - new( - logicalArguments, - grouped.ExitCode, - standardOutput, - grouped.StandardError, - Utf8BackslashDecoder.ProjectOutputLines(standardOutput.Span), - Utf8BackslashDecoder.ProjectErrorLines(grouped.StandardError.Span)); - - private static bool IsExactMarkerFailure(ReadOnlySpan standardError, string marker) + private void PublishImplementation(TmuxImplementation implementation, string rawVersion) { - byte[] expected = Encoding.UTF8.GetBytes($"unknown command: {marker}\n"); - return standardError.SequenceEqual(expected); + 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 static void ValidateLiveGeneration(ServerGeneration generation) + private string RequireDetectedVersionLine() => + Volatile.Read(ref _detectedVersionLine) + ?? throw new InvalidOperationException("The multiplexer version was not detected."); + + private void ValidatePsmuxConnection() { - if (generation.ProcessId <= 0 || generation.StartTime <= 0) + if (Options.PsmuxPreview is null) { - throw new ArgumentException("A live handle requires a positive server generation.", nameof(generation)); + throw new NotSupportedException( + "psmux requires the explicit PsmuxServer query facade."); } - } - private sealed record ResolvedConnection( - ServerConnectionOptions Options, - string[] PrefixArguments, - EndpointIdentity EndpointIdentity, - IReadOnlyDictionary? ChildEnvironment); + if (Options.SocketPath is not null) + { + throw new NotSupportedException( + "psmux connections require a socket name because -S does not select a namespace."); + } - private readonly record struct ResolvedSocketRoot( - string Identity, - string? EnvironmentValue); + if (string.IsNullOrEmpty(_resolvedSocketName) + || string.Equals(_resolvedSocketName, "default", StringComparison.Ordinal)) + { + throw new NotSupportedException( + "psmux connections require a non-default socket name for endpoint isolation."); + } - private readonly record struct EndpointIdentity( - EndpointKind Kind, - string Primary, - string? Secondary) - { - internal static EndpointIdentity ForPath(string socketPath) => - new(EndpointKind.Path, socketPath, Secondary: null); + PsmuxTargetGrammar.ValidateName(_resolvedSocketName, "namespace"); - internal static EndpointIdentity ForName(string socketRoot, string socketName) => - new(EndpointKind.Name, socketRoot, socketName); - } + if (Options.ColorMode is not TmuxColorMode.Default) + { + throw new NotSupportedException( + "psmux does not honor tmux's forced client color modes."); + } - private enum EndpointKind - { - Path, - Name, + if (Options.ConfigurationFile is not null) + { + throw new NotSupportedException( + "psmux cannot apply a per-client configuration file to a pre-existing session."); + } } - [UnsupportedOSPlatform("windows")] - private Task ExecuteSingleAsync( + private async Task ExecuteRawSingleAsync( IReadOnlyList arguments, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + IReadOnlyList? preserveArguments = null) { - PlatformGuard.ThrowIfWindows(); - return _execute(TmuxCommandRequest.Single(arguments), cancellationToken); + 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) @@ -619,4 +511,12 @@ private static void EnsureSuccessful(TmuxCommandResult result, string operation) throw new TmuxCommandException($"{operation} failed.", result); } } + +} + +internal enum TmuxImplementation +{ + Unknown, + Tmux, + Psmux, } diff --git a/src/LibTmux/Connection/TmuxConnectionOptions.cs b/src/LibTmux/Connection/TmuxConnectionOptions.cs index 87aaed6..3de20c4 100644 --- a/src/LibTmux/Connection/TmuxConnectionOptions.cs +++ b/src/LibTmux/Connection/TmuxConnectionOptions.cs @@ -1,8 +1,38 @@ using System.Collections.ObjectModel; +using LibTmux.Internal; using Microsoft.Extensions.Logging; namespace LibTmux; +internal sealed class PsmuxPreviewOptions : IEquatable +{ + internal PsmuxPreviewOptions(string expectedBinarySha256, string dataDirectory) + { + ExpectedBinarySha256 = PsmuxCompatibility.ValidateExpectedBinarySha256( + expectedBinarySha256, + nameof(expectedBinarySha256)); + DataDirectory = PsmuxCompatibility.NormalizeDataDirectory( + dataDirectory, + nameof(dataDirectory)); + } + + internal string ExpectedBinarySha256 { get; } + + internal string DataDirectory { get; } + + public bool Equals(PsmuxPreviewOptions? other) => + other is not null + && string.Equals( + ExpectedBinarySha256, + other.ExpectedBinarySha256, + StringComparison.Ordinal) + && string.Equals(DataDirectory, other.DataDirectory, StringComparison.Ordinal); + + public override bool Equals(object? obj) => Equals(obj as PsmuxPreviewOptions); + + public override int GetHashCode() => HashCode.Combine(ExpectedBinarySha256, DataDirectory); +} + /// Configures a tmux server connection without mutating process-wide state. public sealed record ServerConnectionOptions { @@ -17,6 +47,31 @@ public ServerConnectionOptions( Func? initializeAsync = null, IReadOnlyDictionary? childEnvironment = null, ILogger? logger = null) + : this( + tmuxBinaryPath, + socketName, + socketPath, + socketNameFactory, + configurationFile, + colorMode, + initializeAsync, + childEnvironment, + logger, + psmuxPreview: null) + { + } + + private ServerConnectionOptions( + string tmuxBinaryPath, + string? socketName, + string? socketPath, + Func? socketNameFactory, + string? configurationFile, + TmuxColorMode colorMode, + Func? initializeAsync, + IReadOnlyDictionary? childEnvironment, + ILogger? logger, + PsmuxPreviewOptions? psmuxPreview) { ArgumentException.ThrowIfNullOrWhiteSpace(tmuxBinaryPath); if (socketName is not null) @@ -64,6 +119,49 @@ public ServerConnectionOptions( } } + if (psmuxPreview is not null) + { + if (!Path.IsPathFullyQualified(tmuxBinaryPath)) + { + throw new ArgumentException( + "The psmux preview requires a fully qualified executable path.", + nameof(tmuxBinaryPath)); + } + + if (socketName is null && socketNameFactory is null) + { + throw new ArgumentException( + "The psmux preview requires an explicit socket name or socket-name factory.", + nameof(socketName)); + } + + string[] reservedVariables = + [ + "LIBTMUX_SOCKET_NAME", + "LIBTMUX_SOCKET_PATH", + "TMUX", + "PSMUX_ACTIVE", + "PSMUX_CLIENT_LAST_SESSION", + "PSMUX_CONFIG_FILE", + "PSMUX_DATA_DIR", + "PSMUX_DEFAULT_SESSION", + "PSMUX_SESSION", + "PSMUX_SESSION_NAME", + "PSMUX_SWITCH_TO", + "PSMUX_TARGET_FULL", + "PSMUX_TARGET_SESSION", + ]; + if (childEnvironmentCopy is not null + && childEnvironmentCopy.Keys.Any(key => reservedVariables.Contains( + key, + StringComparer.OrdinalIgnoreCase))) + { + throw new ArgumentException( + "The psmux preview owns its routing environment variables.", + nameof(childEnvironment)); + } + } + TmuxBinaryPath = tmuxBinaryPath; SocketName = socketName; SocketPath = socketPath; @@ -75,11 +173,30 @@ public ServerConnectionOptions( ? null : new ReadOnlyDictionary(childEnvironmentCopy); Logger = logger; + PsmuxPreview = psmuxPreview; } /// Gets conventional connection defaults. public static ServerConnectionOptions Default { get; } = new(); + internal static ServerConnectionOptions ForPsmux(PsmuxConnectionOptions options) + { + ArgumentNullException.ThrowIfNull(options); + return new ServerConnectionOptions( + tmuxBinaryPath: options.ExecutablePath, + socketName: options.NamespaceName, + socketPath: null, + socketNameFactory: null, + configurationFile: null, + colorMode: TmuxColorMode.Default, + initializeAsync: null, + childEnvironment: null, + logger: options.Logger, + psmuxPreview: new PsmuxPreviewOptions( + options.ExpectedBinarySha256, + options.DataDirectory)); + } + /// Gets the tmux executable path. public string TmuxBinaryPath { get; } @@ -106,4 +223,6 @@ public ServerConnectionOptions( /// Gets the connection logger. public ILogger? Logger { get; } + + internal PsmuxPreviewOptions? PsmuxPreview { get; } } diff --git a/src/LibTmux/ControlMode/ControlModeEventBuffer.cs b/src/LibTmux/ControlMode/ControlModeEventBuffer.cs new file mode 100644 index 0000000..abcdc6c --- /dev/null +++ b/src/LibTmux/ControlMode/ControlModeEventBuffer.cs @@ -0,0 +1,121 @@ +using System.Runtime.CompilerServices; + +namespace LibTmux.Internal; + +/// Buffers notifications without allowing a slow consumer to stall commands. +internal sealed class ControlModeEventBuffer +{ + private readonly int _capacity; + private readonly Action? _afterDequeue; + private readonly object _gate = new(); + private readonly Queue _items = new(); + private TaskCompletionSource _changed = NewSignal(); + private long _dropped; + private long _reported; + private bool _completed; + + internal ControlModeEventBuffer(int capacity, Action? afterDequeue = null) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(capacity); + _capacity = capacity; + _afterDequeue = afterDequeue; + } + + internal bool TryWrite(TmuxEvent item) + { + ArgumentNullException.ThrowIfNull(item); + TaskCompletionSource? changed = null; + lock (_gate) + { + if (_completed) + { + return false; + } + + bool wasEmpty = _items.Count == 0; + if (_items.Count == _capacity) + { + _items.Dequeue(); + _dropped++; + } + + _items.Enqueue(item); + if (wasEmpty) + { + changed = _changed; + _changed = NewSignal(); + } + } + + changed?.TrySetResult(); + return true; + } + + internal void Complete() + { + TaskCompletionSource? changed = null; + lock (_gate) + { + if (!_completed) + { + _completed = true; + changed = _changed; + } + } + + changed?.TrySetResult(); + } + + internal async IAsyncEnumerable ReadAllAsync( + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + while (true) + { + TmuxEvent? item = null; + Task? wait = null; + long dropped = 0; + long totalDropped = 0; + bool completed = false; + lock (_gate) + { + if (_items.Count > 0) + { + item = _items.Dequeue(); + _afterDequeue?.Invoke(); + totalDropped = _dropped; + dropped = totalDropped - _reported; + _reported = totalDropped; + } + else if (_completed) + { + completed = true; + } + else + { + wait = _changed.Task; + } + } + + if (completed) + { + yield break; + } + + if (wait is not null) + { + await wait.WaitAsync(cancellationToken).ConfigureAwait(false); + continue; + } + + if (dropped > 0) + { + yield return new TmuxEventsDroppedEvent(dropped, totalDropped); + } + + yield return item!; + } + } + + private static TaskCompletionSource NewSignal() => + new(TaskCreationOptions.RunContinuationsAsynchronously); +} diff --git a/src/LibTmux/ControlMode/ControlModeSession.cs b/src/LibTmux/ControlMode/ControlModeSession.cs index e36d9e4..1856ff1 100644 --- a/src/LibTmux/ControlMode/ControlModeSession.cs +++ b/src/LibTmux/ControlMode/ControlModeSession.cs @@ -1,10 +1,29 @@ using System.Diagnostics; +using System.Runtime.ExceptionServices; using System.Runtime.Versioning; -using System.Threading.Channels; using LibTmux.Internal; 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 @@ -15,31 +34,28 @@ namespace LibTmux; [UnsupportedOSPlatform("windows")] internal sealed class ControlModeSession : IControlModeSession { - private readonly Process _process; + private readonly IControlModeProcess _process; + private readonly TimeSpan _exitBudget; /// How many unread events are held before the oldest are dropped. /// /// A pane can outpace any reader, and a caller may never read /// at all, so unbounded buffering has no ceiling. - /// The channel drops the oldest event instead of blocking, since blocking + /// The buffer drops the oldest event instead of blocking, since blocking /// would also stall the reader that completes commands. /// internal const int EventBufferCapacity = 4096; - private readonly Channel _events = - Channel.CreateBounded(new BoundedChannelOptions(EventBufferCapacity) - { - SingleReader = false, - SingleWriter = true, - FullMode = BoundedChannelFullMode.DropOldest, - }); + private readonly ControlModeEventBuffer _events = new(EventBufferCapacity); private readonly Queue _pending = new(); - private readonly SemaphoreSlim _writeLock = new(1, 1); + private readonly SemaphoreSlim _writeLock; + private readonly object _disposeGate = new(); private readonly TaskCompletionSource _ready = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly Task _pump; - private bool _disposed; + private Task? _disposeTask; + private int _stopRequested; /// How long disposal waits for the client to exit before killing it. /// @@ -47,17 +63,27 @@ internal sealed class ControlModeSession : IControlModeSession /// stopped, or waiting on something -- would otherwise hang the caller's /// disposal forever, and disposal is the one operation that has to finish. /// - private static readonly TimeSpan ExitBudget = TimeSpan.FromSeconds(5); + private static readonly TimeSpan DefaultExitBudget = TimeSpan.FromSeconds(5); - private ControlModeSession(Process process) + internal ControlModeSession( + IControlModeProcess process, + SemaphoreSlim? writeLock = null, + TimeSpan? exitBudget = null) { - _process = process; + _process = process ?? throw new ArgumentNullException(nameof(process)); + _writeLock = writeLock ?? new SemaphoreSlim(1, 1); + _exitBudget = exitBudget ?? DefaultExitBudget; + if (_exitBudget <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(exitBudget)); + } + _pump = Task.Run(PumpAsync); } - public IAsyncEnumerable Events => _events.Reader.ReadAllAsync(); + public IAsyncEnumerable Events => _events.ReadAllAsync(); - public bool IsRunning => !_process.HasExited; + public bool IsRunning => Volatile.Read(ref _stopRequested) == 0 && !_process.HasExited; [UnsupportedOSPlatform("windows")] internal static ControlModeSession Start( @@ -66,15 +92,8 @@ internal static ControlModeSession Start( string? target, Action configureEnvironment) { - // Stderr is intentionally left undrained: a tmux client can hand its - // write end to the server it starts, so the pipe can outlive the client - // and a reader on it never observes cancellation on Unix. Waiting for - // the server to exit, or closing the handle mid-read, would both hang - // disposal instead. - // - // The residual risk is a client blocking on a full stderr pipe. It - // writes to stderr only when tmux itself fails to start -- at most - // kilobytes, followed by the process exiting. + // 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, @@ -102,7 +121,7 @@ internal static ControlModeSession Start( configureEnvironment(startInfo); Process process = Process.Start(startInfo) ?? throw new InvalidOperationException("The tmux control client did not start."); - return new ControlModeSession(process); + return new ControlModeSession(new SystemControlModeProcess(process)); } /// Waits until tmux has answered its own attach. @@ -114,13 +133,14 @@ public async Task> SendAsync( CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrEmpty(command); - ObjectDisposedException.ThrowIf(_disposed, this); + ThrowIfStopping(); if (_process.HasExited) { throw new InvalidOperationException("The tmux control client has exited."); } PendingCommand pending = new(); + 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 @@ -128,27 +148,36 @@ public async Task> SendAsync( await _writeLock.WaitAsync(cancellationToken).ConfigureAwait(false); try { - // 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. 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); } try { - await _process.StandardInput.WriteLineAsync(command.AsMemory(), cancellationToken) + await _process.WriteLineAsync(command.AsMemory(), cancellationToken) .ConfigureAwait(false); - await _process.StandardInput.FlushAsync(cancellationToken).ConfigureAwait(false); + await _process.FlushAsync(cancellationToken).ConfigureAwait(false); } - catch + catch (Exception error) { - // tmux never saw this command, so it will never answer it. The - // slot is marked abandoned rather than removed, so replies skip - // it instead of being handed to the wrong caller. - pending.Abandon(); - throw; + // 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; } } finally @@ -156,70 +185,266 @@ await _process.StandardInput.WriteLineAsync(command.AsMemory(), cancellationToke _writeLock.Release(); } + 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.WaitAsync(cancellationToken).ConfigureAwait(false); } public async ValueTask DisposeAsync() { - if (_disposed) + Task disposal; + lock (_disposeGate) { - return; + Volatile.Write(ref _stopRequested, 1); + disposal = _disposeTask ??= DisposeCoreAsync(); } - _disposed = true; + await disposal.ConfigureAwait(false); + } + + private async Task DisposeCoreAsync() + { + var cleanupFailures = new List(); + bool writeLockHeld = false; try { - if (!_process.HasExited) + FailPending(new ObjectDisposedException(nameof(ControlModeSession))); + 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); + } + + // 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) + { + cleanupFailures.Add(error); + } + finally + { + if (writeLockHeld) { - _process.StandardInput.Close(); - using CancellationTokenSource budget = new(ExitBudget); try { - await _process.WaitForExitAsync(budget.Token).ConfigureAwait(false); + _writeLock.Release(); } - catch (OperationCanceledException) + catch (Exception error) { - // Asking did not work, so stop asking. A disposal that never - // returns is worse than a client that did not shut down - // politely. - // - // Kills only the client, not its process tree: the server - // underneath may still be serving other clients. - _process.Kill(entireProcessTree: false); - await _process.WaitForExitAsync().ConfigureAwait(false); + cleanupFailures.Add(error); } } } - catch (InvalidOperationException) + + Exception? pumpFailure = null; + try + { + await _pump.WaitAsync(_exitBudget).ConfigureAwait(false); + } + catch (Exception error) { - // The client raced us to exit, which is the state we wanted anyway. + pumpFailure = error; } - finally + + try { - await _pump.ConfigureAwait(false); _process.Dispose(); - _writeLock.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); } } - /// One waiting command, and whether tmux ever heard it. + private void ThrowIfStopping() => + ObjectDisposedException.ThrowIf(Volatile.Read(ref _stopRequested) != 0, this); + + /// One waiting command. private sealed class PendingCommand { internal TaskCompletionSource> Completion { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); - - internal bool IsAbandoned { get; private set; } - - internal void Abandon() => IsAbandoned = true; } private async Task PumpAsync() { string? exitReason = null; + Exception? pumpFailure = null; try { - while (await _process.StandardOutput.ReadLineAsync().ConfigureAwait(false) - is string line) + while (await _process.ReadLineAsync().ConfigureAwait(false) is string line) { if (line.StartsWith("%begin ", StringComparison.Ordinal)) { @@ -242,52 +467,67 @@ private async Task PumpAsync() break; } - _events.Writer.TryWrite(ToEvent(name, arguments)); + _events.TryWrite(ToEvent(name, arguments)); } } + catch (Exception error) + { + pumpFailure = error; + throw; + } finally { - _events.Writer.TryWrite(new TmuxExitEvent(exitReason)); - _events.Writer.TryComplete(); - _ready.TrySetException(new InvalidOperationException( - "The tmux control client exited before it finished attaching.")); - FailPending(); + _events.TryWrite(new TmuxExitEvent(exitReason)); + _events.Complete(); + Exception terminalFailure = pumpFailure ?? new InvalidOperationException( + "The tmux control client exited before it finished attaching."); + _ready.TrySetException(terminalFailure); + StopAndFailPending(terminalFailure); } } private async Task ReadBlockAsync(string beginLine) { - // A block ends only at %end or %error carrying the same numbers the - // %begin did. Stopping at the first line that starts with a percent - // would truncate a block whose own output starts with one, and a pane - // id such as %0 does exactly that. + // 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.StandardOutput.ReadLineAsync().ConfigureAwait(false) - is string line) + while (await _process.ReadLineAsync().ConfigureAwait(false) is string line) { if (IsBlockTerminator(line, "%end ", suffix)) { + terminated = true; break; } if (IsBlockTerminator(line, "%error ", suffix)) { failed = true; + terminated = true; break; } lines.Add(line); } - // Attaching is itself a command, so tmux answers it before any caller - // has asked. Handing that block to the first caller would answer every - // command with the previous one's output for the life of the session, - // and waiting for "nobody is queued" loses the race against a caller - // that sends immediately. - if (_ready.TrySetResult()) + if (!terminated) + { + throw new InvalidDataException( + "The tmux control client ended before its command block was terminated."); + } + + // 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)) + : null; + bool completedReadiness = failure is null + ? _ready.TrySetResult() + : _ready.TrySetException(failure); + if (completedReadiness) { return; } @@ -295,19 +535,7 @@ private async Task ReadBlockAsync(string beginLine) TaskCompletionSource>? completion; lock (_pending) { - // Abandoned slots belong to commands that were never sent, so tmux - // is not answering them. Skipping them here is what keeps replies - // aligned with the commands that actually reached it. - completion = null; - while (_pending.Count > 0) - { - PendingCommand candidate = _pending.Dequeue(); - if (!candidate.IsAbandoned) - { - completion = candidate.Completion; - break; - } - } + completion = _pending.Count == 0 ? null : _pending.Dequeue().Completion; } if (completion is null) @@ -317,9 +545,7 @@ private async Task ReadBlockAsync(string beginLine) if (failed) { - completion.TrySetException(new TmuxCommandException( - lines.Count == 0 ? "The tmux command failed." : string.Join('\n', lines), - BuildFailure(lines))); + completion.TrySetException(failure!); return; } @@ -369,15 +595,52 @@ private static TmuxEvent ToEvent(string name, IReadOnlyList arguments) return new TmuxOutputEvent(arguments[0], OptionParser.DecodeEscapes(payload)); } - private void FailPending() + private void FailPending(Exception? failure = null) + { + failure ??= new InvalidOperationException( + "The tmux control client exited before answering."); + lock (_pending) + { + while (_pending.Count > 0) + { + _pending.Dequeue().Completion.TrySetException(failure); + } + } + } + + private void StopAndFailPending(Exception failure) { lock (_pending) { + Volatile.Write(ref _stopRequested, 1); while (_pending.Count > 0) { - _pending.Dequeue().Completion.TrySetException(new InvalidOperationException( - "The tmux control client exited before answering.")); + _pending.Dequeue().Completion.TrySetException(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/IControlModeSession.cs b/src/LibTmux/ControlMode/IControlModeSession.cs index 084b456..9248c82 100644 --- a/src/LibTmux/ControlMode/IControlModeSession.cs +++ b/src/LibTmux/ControlMode/IControlModeSession.cs @@ -19,8 +19,8 @@ public interface IControlModeSession : IAsyncDisposable /// /// The sequence completes after . It may be /// enumerated once; a second enumeration reads only what has not already - /// been taken. Consume it, or events accumulate for as long as the session - /// is held. + /// been taken. A slow reader receives + /// instead of silently missing data when the bounded buffer overflows. /// public IAsyncEnumerable Events { get; } diff --git a/src/LibTmux/ControlMode/TmuxEvent.cs b/src/LibTmux/ControlMode/TmuxEvent.cs index 12c1aee..304730c 100644 --- a/src/LibTmux/ControlMode/TmuxEvent.cs +++ b/src/LibTmux/ControlMode/TmuxEvent.cs @@ -26,6 +26,15 @@ public sealed record TmuxNotificationEvent( string Name, IReadOnlyList Arguments) : TmuxEvent; +/// Reports notifications discarded because the bounded event buffer was full. +/// The events discarded since the previous loss report. +/// The events discarded over this control client's lifetime. +/// +/// LibTmux synthesizes this event before the next retained event. Command +/// replies use a separate queue and are never discarded by this buffer. +/// +public sealed record TmuxEventsDroppedEvent(long Count, long TotalDropped) : TmuxEvent; + /// The control client ended. /// /// Why tmux said it ended, when it said anything. It is silent for an ordinary diff --git a/src/LibTmux/Environment/TmuxEnvironment.cs b/src/LibTmux/Environment/TmuxEnvironment.cs index 8ce9384..708c5f5 100644 --- a/src/LibTmux/Environment/TmuxEnvironment.cs +++ b/src/LibTmux/Environment/TmuxEnvironment.cs @@ -28,6 +28,9 @@ internal static class TmuxEnvironmentVariables /// The variable naming the pane a process was spawned in. internal const string PaneVariable = "TMUX_PANE"; + /// The variable psmux exports with the current session name. + internal const string PsmuxSessionVariable = "PSMUX_SESSION"; + /// Tries to read the tmux server entry from an environment. /// The environment, or null for the process. /// The parsed entry when present and well formed. @@ -69,12 +72,40 @@ internal static bool TryReadPane( out PaneId paneId) => PaneId.TryParse(Read(environment, PaneVariable), out paneId); + internal static bool HasPsmuxMarker(IReadOnlyDictionary? environment) => + environment is null + ? System.Environment.GetEnvironmentVariable(PsmuxSessionVariable) is not null + : environment.Keys.Any(key => string.Equals( + key, + PsmuxSessionVariable, + StringComparison.OrdinalIgnoreCase)); + + internal static bool LooksLikePsmuxServer(IReadOnlyDictionary? environment) => + Read(environment, ServerVariable)?.StartsWith("/tmp/psmux-", StringComparison.Ordinal) + is true; + private static string? Read( IReadOnlyDictionary? environment, - string name) => - environment is null - ? System.Environment.GetEnvironmentVariable(name) - : environment.TryGetValue(name, out string? value) - ? value - : null; + string name) + { + if (environment is null) + { + return System.Environment.GetEnvironmentVariable(name); + } + + if (environment.TryGetValue(name, out string? value)) + { + return value; + } + + foreach ((string key, string? candidate) in environment) + { + if (string.Equals(key, name, StringComparison.OrdinalIgnoreCase)) + { + return candidate; + } + } + + return null; + } } diff --git a/src/LibTmux/Environment/TmuxEnvironmentOperations.cs b/src/LibTmux/Environment/TmuxEnvironmentOperations.cs index a04358b..2437732 100644 --- a/src/LibTmux/Environment/TmuxEnvironmentOperations.cs +++ b/src/LibTmux/Environment/TmuxEnvironmentOperations.cs @@ -93,9 +93,16 @@ public async Task> GetAllAsync( TmuxCommandResult result = await RunAsync(arguments, cancellationToken) .ConfigureAwait(false); - // A name tmux does not hold is an ordinary answer rather than a - // failure, and so is a hidden one, which succeeds and says nothing. - return result.ExitCode == 0 && result.StandardOutputLines.Count > 0 + if (NamesMissingVariable(result, name)) + { + return null; + } + + TmuxCommandFailure.ThrowIfFailed(result, "show-environment"); + + // A hidden name succeeds and says nothing; only that and the exact + // missing-variable result are ordinary absence answers. + return result.StandardOutputLines.Count > 0 ? Read(result.StandardOutputLines[0]) : null; } @@ -129,14 +136,22 @@ public async Task SetAsync( arguments.Add(name); arguments.Add(value); - TmuxCommandResult result = await RunAsync(arguments, cancellationToken) + var sequence = new TmuxMutationSequence(); + _ = await sequence.MutateAsync( + () => RunAsync(arguments, cancellationToken), + static value => TmuxCommandFailure.ThrowIfFailed(value, "set-environment")) .ConfigureAwait(false); - TmuxCommandFailure.ThrowIfFailed(result, "set-environment"); - // A hidden variable cannot be read back, and a format is expanded - // before it lands, so what is reported is asked for rather than echoed. - return await GetAsync(name, cancellationToken).ConfigureAwait(false) - ?? new TmuxEnvironmentEntry(name, hidden ? null : value, false); + // A hidden variable cannot be read back; visible values are returned + // exactly as tmux stored them, including any format expansion. + TmuxEnvironmentEntry? stored = await sequence + .ObserveAsync(() => GetAsync(name, cancellationToken)) + .ConfigureAwait(false); + return sequence.Observe(() => + stored ?? (hidden + ? new TmuxEnvironmentEntry(name, null, false) + : throw new InvalidDataException( + $"tmux did not report the stored environment variable '{name}'."))); } /// Marks a variable removed for the panes tmux spawns. @@ -194,6 +209,15 @@ public async Task UnsetAsync(string name, CancellationToken cancellationToken = : new TmuxEnvironmentEntry(line, null, false); } + private static bool NamesMissingVariable(TmuxCommandResult result, string name) => + result.ExitCode == 1 + && result.StandardOutputLines.Count == 0 + && result.StandardErrorLines.Count == 1 + && string.Equals( + result.StandardErrorLines[0], + $"unknown variable: {name}", + StringComparison.Ordinal); + private List Build(string subcommand) { List arguments = [subcommand]; diff --git a/src/LibTmux/Hooks/TmuxHooks.cs b/src/LibTmux/Hooks/TmuxHooks.cs index 88b14fe..70ce5ea 100644 --- a/src/LibTmux/Hooks/TmuxHooks.cs +++ b/src/LibTmux/Hooks/TmuxHooks.cs @@ -227,8 +227,13 @@ public async Task SetAsync( ArgumentNullException.ThrowIfNull(request); List arguments = BuildSetArguments(request); - await DispatchAsync(arguments, request.Name, cancellationToken).ConfigureAwait(false); - return await ReadBackAsync(request.Name, request.Scope, request.Global, cancellationToken) + return await TmuxMutationSequence.RunAsync( + () => DispatchAsync(arguments, request.Name, cancellationToken), + () => ReadBackAsync( + request.Name, + request.Scope, + request.Global, + cancellationToken)) .ConfigureAwait(false); } @@ -242,16 +247,17 @@ public async Task SetAsync( CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(request); + var sequence = new TmuxMutationSequence(); if (request.ClearExisting) { - await SetAsync( - new SetHookRequest( - request.Name, - string.Empty, - request.Scope, - request.Global, - unset: true), - cancellationToken) + await sequence.MutateAsync(() => SetAsync( + new SetHookRequest( + request.Name, + string.Empty, + request.Scope, + request.Global, + unset: true), + cancellationToken)) .ConfigureAwait(false); } @@ -266,10 +272,17 @@ await SetAsync( AddTarget(arguments, request.Scope); arguments.Add(indexed); arguments.Add(entry.Value); - await DispatchAsync(arguments, request.Name, cancellationToken).ConfigureAwait(false); + await sequence + .MutateAsync(() => DispatchAsync(arguments, request.Name, cancellationToken)) + .ConfigureAwait(false); } - return await ReadBackAsync(request.Name, request.Scope, request.Global, cancellationToken) + return await sequence + .ObserveAsync(() => ReadBackAsync( + request.Name, + request.Scope, + request.Global, + cancellationToken)) .ConfigureAwait(false); } diff --git a/src/LibTmux/Internal/PsmuxBinaryTrust.cs b/src/LibTmux/Internal/PsmuxBinaryTrust.cs new file mode 100644 index 0000000..bda8242 --- /dev/null +++ b/src/LibTmux/Internal/PsmuxBinaryTrust.cs @@ -0,0 +1,163 @@ +using System.Buffers; +using System.Security.Cryptography; + +namespace LibTmux.Internal; + +internal static class PsmuxBinaryTrust +{ + private const int BufferSize = 81920; + private const long MaximumBinaryBytes = 128L * 1024 * 1024; + + internal static async Task VerifyAsync( + string path, + string expectedSha256, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + Task verification = Task.Run( + () => VerifyCoreAsync(path, expectedSha256, cancellationToken), + CancellationToken.None); + try + { + await verification.WaitAsync(cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + ObserveFutureFailure(verification); + throw; + } + } + + private static async Task VerifyCoreAsync( + string path, + string expectedSha256, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + byte[] buffer = ArrayPool.Shared.Rent(BufferSize); + try + { + await using var stream = new FileStream( + path, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + BufferSize, + FileOptions.Asynchronous | FileOptions.SequentialScan); + using IncrementalHash hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); + var commit = new MarkerMatcher("aa26cd3"u8); + var date = new MarkerMatcher("2026-08-17"u8); + long total = 0; + while (true) + { + int read = await stream + .ReadAsync(buffer.AsMemory(0, BufferSize), cancellationToken) + .ConfigureAwait(false); + if (read == 0) + { + break; + } + + total = checked(total + read); + if (total > MaximumBinaryBytes) + { + throw new NotSupportedException( + "The psmux executable exceeds the preview inspection limit."); + } + + hash.AppendData(buffer, 0, read); + commit.Advance(buffer.AsSpan(0, read)); + date.Advance(buffer.AsSpan(0, read)); + } + + byte[] actual = hash.GetHashAndReset(); + byte[] expected = Convert.FromHexString(expectedSha256); + if (!CryptographicOperations.FixedTimeEquals(actual, expected)) + { + throw new NotSupportedException( + "The psmux executable SHA-256 does not match PsmuxConnectionOptions."); + } + + if (!commit.Found || !date.Found) + { + throw new NotSupportedException( + "The trusted psmux executable does not contain the audited build markers."); + } + } + catch (Exception error) when (error is IOException or UnauthorizedAccessException) + { + throw new InvalidOperationException( + "The trusted psmux preview executable could not be read.", + error); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + private static void ObserveFutureFailure(Task task) + { + _ = task.ContinueWith( + static completed => _ = completed.Exception, + CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + } + + private sealed class MarkerMatcher + { + private readonly byte[] _marker; + private readonly int[] _prefix; + private int _matched; + + internal MarkerMatcher(ReadOnlySpan marker) + { + _marker = marker.ToArray(); + _prefix = new int[_marker.Length]; + for (int index = 1, matched = 0; index < _marker.Length; index++) + { + while (matched > 0 && _marker[index] != _marker[matched]) + { + matched = _prefix[matched - 1]; + } + + if (_marker[index] == _marker[matched]) + { + matched++; + } + + _prefix[index] = matched; + } + } + + internal bool Found { get; private set; } + + internal void Advance(ReadOnlySpan bytes) + { + if (Found) + { + return; + } + + foreach (byte value in bytes) + { + while (_matched > 0 && value != _marker[_matched]) + { + _matched = _prefix[_matched - 1]; + } + + if (value == _marker[_matched]) + { + _matched++; + } + + if (_matched == _marker.Length) + { + Found = true; + return; + } + } + } + } +} diff --git a/src/LibTmux/Internal/PsmuxCommandPolicy.cs b/src/LibTmux/Internal/PsmuxCommandPolicy.cs new file mode 100644 index 0000000..31cb1f5 --- /dev/null +++ b/src/LibTmux/Internal/PsmuxCommandPolicy.cs @@ -0,0 +1,318 @@ +using System.Globalization; + +namespace LibTmux.Internal; + +internal static class PsmuxCommandPolicy +{ + internal static void Validate(IReadOnlyList arguments) + { + foreach (string argument in arguments) + { + ValidateArgument(argument); + } + + int targetCount = 0; + int targetOperand = -1; + bool afterEndOfOptions = false; + for (int index = 1; index < arguments.Count; index++) + { + if (string.Equals(arguments[index], "--", StringComparison.Ordinal)) + { + afterEndOfOptions = true; + continue; + } + + if (!string.Equals(arguments[index], "-t", StringComparison.Ordinal)) + { + continue; + } + + targetCount++; + if (targetCount > 1 || afterEndOfOptions) + { + throw new NotSupportedException( + "psmux cannot distinguish an additional -t token from command payload."); + } + + if (index + 1 >= arguments.Count) + { + throw new NotSupportedException("psmux target options require an operand."); + } + + targetOperand = index + 1; + } + + if (targetOperand >= 0) + { + PsmuxTargetGrammar.ValidateTarget(arguments[targetOperand]); + } + + if (!IsSupportedReadCommand(arguments)) + { + throw new NotSupportedException( + "The psmux 3.3.7 preview supports read and query commands only."); + } + } + + internal static bool CanRunWithoutSession(string command) => + command is "list-sessions" or "has-session"; + + internal static void ValidateArgument(string argument) + { + if (argument.Length == 0) + { + throw new NotSupportedException( + "psmux 3.3.7 cannot preserve empty command arguments."); + } + + if (argument.Contains('\0') || argument.Contains('\r') || argument.Contains('\n')) + { + throw new NotSupportedException( + "psmux 3.3.7 commands cannot safely contain NUL, CR, or LF characters."); + } + + if (argument.Contains('\'') + || argument.Contains('"') + || argument.Contains("\\\\", StringComparison.Ordinal) + || argument.EndsWith('\\')) + { + throw new NotSupportedException( + "psmux 3.3.7 cannot preserve quotes, consecutive backslashes, or a trailing backslash in command arguments."); + } + + if (argument.Contains(';')) + { + throw new NotSupportedException("psmux commands cannot safely contain semicolons."); + } + + if (argument.Contains("#(", StringComparison.Ordinal) + || argument.Contains("#{E", StringComparison.Ordinal) + || argument.Contains("#{T", StringComparison.Ordinal)) + { + throw new NotSupportedException( + "psmux preview commands cannot contain shell-command format expansion."); + } + } + + private static bool IsSupportedReadCommand(IReadOnlyList arguments) + { + string command = arguments[0]; + return command switch + { + "has-session" => IsSupportedHasSession(arguments), + "list-sessions" => IsSupportedListCommand( + arguments, + allowAll: false, + allowSessionScope: false, + allowTarget: false), + "list-windows" => IsSupportedListCommand( + arguments, + allowAll: true, + allowSessionScope: false, + allowTarget: true), + "list-panes" => IsSupportedListCommand( + arguments, + allowAll: true, + allowSessionScope: true, + allowTarget: true), + "display-message" => IsSupportedDisplayMessage(arguments), + "capture-pane" => IsSupportedCapturePane(arguments), + _ => false, + }; + } + + private static bool IsSupportedHasSession(IReadOnlyList arguments) => + arguments.Count == 1 + || (arguments.Count == 3 && arguments[1] == "-t"); + + private static bool IsSupportedListCommand( + IReadOnlyList arguments, + bool allowAll, + bool allowSessionScope, + bool allowTarget) + { + bool hasAll = false; + bool hasSessionScope = false; + bool hasTarget = false; + bool hasFormat = false; + for (int index = 1; index < arguments.Count; index++) + { + string argument = arguments[index]; + if (argument == "-a" && allowAll && !hasAll) + { + hasAll = true; + continue; + } + + if (argument == "-s" && allowSessionScope && !hasSessionScope) + { + hasSessionScope = true; + continue; + } + + if (argument == "-t" && allowTarget && !hasTarget) + { + hasTarget = true; + if (++index >= arguments.Count) + { + return false; + } + + continue; + } + + if (argument == "-F" && !hasFormat) + { + hasFormat = true; + if (++index >= arguments.Count) + { + return false; + } + + continue; + } + + return false; + } + + return true; + } + + private static bool IsSupportedDisplayMessage(IReadOnlyList arguments) + { + bool prints = false; + bool hasTarget = false; + bool hasDuration = false; + bool hasMessage = false; + for (int index = 1; index < arguments.Count; index++) + { + string argument = arguments[index]; + if (argument == "-p" && !prints) + { + prints = true; + continue; + } + + if (argument == "-t" && !hasTarget) + { + hasTarget = true; + if (++index >= arguments.Count) + { + return false; + } + + continue; + } + + if (argument == "-d" && !hasDuration) + { + hasDuration = true; + if (++index >= arguments.Count + || !IsCanonicalInteger(arguments[index], allowDash: false)) + { + return false; + } + + continue; + } + + if (argument.StartsWith('-') || hasMessage) + { + return false; + } + + hasMessage = true; + } + + return prints; + } + + private static bool IsSupportedCapturePane(IReadOnlyList arguments) + { + bool prints = false; + bool escapes = false; + bool joins = false; + bool hasTarget = false; + bool hasStart = false; + bool hasEnd = false; + for (int index = 1; index < arguments.Count; index++) + { + string argument = arguments[index]; + if (argument == "-p" && !prints) + { + prints = true; + continue; + } + + if (argument == "-e" && !escapes) + { + escapes = true; + continue; + } + + if (argument == "-J" && !joins) + { + joins = true; + continue; + } + + if (argument == "-t" && !hasTarget) + { + hasTarget = true; + if (++index >= arguments.Count) + { + return false; + } + + continue; + } + + if (argument == "-S" && !hasStart) + { + hasStart = true; + if (++index >= arguments.Count + || !IsCanonicalInteger(arguments[index], allowDash: true)) + { + return false; + } + + continue; + } + + if (argument == "-E" && !hasEnd) + { + hasEnd = true; + if (++index >= arguments.Count + || !IsCanonicalInteger(arguments[index], allowDash: true)) + { + return false; + } + + continue; + } + + return false; + } + + return prints; + } + + private static bool IsCanonicalInteger(string value, bool allowDash) + { + if (allowDash && value == "-") + { + return true; + } + + return int.TryParse( + value, + NumberStyles.AllowLeadingSign, + CultureInfo.InvariantCulture, + out int parsed) + && string.Equals( + value, + parsed.ToString(CultureInfo.InvariantCulture), + StringComparison.Ordinal); + } + +} diff --git a/src/LibTmux/Internal/PsmuxCompatibility.cs b/src/LibTmux/Internal/PsmuxCompatibility.cs new file mode 100644 index 0000000..190a28e --- /dev/null +++ b/src/LibTmux/Internal/PsmuxCompatibility.cs @@ -0,0 +1,168 @@ +namespace LibTmux.Internal; + +/// Owns the exact psmux build and endpoint spellings this preview accepts. +internal static class PsmuxCompatibility +{ + internal const string SupportedVersion = "3.3.7"; + internal const string SupportedCommit = + "aa26cd39edcfab03e718f94ea21bb47e8c5b85e8"; + internal const string SupportedBinarySha256 = + "1abd0eaa3de1ed5491a4f744c8b3db492ae9ac94e9e9a8fea9da217c744ba94e"; + internal const string SupportedImplementationLine = + "psmux 3.3.7 (aa26cd3 2026-08-17)"; + + internal static string ValidateExpectedBinarySha256(string value, string parameterName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value, parameterName); + if (value.Length != 64 + || value.Any(static character => !char.IsAsciiHexDigit(character))) + { + throw new ArgumentException( + "The expected psmux binary SHA-256 must contain exactly 64 hexadecimal characters.", + parameterName); + } + + string normalized = value.ToLowerInvariant(); + if (!string.Equals(normalized, SupportedBinarySha256, StringComparison.Ordinal)) + { + throw new ArgumentException( + "The expected psmux binary SHA-256 must match the exact audited build.", + parameterName); + } + + return normalized; + } + + internal static string NormalizeDataDirectory(string value, string parameterName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value, parameterName); + if (value.IndexOfAny(['\0', '\r', '\n']) >= 0) + { + throw new ArgumentException( + "The psmux data directory cannot contain NUL, CR, or LF characters.", + parameterName); + } + + string path = value.Replace('/', '\\'); + if (path.Length >= 3 + && char.IsAsciiLetter(path[0]) + && path[1] == ':' + && path[2] == '\\') + { + string root = $"{char.ToUpperInvariant(path[0])}:\\"; + EnsureNativeFixedDrive(root, parameterName); + return root + NormalizeSegments(path[3..], parameterName).ToLowerInvariant(); + } + + throw new ArgumentException( + "The psmux data directory must be an absolute path on a local Windows drive.", + parameterName); + } + + internal static void EnsureNativeFixedDrive(string path, string parameterName) + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + string? root = Path.GetPathRoot(path); + DriveType driveType; + try + { + driveType = string.IsNullOrEmpty(root) + ? DriveType.Unknown + : new DriveInfo(root).DriveType; + } + catch (Exception error) when (error is IOException or UnauthorizedAccessException) + { + throw new ArgumentException( + "The psmux path must use an accessible fixed local Windows drive.", + parameterName, + error); + } + + if (driveType != DriveType.Fixed) + { + throw new ArgumentException( + "The psmux path must use a fixed local Windows drive.", + parameterName); + } + } + + internal static string ValidateNamespaceName(string value, string parameterName) + { + ValidateName(value, "namespace", parameterName); + if (value.Any(char.IsAsciiLetterUpper)) + { + throw new ArgumentException( + "The psmux namespace must use lowercase ASCII spelling.", + parameterName); + } + + if (string.Equals(value, "default", StringComparison.Ordinal)) + { + throw new ArgumentException( + "The psmux preview does not use the ambiguous default namespace.", + parameterName); + } + + if (value.Length is < 16 or > 64) + { + throw new ArgumentException( + "The psmux namespace must contain between 16 and 64 characters.", + parameterName); + } + + return value; + } + + internal static string ValidateName(string value, string kind, string parameterName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value, parameterName); + if (value.IndexOfAny(['\0', '\r', '\n']) >= 0 + || value.Contains("__", StringComparison.Ordinal) + || value.Any(static character => + !char.IsAsciiLetterOrDigit(character) && character is not ('-' or '_'))) + { + throw new ArgumentException( + $"psmux {kind} names must use only ASCII letters, digits, '-' or '_' and cannot contain '__'.", + parameterName); + } + + return value; + } + + private static string NormalizeSegments(string value, string parameterName) + { + string[] segments = value.Split('\\', StringSplitOptions.RemoveEmptyEntries); + if (segments.Length == 0) + { + throw new ArgumentException( + "The psmux data directory cannot be a filesystem root.", + parameterName); + } + + foreach (string segment in segments) + { + string stem = segment.Split('.', 2)[0].ToUpperInvariant(); + bool reservedDevice = stem is "CON" or "PRN" or "AUX" or "NUL" + || (stem.Length == 4 + && stem[3] is >= '1' and <= '9' + && stem[..3] is "COM" or "LPT"); + if (segment is "." or ".." + || segment.EndsWith(' ') + || segment.EndsWith('.') + || segment.Any(static character => character < ' ') + || segment.IndexOfAny(['<', '>', ':', '"', '|', '?', '*']) >= 0 + || reservedDevice) + { + throw new ArgumentException( + "The psmux data directory must use canonical Windows segments without reserved names or characters.", + parameterName); + } + } + + return string.Join('\\', segments); + } +} diff --git a/src/LibTmux/Internal/PsmuxProcessEnvironment.cs b/src/LibTmux/Internal/PsmuxProcessEnvironment.cs new file mode 100644 index 0000000..d2eb872 --- /dev/null +++ b/src/LibTmux/Internal/PsmuxProcessEnvironment.cs @@ -0,0 +1,102 @@ +using System.Diagnostics; + +namespace LibTmux.Internal; + +internal static class PsmuxProcessEnvironment +{ + internal static void Apply( + ProcessStartInfo startInfo, + IReadOnlyDictionary? childEnvironment, + bool forwardDataDirectoryThroughWsl) + { + ArgumentNullException.ThrowIfNull(startInfo); + Remove(startInfo, "TMUX"); + Remove(startInfo, "TMUX_PANE"); + string[] inheritedPsmuxVariables = + [ + .. startInfo.Environment.Keys.Where(IsPsmuxVariable), + ]; + foreach (string variable in inheritedPsmuxVariables) + { + startInfo.Environment.Remove(variable); + } + + if (childEnvironment is null) + { + return; + } + + foreach ((string key, string? value) in childEnvironment) + { + ArgumentException.ThrowIfNullOrWhiteSpace(key); + if (value is null) + { + startInfo.Environment.Remove(key); + } + else + { + startInfo.Environment[key] = value; + } + } + + if (forwardDataDirectoryThroughWsl) + { + ForwardDataDirectoryThroughWsl(startInfo); + } + } + + private static void ForwardDataDirectoryThroughWsl(ProcessStartInfo startInfo) + { + if (!startInfo.Environment.TryGetValue("PSMUX_DATA_DIR", out string? dataDirectory) + || string.IsNullOrEmpty(dataDirectory)) + { + throw new InvalidOperationException( + "The psmux preview requires a child PSMUX_DATA_DIR value."); + } + + var entries = new List(); + foreach ((string key, string? value) in startInfo.Environment) + { + if (!string.Equals(key, "WSLENV", StringComparison.OrdinalIgnoreCase) + || string.IsNullOrEmpty(value)) + { + continue; + } + + foreach (string entry in value.Split(':', StringSplitOptions.RemoveEmptyEntries)) + { + int modifier = entry.IndexOf('/'); + string variable = modifier < 0 ? entry : entry[..modifier]; + if (!IsRoutingVariable(variable)) + { + entries.Add(entry); + } + } + } + + Remove(startInfo, "WSLENV"); + entries.Add("PSMUX_DATA_DIR/w"); + startInfo.Environment["WSLENV"] = string.Join(':', entries); + } + + private static bool IsRoutingVariable(string name) => + string.Equals(name, "TMUX", StringComparison.OrdinalIgnoreCase) + || string.Equals(name, "TMUX_PANE", StringComparison.OrdinalIgnoreCase) + || IsPsmuxVariable(name); + + private static bool IsPsmuxVariable(string name) => + name.StartsWith("PSMUX_", StringComparison.OrdinalIgnoreCase); + + private static void Remove(ProcessStartInfo startInfo, string name) + { + string[] matches = + [ + .. startInfo.Environment.Keys.Where( + key => string.Equals(key, name, StringComparison.OrdinalIgnoreCase)), + ]; + foreach (string key in matches) + { + startInfo.Environment.Remove(key); + } + } +} diff --git a/src/LibTmux/Internal/PsmuxSessionRouter.cs b/src/LibTmux/Internal/PsmuxSessionRouter.cs new file mode 100644 index 0000000..d9eeff9 --- /dev/null +++ b/src/LibTmux/Internal/PsmuxSessionRouter.cs @@ -0,0 +1,331 @@ +using System.Diagnostics.CodeAnalysis; +using System.Globalization; + +namespace LibTmux.Internal; + +[SuppressMessage( + "Design", + "CA1001:Types that own disposable fields should be disposable", + Justification = "The semaphore owns no wait handle unless its AvailableWaitHandle is used.")] +internal sealed class PsmuxSessionRouter +{ + private const string SessionFormat = + "#{pid}:#{start_time}\t#{session_id}\t#{session_name}"; + private readonly Func< + IReadOnlyList, + CancellationToken, + IReadOnlyList?, + Task> _executeRaw; + private readonly SemaphoreSlim _gate = new(1, 1); + + internal PsmuxSessionRouter( + Func< + IReadOnlyList, + CancellationToken, + IReadOnlyList?, + Task> executeRaw) => + _executeRaw = executeRaw; + + internal async Task DiscoverSessionAsync( + CancellationToken cancellationToken) + { + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + return await RequireSingleSessionAsync(cancellationToken).ConfigureAwait(false); + } + finally + { + _gate.Release(); + } + } + + internal async Task ExecuteSingleAsync( + IReadOnlyList arguments, + CancellationToken cancellationToken) + { + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + PsmuxCommandPolicy.Validate(arguments); + string command = arguments[0]; + if (PsmuxCommandPolicy.CanRunWithoutSession(command)) + { + IReadOnlyList sessions = await ReadSessionsAsync( + cancellationToken) + .ConfigureAwait(false); + EnsureAtMostOneSession(sessions); + if (sessions.Count == 0) + { + return MissingSessionResult(arguments); + } + + if (command is "has-session" or "has") + { + IReadOnlyList rewritten = + PsmuxTargetGrammar.RewriteSessionTarget(arguments, sessions[0]); + return await _executeRaw(rewritten, cancellationToken, arguments) + .ConfigureAwait(false); + } + + return await _executeRaw(arguments, cancellationToken, null).ConfigureAwait(false); + } + + IReadOnlyList available = await ReadSessionsAsync( + cancellationToken) + .ConfigureAwait(false); + EnsureAtMostOneSession(available); + if (available.Count == 0) + { + return MissingSessionResult(arguments); + } + + PsmuxSessionState session = available[0]; + IReadOnlyList routed = + PsmuxTargetGrammar.RewriteSessionTarget(arguments, session); + await EnsureObjectTargetExistsAsync(routed, session.Name, cancellationToken) + .ConfigureAwait(false); + return await _executeRaw(routed, cancellationToken, arguments).ConfigureAwait(false); + } + finally + { + _gate.Release(); + } + } + + internal async Task ExecuteGuardedAsync( + ServerGeneration expected, + IReadOnlyList> commands, + CancellationToken cancellationToken) + { + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if (commands.Count != 1) + { + throw new NotSupportedException( + "psmux does not preserve tmux grouped-command semantics."); + } + + IReadOnlyList command = commands[0]; + PsmuxCommandPolicy.Validate(command); + PsmuxSessionState session = await RequireSingleSessionAsync(cancellationToken) + .ConfigureAwait(false); + if (session.Generation != expected) + { + ThrowStaleGeneration(expected, session.Generation); + } + + IReadOnlyList routed = + PsmuxTargetGrammar.RewriteSessionTarget(command, session); + await EnsureObjectTargetExistsAsync(routed, session.Name, cancellationToken) + .ConfigureAwait(false); + return await _executeRaw(routed, cancellationToken, command).ConfigureAwait(false); + } + finally + { + _gate.Release(); + } + } + + private async Task> ReadSessionsAsync( + CancellationToken cancellationToken) + { + TmuxCommandResult result = await _executeRaw( + ["list-sessions", "-F", SessionFormat], + cancellationToken, + null) + .ConfigureAwait(false); + EnsureSuccessful(result, "psmux session discovery"); + + var sessions = new List(result.StandardOutputLines.Count); + foreach (string line in result.StandardOutputLines) + { + PsmuxSessionState session = ParseSessionRow(line, "psmux session"); + PsmuxTargetGrammar.ValidateName(session.Name, "session"); + sessions.Add(session); + } + + if (sessions.Count == 1) + { + await ValidateExactSessionAsync(sessions[0], cancellationToken) + .ConfigureAwait(false); + } + + return sessions; + } + + private async Task ValidateExactSessionAsync( + PsmuxSessionState session, + CancellationToken cancellationToken) + { + TmuxCommandResult targeted = await _executeRaw( + ["display-message", "-p", "-t", session.Name, SessionFormat], + cancellationToken, + null) + .ConfigureAwait(false); + if (targeted.ExitCode != 0 || targeted.StandardErrorLines.Count > 0) + { + throw new NotSupportedException( + "psmux namespace discovery returned a session that cannot be targeted exactly."); + } + + if (targeted.StandardOutputLines.Count != 1 + || ParseSessionRow(targeted.StandardOutputLines[0], "targeted psmux session") + != session) + { + throw new NotSupportedException( + "psmux namespace discovery returned an inconsistent session identity."); + } + + TmuxCommandResult selected = await _executeRaw( + ["display-message", "-p", SessionFormat], + cancellationToken, + null) + .ConfigureAwait(false); + if (selected.ExitCode != 0 + || selected.StandardErrorLines.Count > 0 + || selected.StandardOutputLines.Count != 1) + { + throw new NotSupportedException( + "psmux default routing could not be matched to the selected session."); + } + + PsmuxSessionState selectedSession = ParseSessionRow( + selected.StandardOutputLines[0], + "psmux selected session"); + if (selectedSession != session) + { + throw new NotSupportedException( + "psmux default routing does not match the selected session."); + } + } + + private async Task RequireSingleSessionAsync( + CancellationToken cancellationToken) + { + IReadOnlyList sessions = await ReadSessionsAsync(cancellationToken) + .ConfigureAwait(false); + EnsureAtMostOneSession(sessions); + if (sessions.Count == 0) + { + throw new InvalidOperationException( + "The selected psmux namespace has no live session."); + } + + return sessions[0]; + } + + private async Task EnsureObjectTargetExistsAsync( + IReadOnlyList arguments, + string sessionName, + CancellationToken cancellationToken) + { + int operandIndex = PsmuxTargetGrammar.FindOptionOperand(arguments, "-t"); + if (operandIndex < 0) + { + return; + } + + string target = arguments[operandIndex]; + int separator = target.LastIndexOf(':'); + string candidate = separator < 0 ? target : target[(separator + 1)..]; + if (candidate.StartsWith('.') && PaneId.TryParse(candidate[1..], out _)) + { + candidate = candidate[1..]; + } + + if (candidate.StartsWith('=')) + { + candidate = candidate[1..]; + } + + IReadOnlyList? probe = null; + if (PaneId.TryParse(candidate, out _)) + { + probe = ["list-panes", "-s", "-t", sessionName, "-F", "#{pane_id}"]; + } + else if (WindowId.TryParse(candidate, out _)) + { + probe = ["list-windows", "-t", sessionName, "-F", "#{window_id}"]; + } + + if (probe is null) + { + return; + } + + TmuxCommandResult result = await _executeRaw(probe, cancellationToken, null) + .ConfigureAwait(false); + EnsureSuccessful(result, "psmux object target validation"); + if (!result.StandardOutputLines.Contains(candidate, StringComparer.Ordinal)) + { + throw new TmuxObjectNotFoundException( + $"The psmux target {candidate} is no longer visible in session {sessionName}.", + candidate); + } + } + + private static void EnsureAtMostOneSession(IReadOnlyList sessions) + { + if (sessions.Count > 1) + { + throw new NotSupportedException( + "LibTmux psmux connections require exactly one session per namespace."); + } + } + + private static TmuxCommandResult MissingSessionResult(IReadOnlyList arguments) + { + byte[] error = "no server running on selected psmux namespace\n"u8.ToArray(); + return new TmuxCommandResult( + arguments, + 1, + ReadOnlyMemory.Empty, + error, + [], + Utf8BackslashDecoder.ProjectErrorLines(error)); + } + + private static PsmuxSessionState ParseSessionRow(string line, string kind) + { + string[] fields = line.Split('\t'); + if (fields.Length != 3 + || !SessionId.TryParse(fields[1], out SessionId id)) + { + throw new InvalidDataException($"tmux reported a malformed {kind} identity row."); + } + + return new PsmuxSessionState( + fields[2], + id, + TmuxConnection.ParseGeneration(fields[0])); + } + + private static void ThrowStaleGeneration( + ServerGeneration expected, + ServerGeneration actual) + { + string expectedText = + $"{expected.ProcessId.ToString(CultureInfo.InvariantCulture)}:{expected.StartTime.ToString(CultureInfo.InvariantCulture)}"; + string actualText = + $"{actual.ProcessId.ToString(CultureInfo.InvariantCulture)}:{actual.StartTime.ToString(CultureInfo.InvariantCulture)}"; + throw new StaleServerGenerationException( + $"The tmux server generation changed from {expectedText} to {actualText}.", + expected, + actual); + } + + private static void EnsureSuccessful(TmuxCommandResult result, string operation) + { + if (result.ExitCode != 0 || result.StandardErrorLines.Count > 0) + { + throw new TmuxCommandException($"{operation} failed.", result); + } + } +} + +internal sealed record PsmuxSessionState( + string Name, + SessionId Id, + ServerGeneration Generation); diff --git a/src/LibTmux/Internal/PsmuxTargetGrammar.cs b/src/LibTmux/Internal/PsmuxTargetGrammar.cs new file mode 100644 index 0000000..9a43fb2 --- /dev/null +++ b/src/LibTmux/Internal/PsmuxTargetGrammar.cs @@ -0,0 +1,191 @@ +namespace LibTmux.Internal; + +internal static class PsmuxTargetGrammar +{ + internal static void ValidateName(string name, string kind) + { + PsmuxCommandPolicy.ValidateArgument(name); + if (name.Contains("__", StringComparison.Ordinal) + || name.Any(static character => + !char.IsAsciiLetterOrDigit(character) && character is not '-' and not '_')) + { + throw new NotSupportedException( + $"psmux {kind} names must use only ASCII letters, digits, '-' or '_' and cannot contain '__'."); + } + } + + internal static void ValidateTarget(string target) + { + string unmarked = target.StartsWith('=') ? target[1..] : target; + if (WindowId.TryParse(unmarked, out _) + || PaneId.TryParse(unmarked, out _)) + { + return; + } + + int separator = unmarked.IndexOf(':'); + string selector = separator < 0 ? unmarked : unmarked[..separator]; + if (selector.Length > 0 && !SessionId.TryParse(selector, out _)) + { + ValidateName(selector, "target session"); + } + + if (separator < 0) + { + if (selector.Length == 0) + { + throw new NotSupportedException("psmux query targets cannot be empty."); + } + + return; + } + + string objectTarget = unmarked[(separator + 1)..]; + if (!WindowId.TryParse(objectTarget, out _) + && !PaneId.TryParse(objectTarget, out _)) + { + throw new NotSupportedException( + "The psmux query preview accepts canonical session, window, and pane targets only."); + } + } + + internal static List RewriteSessionTarget( + IReadOnlyList arguments, + PsmuxSessionState session) + { + string command = arguments[0]; + var rewritten = new List(arguments); + if (command == "list-sessions") + { + return rewritten; + } + + int allIndex = FindFlagIndex(rewritten, "-a"); + if ((command is "list-windows" or "list-panes") + && allIndex >= 0) + { + rewritten.RemoveAt(allIndex); + if (command == "list-panes" + && FindFlagIndex(rewritten, "-s") < 0) + { + rewritten.Insert(1, "-s"); + } + } + + int targetIndex = FindOptionOperand(rewritten, "-t"); + if (targetIndex < 0) + { + rewritten.InsertRange(1, ["-t", session.Name]); + } + else + { + rewritten[targetIndex] = BindTarget(rewritten[targetIndex], session); + } + + return rewritten; + } + + internal static int FindOptionOperand( + IReadOnlyList arguments, + string option) + { + for (int index = 1; index < arguments.Count; index++) + { + if (string.Equals(arguments[index], "--", StringComparison.Ordinal)) + { + break; + } + + if (string.Equals(arguments[index], option, StringComparison.Ordinal)) + { + return index + 1 < arguments.Count ? index + 1 : -1; + } + + if (OptionTakesValue(arguments[0], arguments[index])) + { + index++; + } + } + + return -1; + } + + private static string BindTarget(string target, PsmuxSessionState session) + { + bool exact = target.StartsWith('='); + string unmarked = exact ? target[1..] : target; + if (unmarked.Length == 0) + { + throw new NotSupportedException("psmux query targets cannot be empty."); + } + + string replacement; + if (unmarked.StartsWith(':')) + { + replacement = $"{session.Name}:{EncodeObjectTarget(unmarked[1..])}"; + } + else if (WindowId.TryParse(unmarked, out _)) + { + replacement = $"{session.Name}:{unmarked}"; + } + else if (PaneId.TryParse(unmarked, out _)) + { + replacement = $"{session.Name}:.{unmarked}"; + } + else + { + int separator = unmarked.IndexOf(':'); + string selector = separator < 0 ? unmarked : unmarked[..separator]; + if (!SessionSelectorMatches(selector, session)) + { + throw new NotSupportedException( + "The psmux query target does not match the sole visible session."); + } + + replacement = separator < 0 + ? session.Name + : $"{session.Name}:{EncodeObjectTarget(unmarked[(separator + 1)..])}"; + } + + return exact ? $"={replacement}" : replacement; + } + + private static string EncodeObjectTarget(string target) => + PaneId.TryParse(target, out _) ? $".{target}" : target; + + private static bool SessionSelectorMatches( + string selector, + PsmuxSessionState session) => + SessionId.TryParse(selector, out SessionId id) + ? id == session.Id + : string.Equals(selector, session.Name, StringComparison.Ordinal); + + private static int FindFlagIndex(List arguments, string flag) + { + for (int index = 1; index < arguments.Count; index++) + { + if (string.Equals(arguments[index], flag, StringComparison.Ordinal)) + { + return index; + } + + if (OptionTakesValue(arguments[0], arguments[index])) + { + index++; + } + } + + return -1; + } + + private static bool OptionTakesValue(string command, string option) => + command switch + { + "has-session" => option == "-t", + "list-sessions" => option == "-F", + "list-windows" or "list-panes" => option is "-t" or "-F", + "display-message" => option is "-t" or "-d", + "capture-pane" => option is "-t" or "-S" or "-E", + _ => false, + }; +} diff --git a/src/LibTmux/Internal/TmuxConnectionEndpoint.cs b/src/LibTmux/Internal/TmuxConnectionEndpoint.cs new file mode 100644 index 0000000..7583394 --- /dev/null +++ b/src/LibTmux/Internal/TmuxConnectionEndpoint.cs @@ -0,0 +1,209 @@ +using System.Collections.ObjectModel; +using System.Globalization; +using System.Security.Cryptography; +using System.Text; + +namespace LibTmux.Internal; + +/// Resolves one immutable connection endpoint and its child environment. +internal static class TmuxConnectionEndpoint +{ + private const string DefaultSocketRoot = "/tmp"; + private const string SocketNameVariable = "LIBTMUX_SOCKET_NAME"; + private const string SocketPathVariable = "LIBTMUX_SOCKET_PATH"; + + internal static ResolvedTmuxConnection Resolve(ServerConnectionOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + bool chosen = options.SocketPath is not null + || options.SocketName is not null + || options.SocketNameFactory is not null; + string? environmentSocketName = chosen + ? null + : ReadVariable(options.ChildEnvironment, SocketNameVariable); + + string? socketPath = options.SocketPath is not null + ? Path.GetFullPath(options.SocketPath) + : NormalizeSocketPath( + chosen ? null : ReadVariable(options.ChildEnvironment, SocketPathVariable)); + string? socketName = null; + IReadOnlyDictionary? childEnvironment = options.ChildEnvironment; + TmuxEndpointIdentity endpointIdentity; + if (socketPath is null) + { + socketName = options.SocketName; + if (socketName is null && options.SocketNameFactory is not null) + { + socketName = options.SocketNameFactory(); + if (string.IsNullOrWhiteSpace(socketName)) + { + throw new InvalidOperationException( + "The selected socket-name factory returned no usable name."); + } + } + + socketName ??= environmentSocketName; + socketName ??= "default"; + ResolvedSocketRoot socketRoot = ResolveSocketRoot(options.ChildEnvironment); + childEnvironment = FreezeChildEnvironment( + options.ChildEnvironment, + socketRoot.EnvironmentValue, + options.PsmuxPreview?.DataDirectory); + endpointIdentity = options.PsmuxPreview is null + ? TmuxEndpointIdentity.ForName(socketRoot.Identity, socketName) + : TmuxEndpointIdentity.ForPsmux(options.PsmuxPreview.DataDirectory, socketName); + } + else + { + endpointIdentity = TmuxEndpointIdentity.ForPath(socketPath); + } + + return new ResolvedTmuxConnection( + options, + BuildPrefixArguments(options, socketPath, socketName), + socketName, + socketPath, + endpointIdentity, + childEnvironment); + } + + private static string[] BuildPrefixArguments( + ServerConnectionOptions options, + string? socketPath, + string? socketName) + { + var arguments = new List(); + switch (options.ColorMode) + { + case TmuxColorMode.Default: + break; + case TmuxColorMode.Colors256: + arguments.Add("-2"); + break; + case TmuxColorMode.TrueColor: + arguments.Add("-T"); + arguments.Add("RGB"); + break; + default: + throw new ArgumentOutOfRangeException( + nameof(options), + options.ColorMode, + "The tmux color mode is not defined."); + } + + if (options.ConfigurationFile is not null) + { + arguments.Add("-f"); + arguments.Add(options.ConfigurationFile); + } + + if (socketPath is not null) + { + arguments.Add("-S"); + arguments.Add(socketPath); + } + else if (socketName is not null) + { + arguments.Add("-L"); + arguments.Add(socketName); + } + + return [.. arguments]; + } + + private static string? ReadVariable( + IReadOnlyDictionary? childEnvironment, + string name) + { + string? value; + if (childEnvironment is null || !childEnvironment.TryGetValue(name, out value)) + { + value = Environment.GetEnvironmentVariable(name); + } + + return string.IsNullOrWhiteSpace(value) ? null : value; + } + + private static string? NormalizeSocketPath(string? socketPath) => + socketPath is null ? null : Path.GetFullPath(socketPath); + + private static ResolvedSocketRoot ResolveSocketRoot( + IReadOnlyDictionary? childEnvironment) + { + string? configuredRoot = ReadVariable(childEnvironment, "TMUX_TMPDIR"); + if (string.IsNullOrEmpty(configuredRoot)) + { + return new ResolvedSocketRoot( + NormalizeSocketRoot(DefaultSocketRoot), + EnvironmentValue: null); + } + + string normalizedRoot = NormalizeSocketRoot(configuredRoot); + return new ResolvedSocketRoot(normalizedRoot, normalizedRoot); + } + + private static ReadOnlyDictionary FreezeChildEnvironment( + IReadOnlyDictionary? childEnvironment, + string? socketRoot, + string? psmuxDataDirectory) + { + var frozen = childEnvironment is null + ? new Dictionary(StringComparer.Ordinal) + : new Dictionary(childEnvironment, StringComparer.Ordinal); + frozen["TMUX_TMPDIR"] = socketRoot; + if (psmuxDataDirectory is not null) + { + frozen["PSMUX_DATA_DIR"] = psmuxDataDirectory; + } + + return new ReadOnlyDictionary(frozen); + } + + private static string NormalizeSocketRoot(string socketRoot) => + Path.TrimEndingDirectorySeparator(Path.GetFullPath(socketRoot)); + + private readonly record struct ResolvedSocketRoot( + string Identity, + string? EnvironmentValue); +} + +internal sealed record ResolvedTmuxConnection( + ServerConnectionOptions Options, + string[] PrefixArguments, + string? SocketName, + string? SocketPath, + TmuxEndpointIdentity EndpointIdentity, + IReadOnlyDictionary? ChildEnvironment); + +internal readonly record struct TmuxEndpointIdentity( + TmuxEndpointKind Kind, + string Primary, + string? Secondary) +{ + internal static TmuxEndpointIdentity ForPath(string socketPath) => + new(TmuxEndpointKind.Path, socketPath, Secondary: null); + + internal static TmuxEndpointIdentity ForName(string socketRoot, string socketName) => + new(TmuxEndpointKind.Name, socketRoot, socketName); + + internal static TmuxEndpointIdentity ForPsmux(string dataDirectory, string socketName) => + new(TmuxEndpointKind.Psmux, dataDirectory, socketName); + + internal string Fingerprint() + { + string material = string.Create( + CultureInfo.InvariantCulture, + $"{(int)Kind}:{Primary.Length}:{Primary}:" + + $"{(Secondary is null ? -1 : Secondary.Length)}:{Secondary}"); + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(material))) + .ToLowerInvariant(); + } +} + +internal enum TmuxEndpointKind +{ + Path, + Name, + Psmux, +} diff --git a/src/LibTmux/Internal/TmuxEntityLookup.cs b/src/LibTmux/Internal/TmuxEntityLookup.cs new file mode 100644 index 0000000..3dea7ab --- /dev/null +++ b/src/LibTmux/Internal/TmuxEntityLookup.cs @@ -0,0 +1,108 @@ +namespace LibTmux.Internal; + +/// Resolves stable entity identifiers without materializing collections. +internal sealed class TmuxEntityLookup( + Func, CancellationToken, Task> execute) +{ + private const string GenerationFormat = "#{pid}:#{start_time}"; + + internal async Task<(ServerGeneration Generation, SessionId Id)?> FindSessionAsync( + SessionId id, + CancellationToken cancellationToken) + { + TmuxCommandResult result = await execute( + ["list-sessions", "-F", $"{GenerationFormat}\t#{{session_id}}"], + cancellationToken).ConfigureAwait(false); + EnsureSuccessful(result, "session lookup"); + foreach (string line in result.StandardOutputLines) + { + (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; + } + + internal async Task<(ServerGeneration Generation, WindowId Id)?> FindWindowAsync( + WindowId id, + CancellationToken cancellationToken) + { + TmuxCommandResult result = await execute( + ["list-windows", "-a", "-F", $"{GenerationFormat}\t#{{window_id}}"], + cancellationToken).ConfigureAwait(false); + EnsureSuccessful(result, "window lookup"); + var seen = new HashSet<(ServerGeneration Generation, WindowId Id)>(); + foreach (string line in result.StandardOutputLines) + { + (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; + } + + internal async Task<(ServerGeneration Generation, PaneId Id)?> FindPaneAsync( + PaneId id, + CancellationToken cancellationToken) + { + TmuxCommandResult result = await execute( + ["list-panes", "-a", "-F", $"{GenerationFormat}\t#{{pane_id}}"], + cancellationToken).ConfigureAwait(false); + EnsureSuccessful(result, "pane lookup"); + var seen = new HashSet<(ServerGeneration Generation, PaneId Id)>(); + foreach (string line in result.StandardOutputLines) + { + (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; + } + + private static (ServerGeneration Generation, string Text) ParseIdentityRow( + string line, + string kind) + { + string[] fields = line.Split('\t'); + if (fields.Length != 2) + { + throw new InvalidDataException($"tmux reported a malformed {kind} identity row."); + } + + return (TmuxConnection.ParseGeneration(fields[0]), fields[1]); + } + + private static void EnsureSuccessful(TmuxCommandResult result, string operation) + { + if (result.ExitCode != 0 || result.StandardErrorLines.Count > 0) + { + throw new TmuxCommandException($"{operation} failed.", result); + } + } +} diff --git a/src/LibTmux/Internal/TmuxGenerationGuard.cs b/src/LibTmux/Internal/TmuxGenerationGuard.cs new file mode 100644 index 0000000..1d72170 --- /dev/null +++ b/src/LibTmux/Internal/TmuxGenerationGuard.cs @@ -0,0 +1,132 @@ +using System.Globalization; +using System.Text; + +namespace LibTmux.Internal; + +/// Executes tmux commands only while a materialized server generation is live. +internal sealed class TmuxGenerationGuard( + Func> execute, + Func markerFactory) +{ + private const string GenerationFormat = "#{pid}:#{start_time}"; + + internal async Task ExecuteAsync( + ServerGeneration expected, + IReadOnlyList> commands, + CancellationToken cancellationToken) + { + IReadOnlyList logicalArguments = [.. commands.SelectMany(static command => command)]; + string marker = markerFactory(); + ArgumentException.ThrowIfNullOrWhiteSpace(marker); + string generationText = + $"{expected.ProcessId.ToString(CultureInfo.InvariantCulture)}:" + + expected.StartTime.ToString(CultureInfo.InvariantCulture); + IReadOnlyList[] guarded = + [ + ["display-message", "-p", GenerationFormat], + ["if-shell", "-F", $"#{{==:{GenerationFormat},{generationText}}}", string.Empty, marker], + .. commands, + ]; + + TmuxCommandResult grouped; + try + { + grouped = await execute(TmuxCommandRequest.Group(guarded), cancellationToken) + .ConfigureAwait(false); + } + catch (TmuxTransportException error) + { + throw new TmuxTransportException( + error.Message, + logicalArguments, + error.Dispatch, + error.InnerException); + } + + if (!TryStripGenerationPrefix( + grouped.StandardOutput.Span, + out ServerGeneration actual, + out byte[] remainingOutput)) + { + bool exactMarkerFailure = grouped.ExitCode == 1 + && IsExactMarkerFailure(grouped.StandardError.Span, marker); + if (grouped.ExitCode != 0 && !exactMarkerFailure) + { + return TmuxCommandResultProjection.Remap( + grouped, + logicalArguments, + grouped.StandardOutput); + } + + throw new InvalidDataException( + "tmux did not return a valid leading generation line."); + } + + if (grouped.ExitCode == 1 && IsExactMarkerFailure(grouped.StandardError.Span, marker)) + { + throw new StaleServerGenerationException( + $"The tmux server generation changed from {generationText} to " + + $"{actual.ProcessId.ToString(CultureInfo.InvariantCulture)}:" + + $"{actual.StartTime.ToString(CultureInfo.InvariantCulture)}.", + expected, + actual); + } + + return TmuxCommandResultProjection.Remap(grouped, logicalArguments, remainingOutput); + } + + private static bool TryStripGenerationPrefix( + ReadOnlySpan standardOutput, + out ServerGeneration generation, + out byte[] remainingOutput) + { + int lineEnd = standardOutput.IndexOf((byte)'\n'); + if (lineEnd < 0) + { + generation = default; + remainingOutput = []; + return false; + } + + ReadOnlySpan generationBytes = standardOutput[..lineEnd]; + if (!generationBytes.IsEmpty && generationBytes[^1] == '\r') + { + generationBytes = generationBytes[..^1]; + } + + try + { + generation = TmuxConnection.ParseGeneration(Encoding.UTF8.GetString(generationBytes)); + } + catch (InvalidDataException) + { + generation = default; + remainingOutput = []; + return false; + } + + remainingOutput = standardOutput[(lineEnd + 1)..].ToArray(); + return true; + } + + private static bool IsExactMarkerFailure(ReadOnlySpan standardError, string marker) + { + byte[] expected = Encoding.UTF8.GetBytes($"unknown command: {marker}\n"); + return standardError.SequenceEqual(expected); + } +} + +internal static class TmuxCommandResultProjection +{ + internal static TmuxCommandResult Remap( + TmuxCommandResult result, + IReadOnlyList logicalArguments, + ReadOnlyMemory standardOutput) => + new( + logicalArguments, + result.ExitCode, + standardOutput, + result.StandardError, + Utf8BackslashDecoder.ProjectOutputLines(standardOutput.Span), + Utf8BackslashDecoder.ProjectErrorLines(result.StandardError.Span)); +} diff --git a/src/LibTmux/Internal/TmuxMutationSequence.cs b/src/LibTmux/Internal/TmuxMutationSequence.cs new file mode 100644 index 0000000..951a80b --- /dev/null +++ b/src/LibTmux/Internal/TmuxMutationSequence.cs @@ -0,0 +1,147 @@ +namespace LibTmux.Internal; + +internal sealed class TmuxMutationSequence +{ + private static readonly object PartialFailureDataKey = new(); + + internal const string PartialFailureMessage = + "An earlier tmux mutation succeeded, but a later step failed. " + + "tmux state may already have changed; do not retry the whole operation."; + + private readonly string _partialFailureMessage; + private bool _mutationSucceeded; + + internal TmuxMutationSequence(string? partialFailureMessage = null) => + _partialFailureMessage = partialFailureMessage ?? PartialFailureMessage; + + internal async Task MutateAsync(Func mutation) + { + ArgumentNullException.ThrowIfNull(mutation); + try + { + await mutation().ConfigureAwait(false); + _mutationSucceeded = true; + } + catch (Exception error) + { + ThrowIfPartial(error); + throw; + } + } + + internal async Task MutateAsync( + Func> mutation, + Action validate) + { + ArgumentNullException.ThrowIfNull(mutation); + ArgumentNullException.ThrowIfNull(validate); + bool hadSuccessfulMutation = _mutationSucceeded; + T value; + try + { + value = await mutation().ConfigureAwait(false); + } + catch (Exception error) + { + ThrowIfPartial(error); + throw; + } + + _mutationSucceeded = true; + try + { + validate(value); + return value; + } + catch (LibTmuxException error) when (IsPartialFailure(error)) + { + throw; + } + catch (LibTmuxException) when (!hadSuccessfulMutation) + { + throw; + } + catch (Exception error) + { + throw PartialFailure(error); + } + } + + internal Task MutateAsync(Func> mutation) => + MutateAsync(mutation, static _ => { }); + + internal async Task ObserveAsync(Func> observation) + { + ArgumentNullException.ThrowIfNull(observation); + try + { + return await observation().ConfigureAwait(false); + } + catch (Exception error) + { + ThrowIfPartial(error); + throw; + } + } + + internal async Task ObserveAsync(Func observation) + { + ArgumentNullException.ThrowIfNull(observation); + try + { + await observation().ConfigureAwait(false); + } + catch (Exception error) + { + ThrowIfPartial(error); + throw; + } + } + + internal T Observe(Func observation) + { + ArgumentNullException.ThrowIfNull(observation); + try + { + return observation(); + } + catch (Exception error) + { + ThrowIfPartial(error); + throw; + } + } + + internal static async Task RunAsync( + Func mutation, + Func> observation) + { + var sequence = new TmuxMutationSequence(); + await sequence.MutateAsync(mutation).ConfigureAwait(false); + return await sequence.ObserveAsync(observation).ConfigureAwait(false); + } + + private void ThrowIfPartial(Exception error) + { + if (!_mutationSucceeded || IsPartialFailure(error)) + { + return; + } + + throw PartialFailure(error); + } + + private LibTmuxException PartialFailure(Exception error) + { + var failure = new LibTmuxException( + _partialFailureMessage, + TmuxDispatchState.Unknown, + error); + failure.Data[PartialFailureDataKey] = true; + return failure; + } + + private static bool IsPartialFailure(Exception error) => + error is LibTmuxException failure + && failure.Data[PartialFailureDataKey] is true; +} diff --git a/src/LibTmux/Internal/TmuxVersionBanner.cs b/src/LibTmux/Internal/TmuxVersionBanner.cs new file mode 100644 index 0000000..f0d1bcc --- /dev/null +++ b/src/LibTmux/Internal/TmuxVersionBanner.cs @@ -0,0 +1,57 @@ +namespace LibTmux.Internal; + +internal readonly record struct TmuxVersionBanner( + TmuxImplementation Implementation, + string RawVersion, + string Version, + string? ImplementationLine); + +internal static class TmuxVersionBannerParser +{ + internal static bool TryParse( + IReadOnlyList lines, + out TmuxVersionBanner banner) + { + banner = default; + if (lines.Count is < 1 or > 2) + { + return false; + } + + string first = lines[0]; + if (!first.StartsWith("tmux ", StringComparison.Ordinal) + || !TmuxVersion.TryParse(first[5..], out TmuxVersion version)) + { + return false; + } + + if (lines.Count == 1) + { + banner = new TmuxVersionBanner( + TmuxImplementation.Tmux, + first, + version.Raw, + ImplementationLine: null); + return true; + } + + string prefix = $"psmux {version.Raw}"; + string second = lines[1]; + if (!second.StartsWith(prefix, StringComparison.Ordinal) + || (second.Length != prefix.Length + && !(second.Length > prefix.Length + 3 + && second[prefix.Length] == ' ' + && second[prefix.Length + 1] == '(' + && second[^1] == ')'))) + { + return false; + } + + banner = new TmuxVersionBanner( + TmuxImplementation.Psmux, + first, + version.Raw, + second); + return true; + } +} diff --git a/src/LibTmux/LibTmux.csproj b/src/LibTmux/LibTmux.csproj index 21e0273..56b3907 100644 --- a/src/LibTmux/LibTmux.csproj +++ b/src/LibTmux/LibTmux.csproj @@ -54,6 +54,7 @@ + diff --git a/src/LibTmux/Options/TmuxOptions.cs b/src/LibTmux/Options/TmuxOptions.cs index 811208c..a516b37 100644 --- a/src/LibTmux/Options/TmuxOptions.cs +++ b/src/LibTmux/Options/TmuxOptions.cs @@ -130,18 +130,21 @@ public async Task SetAsync( ArgumentNullException.ThrowIfNull(request); List arguments = BuildSetArguments(request); - TmuxCommandResult result = await _dispatcher - .ExecuteAsync(arguments, cancellationToken) + var sequence = new TmuxMutationSequence(); + _ = await sequence.MutateAsync( + () => _dispatcher.ExecuteAsync(arguments, cancellationToken), + value => OptionFailure.ThrowIfFailed(value, request.Name)) .ConfigureAwait(false); - OptionFailure.ThrowIfFailed(result, request.Name); - IReadOnlyList stored = await GetAsync( + IReadOnlyList stored = await sequence + .ObserveAsync(() => GetAsync( new GetOptionRequest(request.Name, request.Scope, request.Global, quiet: true), - cancellationToken) + cancellationToken)) .ConfigureAwait(false); - return stored.Count > 0 - ? stored[^1].Value - : new TmuxOptionValue(null, TmuxOptionState.Absent, null, null); + return sequence.Observe(() => + stored.Count > 0 + ? stored[^1].Value + : new TmuxOptionValue(null, TmuxOptionState.Absent, null, null)); } /// Builds the arguments an unset request sends. diff --git a/src/LibTmux/Pane.Command.cs b/src/LibTmux/Pane.Command.cs index b75a7a2..d1c2e0c 100644 --- a/src/LibTmux/Pane.Command.cs +++ b/src/LibTmux/Pane.Command.cs @@ -3,7 +3,7 @@ namespace LibTmux; -/// Provides raw command execution for a tmux pane. +// Provides raw command execution for a tmux pane. public sealed partial class Pane { private readonly TmuxCommandDispatcher _commandDispatcher; @@ -24,7 +24,6 @@ public Task ExecuteCommandAsync( string? targetOverride = null, CancellationToken cancellationToken = default) { - PlatformGuard.ThrowIfWindows(); return TargetedCommandArguments.ExecuteAsync( _commandDispatcher, arguments, diff --git a/src/LibTmux/Pane.Environment.cs b/src/LibTmux/Pane.Environment.cs index 345a80b..213f265 100644 --- a/src/LibTmux/Pane.Environment.cs +++ b/src/LibTmux/Pane.Environment.cs @@ -4,7 +4,7 @@ namespace LibTmux; -/// Resolves a pane from tmux's exported environment. +// Resolves a pane from tmux's exported environment. public sealed partial class Pane { /// Returns the pane this process was spawned in. diff --git a/src/LibTmux/Pane.Hooks.cs b/src/LibTmux/Pane.Hooks.cs index a816d16..dd4eede 100644 --- a/src/LibTmux/Pane.Hooks.cs +++ b/src/LibTmux/Pane.Hooks.cs @@ -2,7 +2,7 @@ namespace LibTmux; -/// Reaches this pane's hooks. +// Reaches this pane's hooks. public sealed partial class Pane { private TmuxHooks? _hooks; diff --git a/src/LibTmux/Pane.Identity.cs b/src/LibTmux/Pane.Identity.cs index e0122bd..385305d 100644 --- a/src/LibTmux/Pane.Identity.cs +++ b/src/LibTmux/Pane.Identity.cs @@ -3,7 +3,7 @@ namespace LibTmux; -/// Provides typed pane identity. +// Provides typed pane identity. public sealed partial class Pane { private readonly PaneId _id; diff --git a/src/LibTmux/Pane.Operations.cs b/src/LibTmux/Pane.Operations.cs index d11c4cc..ecd0a1d 100644 --- a/src/LibTmux/Pane.Operations.cs +++ b/src/LibTmux/Pane.Operations.cs @@ -5,13 +5,8 @@ namespace LibTmux; -/// Reads, writes, moves, and tears down a pane. -/// -/// Handles are immutable, so an operation that changes tmux state returns a -/// replacement rather than mutating the receiver. Operations that destroy a -/// pane or hand it to another window return nothing, because there is no -/// truthful replacement to hand back. -/// +// 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"; @@ -142,6 +137,10 @@ internal List BuildSendKeysArguments(SendKeysRequest request) /// 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, @@ -157,13 +156,18 @@ public async Task SendKeysAsync( } List arguments = BuildSendKeysArguments(request); - await RunAsync(arguments, cancellationToken).ConfigureAwait(false); + 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 RunAsync(["send-keys", "-t", Target, "Enter"], cancellationToken) + await sequence.MutateAsync( + () => RunAsync(["send-keys", "-t", Target, "Enter"], cancellationToken)) .ConfigureAwait(false); } } @@ -172,6 +176,10 @@ await RunAsync(["send-keys", "-t", Target, "Enter"], cancellationToken) /// 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, @@ -202,9 +210,10 @@ public Task SendPrefixAsync( [UnsupportedOSPlatform("windows")] public async Task EnterAsync(CancellationToken cancellationToken = default) { - await RunAsync(["send-keys", "-t", Target, "Enter"], cancellationToken) + return await TmuxMutationSequence.RunAsync( + () => RunAsync(["send-keys", "-t", Target, "Enter"], cancellationToken), + () => RefreshAsync(cancellationToken)) .ConfigureAwait(false); - return await RefreshAsync(cancellationToken).ConfigureAwait(false); } /// Clears the pane by running the shell's reset. @@ -213,8 +222,10 @@ await RunAsync(["send-keys", "-t", Target, "Enter"], cancellationToken) [UnsupportedOSPlatform("windows")] public async Task ClearAsync(CancellationToken cancellationToken = default) { - await SendKeysAsync(new SendKeysRequest("reset"), cancellationToken).ConfigureAwait(false); - return await RefreshAsync(cancellationToken).ConfigureAwait(false); + return await TmuxMutationSequence.RunAsync( + () => SendKeysAsync(new SendKeysRequest("reset"), cancellationToken), + () => RefreshAsync(cancellationToken)) + .ConfigureAwait(false); } /// Resets the pane's terminal state and drops its history. @@ -228,9 +239,15 @@ public async Task ClearAsync(CancellationToken cancellationToken = default [UnsupportedOSPlatform("windows")] public async Task ResetAsync(CancellationToken cancellationToken = default) { - await RunAsync(["send-keys", "-t", Target, "-R"], cancellationToken).ConfigureAwait(false); - await RunAsync(["clear-history", "-t", Target], cancellationToken).ConfigureAwait(false); - return await RefreshAsync(cancellationToken).ConfigureAwait(false); + 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. @@ -610,32 +627,36 @@ public async Task BreakAsync( arguments.Add("-s"); arguments.Add(Target); - TmuxCommandResult result = await _commandDispatcher - .ExecuteAsync(arguments, cancellationToken) + var sequence = new TmuxMutationSequence(); + TmuxCommandResult result = await sequence.MutateAsync( + () => _commandDispatcher.ExecuteAsync(arguments, cancellationToken), + static value => TmuxCommandFailure.ThrowIfFailed(value, "break-pane")) .ConfigureAwait(false); - TmuxCommandFailure.ThrowIfFailed(result, "break-pane"); - if (result.StandardOutputLines.Count == 0 - || !WindowId.TryParse(result.StandardOutputLines[0], out WindowId created)) - { - throw new InvalidDataException("tmux reported no new window identifier."); - } + 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 RunAsync( - ["rename-window", "-t", created.ToString(), windowName], - cancellationToken) + await sequence.MutateAsync( + () => RunAsync( + ["rename-window", "-t", created.ToString(), windowName], + cancellationToken)) .ConfigureAwait(false); } - IReadOnlyList windows = await owner.GetWindowsAsync(cancellationToken) + IReadOnlyList windows = await sequence + .ObserveAsync(() => owner.GetWindowsAsync(cancellationToken)) .ConfigureAwait(false); - return windows.FirstOrDefault(window => window.Id == created) - ?? throw new TmuxObjectNotFoundException( - $"tmux did not report the created window '{created}'.", - created.ToString()); + 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. @@ -667,7 +688,6 @@ public async Task CreatePaneAsync( CancellationToken cancellationToken = default) { NewPaneRequest options = request ?? new NewPaneRequest(); - Server owner = Server; List arguments = BuildNewPaneArguments(options); return await CreatePaneFromAsync(arguments, "new-pane", cancellationToken) @@ -755,8 +775,10 @@ public async Task ResizeAsync( ArgumentNullException.ThrowIfNull(request); List arguments = BuildResizePaneArguments(request); - await RunAsync(arguments, cancellationToken).ConfigureAwait(false); - return await RefreshAsync(cancellationToken).ConfigureAwait(false); + return await TmuxMutationSequence.RunAsync( + () => RunAsync(arguments, cancellationToken), + () => RefreshAsync(cancellationToken)) + .ConfigureAwait(false); } /// Sets this pane's width. @@ -789,9 +811,10 @@ public async Task SetTitleAsync( CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(title); - await RunAsync(["select-pane", "-t", Target, "-T", title], cancellationToken) + return await TmuxMutationSequence.RunAsync( + () => RunAsync(["select-pane", "-t", Target, "-T", title], cancellationToken), + () => RefreshAsync(cancellationToken)) .ConfigureAwait(false); - return await RefreshAsync(cancellationToken).ConfigureAwait(false); } /// Selects this pane. @@ -806,8 +829,10 @@ public async Task SelectAsync( SelectPaneRequest options = request ?? new SelectPaneRequest(); List arguments = BuildSelectPaneArguments(options); - await RunAsync(arguments, cancellationToken).ConfigureAwait(false); - return await RefreshAsync(cancellationToken).ConfigureAwait(false); + return await TmuxMutationSequence.RunAsync( + () => RunAsync(arguments, cancellationToken), + () => RefreshAsync(cancellationToken)) + .ConfigureAwait(false); } internal List BuildPipePaneArguments(PipePaneRequest request) @@ -1574,25 +1599,31 @@ private async Task CreatePaneFromAsync( string subcommand, CancellationToken cancellationToken) { - TmuxCommandResult result = await _commandDispatcher - .ExecuteAsync(arguments, cancellationToken) + var sequence = new TmuxMutationSequence(); + TmuxCommandResult result = await sequence.MutateAsync( + () => _commandDispatcher.ExecuteAsync(arguments, cancellationToken), + value => TmuxCommandFailure.ThrowIfFailed(value, subcommand)) .ConfigureAwait(false); - TmuxCommandFailure.ThrowIfFailed(result, subcommand); - if (result.StandardOutputLines.Count == 0 - || !PaneId.TryParse(result.StandardOutputLines[0], out PaneId created)) - { - throw new InvalidDataException("tmux reported no new pane identifier."); - } - - Server owner = Server; - IReadOnlyList> rows = await RelationReader - .ListAsync(owner, "list-panes", ["-a"], cancellationToken) + 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 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()); + 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(); diff --git a/src/LibTmux/Pane.Options.cs b/src/LibTmux/Pane.Options.cs index ccda933..821cedc 100644 --- a/src/LibTmux/Pane.Options.cs +++ b/src/LibTmux/Pane.Options.cs @@ -3,7 +3,7 @@ namespace LibTmux; -/// Reaches this pane's option table. +// Reaches this pane's option table. public sealed partial class Pane { private TmuxOptions? _options; diff --git a/src/LibTmux/Pane.Relations.cs b/src/LibTmux/Pane.Relations.cs index b1ab6ae..d1fe231 100644 --- a/src/LibTmux/Pane.Relations.cs +++ b/src/LibTmux/Pane.Relations.cs @@ -3,7 +3,7 @@ namespace LibTmux; -/// Provides pane hierarchy relations captured with the pane. +// Provides pane hierarchy relations captured with the pane. public sealed partial class Pane { private readonly Server? _owner; diff --git a/src/LibTmux/Psmux/PsmuxCaptureOptions.cs b/src/LibTmux/Psmux/PsmuxCaptureOptions.cs new file mode 100644 index 0000000..287e6f9 --- /dev/null +++ b/src/LibTmux/Psmux/PsmuxCaptureOptions.cs @@ -0,0 +1,40 @@ +namespace LibTmux; + +/// Chooses the psmux pane text that can be captured safely. +public sealed class PsmuxCaptureOptions +{ + /// Initializes a bounded psmux capture. + /// The first line, or for psmux's default. + /// The last line, or for psmux's default. + /// Whether terminal escape sequences are preserved. + /// Whether wrapped screen rows are joined. + public PsmuxCaptureOptions( + CapturePanePosition? startLine = null, + CapturePanePosition? endLine = null, + bool escapeSequences = false, + bool joinWrappedLines = false) + { + StartLine = startLine; + EndLine = endLine; + EscapeSequences = escapeSequences; + JoinWrappedLines = joinWrappedLines; + } + + /// Gets the first line to capture. + public CapturePanePosition? StartLine { get; } + + /// Gets the last line to capture. + public CapturePanePosition? EndLine { get; } + + /// Gets whether terminal escape sequences are preserved. + public bool EscapeSequences { get; } + + /// Gets whether wrapped screen rows are joined. + public bool JoinWrappedLines { get; } + + internal CapturePaneRequest ToRequest() => new( + startLine: StartLine, + endLine: EndLine, + escapeSequences: EscapeSequences, + joinWrappedLines: JoinWrappedLines); +} diff --git a/src/LibTmux/Psmux/PsmuxConnectionOptions.cs b/src/LibTmux/Psmux/PsmuxConnectionOptions.cs new file mode 100644 index 0000000..83a0e99 --- /dev/null +++ b/src/LibTmux/Psmux/PsmuxConnectionOptions.cs @@ -0,0 +1,81 @@ +using LibTmux.Internal; +using Microsoft.Extensions.Logging; + +namespace LibTmux; + +/// Configures the bounded psmux query preview. +/// +/// The executable is verified before every client launch. The already-running +/// psmux server must have been provisioned separately with the same clean build, +/// data directory, namespace, and an alias-free configuration. +/// +public sealed class PsmuxConnectionOptions +{ + /// Initializes one explicit psmux endpoint. + /// + /// The fully qualified psmux.exe path on a fixed local Windows drive. + /// Use its /mnt/... path when WSL launches psmux. + /// + /// + /// The exact audited client SHA-256 exposed by + /// . + /// + /// + /// A canonical, absolute data-directory path on a fixed local Windows drive. + /// + /// + /// A dedicated non-default -L namespace containing exactly one session. + /// + /// The optional connection logger. + /// + /// A path, hash, or namespace is absent, malformed, ambiguous, not fixed-drive, + /// or not isolated. + /// + public PsmuxConnectionOptions( + string executablePath, + string expectedBinarySha256, + string dataDirectory, + string namespaceName, + ILogger? logger = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(executablePath); + if (executablePath.IndexOfAny(['\0', '\r', '\n']) >= 0 + || !Path.IsPathFullyQualified(executablePath) + || executablePath.StartsWith("\\\\", StringComparison.Ordinal) + || executablePath.StartsWith("//", StringComparison.Ordinal) + || !executablePath.EndsWith(".exe", StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException( + "The psmux executable must be a local, fully qualified .exe path without control characters.", + nameof(executablePath)); + } + + ExecutablePath = Path.GetFullPath(executablePath); + PsmuxCompatibility.EnsureNativeFixedDrive(ExecutablePath, nameof(executablePath)); + ExpectedBinarySha256 = PsmuxCompatibility.ValidateExpectedBinarySha256( + expectedBinarySha256, + nameof(expectedBinarySha256)); + DataDirectory = PsmuxCompatibility.NormalizeDataDirectory( + dataDirectory, + nameof(dataDirectory)); + NamespaceName = PsmuxCompatibility.ValidateNamespaceName( + namespaceName, + nameof(namespaceName)); + Logger = logger; + } + + /// Gets the local absolute psmux client executable path. + public string ExecutablePath { get; } + + /// Gets the expected executable SHA-256 in lowercase hexadecimal. + public string ExpectedBinarySha256 { get; } + + /// Gets the canonical isolated data-directory path on a fixed local Windows drive. + public string DataDirectory { get; } + + /// Gets the explicit non-default psmux namespace. + public string NamespaceName { get; } + + /// Gets the optional connection logger. + public ILogger? Logger { get; } +} diff --git a/src/LibTmux/Psmux/PsmuxPane.cs b/src/LibTmux/Psmux/PsmuxPane.cs new file mode 100644 index 0000000..9677da8 --- /dev/null +++ b/src/LibTmux/Psmux/PsmuxPane.cs @@ -0,0 +1,78 @@ +#pragma warning disable CA1416 + +namespace LibTmux; + +/// An immutable observation of one psmux pane. +/// +/// This observation is bound to 's captured generation. +/// Queries throw after replacement; +/// call to obtain a fresh observation. +/// +public sealed class PsmuxPane +{ + private readonly Pane _inner; + + internal PsmuxPane(PsmuxServer server, Pane inner) + { + Server = server; + _inner = inner; + } + + /// Gets the psmux endpoint that produced this observation. + public PsmuxServer Server { get; } + + /// Gets the captured pane identifier. + public PaneId Id => _inner.Id; + + /// Gets the captured parent session identifier. + public SessionId SessionId => SessionId.Parse(ReadRequired("session_id")); + + /// Gets the captured parent window identifier. + public WindowId WindowId => WindowId.Parse(ReadRequired("window_id")); + + /// Gets the captured pane index. + public int Index => _inner.Index; + + /// Gets the captured width in columns. + public int Width => _inner.Width; + + /// Gets the captured height in rows. + public int Height => _inner.Height; + + /// Gets the captured pane title. + public string? Title => _inner.Title; + + /// Reads this pane's text through the audited capture subset. + /// The typed capture range and rendering choices. + /// Cancels the psmux query. + /// The captured lines. + /// + /// Target consistency is best effort. An external process can remove the + /// pane between LibTmux's existence preflight and psmux's capture. + /// + /// + /// The selected namespace has no live session. + /// + /// + /// The sole session was replaced after this observation was read. + /// + /// + /// The selected namespace contains more than one session. + /// + /// + /// The observed pane is no longer visible during the preflight. + /// + /// The verified client could not complete the query. + public Task> CaptureAsync( + PsmuxCaptureOptions? options = null, + CancellationToken cancellationToken = default) => + _inner.CaptureAsync(options?.ToRequest(), cancellationToken); + + private string ReadRequired(string name) => + _inner.RawFormatFields.TryGetValue(name, out string? value) + && !string.IsNullOrEmpty(value) + ? value + : throw new InvalidDataException($"The psmux pane row omitted {name}."); +} + +#pragma warning restore CA1416 diff --git a/src/LibTmux/Psmux/PsmuxServer.cs b/src/LibTmux/Psmux/PsmuxServer.cs new file mode 100644 index 0000000..613182c --- /dev/null +++ b/src/LibTmux/Psmux/PsmuxServer.cs @@ -0,0 +1,156 @@ +using LibTmux.Internal; + +#pragma warning disable CA1416 + +namespace LibTmux; + +/// Reads one isolated, single-session psmux namespace. +/// +/// This type exposes only the query operations audited for the pinned psmux +/// build. It is intentionally separate from , whose +/// lifecycle, mutation, chaining, and control-mode contracts require real tmux. +/// Each observation is bound to the session generation seen at connection time. +/// Use to observe a replacement session. +/// +public sealed class PsmuxServer +{ + private readonly Server _inner; + + internal PsmuxServer(PsmuxConnectionOptions options, Server inner) + { + ConnectionOptions = options; + _inner = inner; + } + + /// Gets the exact psmux source commit accepted by this preview. + public const string SupportedCommit = PsmuxCompatibility.SupportedCommit; + + /// Gets the exact psmux client executable SHA-256 accepted by this preview. + public const string SupportedBinarySha256 = PsmuxCompatibility.SupportedBinarySha256; + + /// Gets the exact clean implementation banner accepted by this preview. + public const string SupportedImplementationBanner = + PsmuxCompatibility.SupportedImplementationLine; + + /// Gets the connection settings used for this observation. + public PsmuxConnectionOptions ConnectionOptions { get; } + + /// Gets the psmux compatibility version reported at connection time. + public TmuxVersion Version => _inner.Version + ?? throw new InvalidDataException("The connected psmux client reported no usable version."); + + /// Connects to a separately provisioned psmux namespace. + /// The executable trust and isolated endpoint settings. + /// Cancels process startup and discovery. + /// A query-only psmux server observation. + /// is null. + /// + /// The selected namespace has no live session. + /// + /// + /// The executable, build, namespace, session count, or requested behavior is outside + /// the audited preview contract. + /// + /// The verified client could not complete discovery. + public static async Task ConnectAsync( + PsmuxConnectionOptions options, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(options); + Server inner = await Server + .ConnectAsync(ServerConnectionOptions.ForPsmux(options), cancellationToken) + .ConfigureAwait(false); + var connected = new PsmuxServer(options, inner); + + // Validate the public projection before the endpoint escapes. + _ = await connected.GetSessionAsync(cancellationToken).ConfigureAwait(false); + return connected; + } + + /// Reads the sole visible session. + /// Cancels the psmux query. + /// The current immutable session observation. + /// + /// The selected namespace no longer has one live session. + /// + /// + /// The sole session was replaced after this server was connected. + /// + /// + /// The selected namespace contains more than one session. + /// + /// The verified client could not complete the query. + public async Task GetSessionAsync( + CancellationToken cancellationToken = default) + { + IReadOnlyList sessions = await _inner + .GetSessionsStrictAsync(cancellationToken) + .ConfigureAwait(false); + if (sessions.Count != 1) + { + throw new InvalidOperationException( + $"The psmux preview requires exactly one visible session; found {sessions.Count}."); + } + + return new PsmuxSession(this, sessions[0]); + } + + /// Reads every window in the sole session. + /// Cancels the psmux query. + /// Current immutable window observations. + /// + /// The selected namespace has no live session. + /// + /// + /// The sole session was replaced after this server was connected. + /// + /// + /// The selected namespace contains more than one session. + /// + /// The verified client could not complete the query. + public async Task> GetWindowsAsync( + CancellationToken cancellationToken = default) + { + IReadOnlyList windows = await _inner + .GetWindowsStrictAsync(cancellationToken) + .ConfigureAwait(false); + return [.. windows.Select(window => new PsmuxWindow(this, window))]; + } + + /// Reads every pane in the sole session. + /// Cancels the psmux query. + /// Current immutable pane observations. + /// + /// The selected namespace has no live session. + /// + /// + /// The sole session was replaced after this server was connected. + /// + /// + /// The selected namespace contains more than one session. + /// + /// The verified client could not complete the query. + public async Task> GetPanesAsync( + CancellationToken cancellationToken = default) + { + IReadOnlyList panes = await _inner + .GetPanesStrictAsync(cancellationToken) + .ConfigureAwait(false); + return [.. panes.Select(pane => new PsmuxPane(this, pane))]; + } + + /// Reconnects and returns a fresh server observation. + /// Cancels process startup and discovery. + /// A replacement observation using the same endpoint settings. + /// + /// The selected namespace has no live session. + /// + /// + /// The executable, build, namespace, or session count is outside the preview contract. + /// + /// The verified client could not complete discovery. + public Task RefreshAsync(CancellationToken cancellationToken = default) => + ConnectAsync(ConnectionOptions, cancellationToken); +} + +#pragma warning restore CA1416 diff --git a/src/LibTmux/Psmux/PsmuxSession.cs b/src/LibTmux/Psmux/PsmuxSession.cs new file mode 100644 index 0000000..96768bf --- /dev/null +++ b/src/LibTmux/Psmux/PsmuxSession.cs @@ -0,0 +1,78 @@ +#pragma warning disable CA1416 + +namespace LibTmux; + +/// An immutable observation of the sole psmux session. +/// +/// This observation is bound to 's captured generation. +/// Query methods throw after replacement; +/// call to obtain a fresh observation. +/// +public sealed class PsmuxSession +{ + private readonly Session _inner; + + internal PsmuxSession(PsmuxServer server, Session inner) + { + Server = server; + _inner = inner; + } + + /// Gets the psmux endpoint that produced this observation. + public PsmuxServer Server { get; } + + /// Gets the captured session identifier. + public SessionId Id => _inner.Id; + + /// Gets the captured session name. + public string Name => _inner.Name; + + /// Gets whether a client was attached when the session was read. + public bool Attached => _inner.Attached; + + /// Reads the session's current windows. + /// Cancels the psmux query. + /// Current immutable window observations. + /// + /// The selected namespace has no live session. + /// + /// + /// The sole session was replaced after this observation was read. + /// + /// + /// The selected namespace contains more than one session. + /// + /// The verified client could not complete the query. + public async Task> GetWindowsAsync( + CancellationToken cancellationToken = default) + { + IReadOnlyList windows = await _inner + .GetWindowsAsync(cancellationToken) + .ConfigureAwait(false); + return [.. windows.Select(window => new PsmuxWindow(Server, window))]; + } + + /// Reads the session's current panes. + /// Cancels the psmux query. + /// Current immutable pane observations. + /// + /// The selected namespace has no live session. + /// + /// + /// The sole session was replaced after this observation was read. + /// + /// + /// The selected namespace contains more than one session. + /// + /// The verified client could not complete the query. + public async Task> GetPanesAsync( + CancellationToken cancellationToken = default) + { + IReadOnlyList panes = await _inner + .GetPanesAsync(cancellationToken) + .ConfigureAwait(false); + return [.. panes.Select(pane => new PsmuxPane(Server, pane))]; + } +} + +#pragma warning restore CA1416 diff --git a/src/LibTmux/Psmux/PsmuxWindow.cs b/src/LibTmux/Psmux/PsmuxWindow.cs new file mode 100644 index 0000000..47c350a --- /dev/null +++ b/src/LibTmux/Psmux/PsmuxWindow.cs @@ -0,0 +1,68 @@ +#pragma warning disable CA1416 + +namespace LibTmux; + +/// An immutable observation of one psmux window. +/// +/// This observation is bound to 's captured generation. +/// Query methods throw after replacement; +/// call to obtain a fresh observation. +/// +public sealed class PsmuxWindow +{ + private readonly Window _inner; + + internal PsmuxWindow(PsmuxServer server, Window inner) + { + Server = server; + _inner = inner; + } + + /// Gets the psmux endpoint that produced this observation. + public PsmuxServer Server { get; } + + /// Gets the captured window identifier. + public WindowId Id => _inner.Id; + + /// Gets the captured parent session identifier. + public SessionId SessionId => _inner.EntityKey.SessionId; + + /// Gets the captured window index. + public int Index => _inner.Index; + + /// Gets the captured window name. + public string Name => _inner.Name; + + /// Gets the captured width in columns. + public int Width => _inner.Width; + + /// Gets the captured height in rows. + public int Height => _inner.Height; + + /// Reads the window's current panes. + /// Cancels the psmux query. + /// Current immutable pane observations. + /// + /// The selected namespace has no live session. + /// + /// + /// The sole session was replaced after this observation was read. + /// + /// + /// The selected namespace contains more than one session. + /// + /// + /// The observed window is no longer visible. + /// + /// The verified client could not complete the query. + public async Task> GetPanesAsync( + CancellationToken cancellationToken = default) + { + IReadOnlyList panes = await _inner + .GetPanesAsync(cancellationToken) + .ConfigureAwait(false); + return [.. panes.Select(pane => new PsmuxPane(Server, pane))]; + } +} + +#pragma warning restore CA1416 diff --git a/src/LibTmux/PublicAPI.Unshipped.txt b/src/LibTmux/PublicAPI.Unshipped.txt index 8c0dfe4..8dc269e 100644 --- a/src/LibTmux/PublicAPI.Unshipped.txt +++ b/src/LibTmux/PublicAPI.Unshipped.txt @@ -443,6 +443,56 @@ LibTmux.PromptType.Command = 0 -> LibTmux.PromptType LibTmux.PromptType.Search = 1 -> LibTmux.PromptType LibTmux.PromptType.Target = 2 -> LibTmux.PromptType LibTmux.PromptType.WindowTarget = 3 -> LibTmux.PromptType +LibTmux.PsmuxCaptureOptions +LibTmux.PsmuxCaptureOptions.EndLine.get -> LibTmux.CapturePanePosition? +LibTmux.PsmuxCaptureOptions.EscapeSequences.get -> bool +LibTmux.PsmuxCaptureOptions.JoinWrappedLines.get -> bool +LibTmux.PsmuxCaptureOptions.PsmuxCaptureOptions(LibTmux.CapturePanePosition? startLine = null, LibTmux.CapturePanePosition? endLine = null, bool escapeSequences = false, bool joinWrappedLines = false) -> void +LibTmux.PsmuxCaptureOptions.StartLine.get -> LibTmux.CapturePanePosition? +LibTmux.PsmuxConnectionOptions +LibTmux.PsmuxConnectionOptions.DataDirectory.get -> string! +LibTmux.PsmuxConnectionOptions.ExecutablePath.get -> string! +LibTmux.PsmuxConnectionOptions.ExpectedBinarySha256.get -> string! +LibTmux.PsmuxConnectionOptions.Logger.get -> Microsoft.Extensions.Logging.ILogger? +LibTmux.PsmuxConnectionOptions.NamespaceName.get -> string! +LibTmux.PsmuxConnectionOptions.PsmuxConnectionOptions(string! executablePath, string! expectedBinarySha256, string! dataDirectory, string! namespaceName, Microsoft.Extensions.Logging.ILogger? logger = null) -> void +LibTmux.PsmuxPane +LibTmux.PsmuxPane.CaptureAsync(LibTmux.PsmuxCaptureOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +LibTmux.PsmuxPane.Height.get -> int +LibTmux.PsmuxPane.Id.get -> LibTmux.PaneId +LibTmux.PsmuxPane.Index.get -> int +LibTmux.PsmuxPane.Server.get -> LibTmux.PsmuxServer! +LibTmux.PsmuxPane.SessionId.get -> LibTmux.SessionId +LibTmux.PsmuxPane.Title.get -> string? +LibTmux.PsmuxPane.Width.get -> int +LibTmux.PsmuxPane.WindowId.get -> LibTmux.WindowId +LibTmux.PsmuxServer +LibTmux.PsmuxServer.ConnectionOptions.get -> LibTmux.PsmuxConnectionOptions! +LibTmux.PsmuxServer.GetPanesAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +LibTmux.PsmuxServer.GetSessionAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +LibTmux.PsmuxServer.GetWindowsAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +LibTmux.PsmuxServer.RefreshAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +LibTmux.PsmuxServer.Version.get -> LibTmux.TmuxVersion +LibTmux.PsmuxSession +LibTmux.PsmuxSession.Attached.get -> bool +LibTmux.PsmuxSession.GetPanesAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +LibTmux.PsmuxSession.GetWindowsAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +LibTmux.PsmuxSession.Id.get -> LibTmux.SessionId +LibTmux.PsmuxSession.Name.get -> string! +LibTmux.PsmuxSession.Server.get -> LibTmux.PsmuxServer! +LibTmux.PsmuxWindow +LibTmux.PsmuxWindow.GetPanesAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! +LibTmux.PsmuxWindow.Height.get -> int +LibTmux.PsmuxWindow.Id.get -> LibTmux.WindowId +LibTmux.PsmuxWindow.Index.get -> int +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 = "1abd0eaa3de1ed5491a4f744c8b3db492ae9ac94e9e9a8fea9da217c744ba94e" -> string! +const LibTmux.PsmuxServer.SupportedCommit = "aa26cd39edcfab03e718f94ea21bb47e8c5b85e8" -> string! +const LibTmux.PsmuxServer.SupportedImplementationBanner = "psmux 3.3.7 (aa26cd3 2026-08-17)" -> 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 @@ -1032,6 +1082,14 @@ LibTmux.TmuxEnvironmentEntry.Value.get -> string? LibTmux.TmuxEvent LibTmux.TmuxEvent.TmuxEvent() -> void LibTmux.TmuxEvent.TmuxEvent(LibTmux.TmuxEvent! original) -> void +LibTmux.TmuxEventsDroppedEvent +LibTmux.TmuxEventsDroppedEvent.Count.get -> long +LibTmux.TmuxEventsDroppedEvent.Count.init -> void +LibTmux.TmuxEventsDroppedEvent.Deconstruct(out long Count, out long TotalDropped) -> void +LibTmux.TmuxEventsDroppedEvent.Equals(LibTmux.TmuxEventsDroppedEvent? other) -> bool +LibTmux.TmuxEventsDroppedEvent.TmuxEventsDroppedEvent(long Count, long TotalDropped) -> void +LibTmux.TmuxEventsDroppedEvent.TotalDropped.get -> long +LibTmux.TmuxEventsDroppedEvent.TotalDropped.init -> void LibTmux.TmuxExitEvent LibTmux.TmuxExitEvent.Deconstruct(out string? Reason) -> void LibTmux.TmuxExitEvent.Equals(LibTmux.TmuxExitEvent? other) -> bool @@ -1501,6 +1559,10 @@ override LibTmux.TmuxEnvironmentEntry.ToString() -> string! override LibTmux.TmuxEvent.Equals(object? obj) -> bool override LibTmux.TmuxEvent.GetHashCode() -> int override LibTmux.TmuxEvent.ToString() -> string! +override LibTmux.TmuxEventsDroppedEvent.$() -> LibTmux.TmuxEventsDroppedEvent! +override LibTmux.TmuxEventsDroppedEvent.Equals(object? obj) -> bool +override LibTmux.TmuxEventsDroppedEvent.GetHashCode() -> int +override LibTmux.TmuxEventsDroppedEvent.ToString() -> string! override LibTmux.TmuxExitEvent.$() -> LibTmux.TmuxExitEvent! override LibTmux.TmuxExitEvent.Equals(object? obj) -> bool override LibTmux.TmuxExitEvent.GetHashCode() -> int @@ -1571,6 +1633,7 @@ override sealed LibTmux.Query.StringConstant.Equals(LibTmux.Query.QueryConstant? 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.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 @@ -1828,6 +1891,8 @@ static LibTmux.TmuxEnvironmentEntry.operator !=(LibTmux.TmuxEnvironmentEntry? le static LibTmux.TmuxEnvironmentEntry.operator ==(LibTmux.TmuxEnvironmentEntry? left, LibTmux.TmuxEnvironmentEntry? right) -> bool static LibTmux.TmuxEvent.operator !=(LibTmux.TmuxEvent? left, LibTmux.TmuxEvent? right) -> bool static LibTmux.TmuxEvent.operator ==(LibTmux.TmuxEvent? left, LibTmux.TmuxEvent? right) -> bool +static LibTmux.TmuxEventsDroppedEvent.operator !=(LibTmux.TmuxEventsDroppedEvent? left, LibTmux.TmuxEventsDroppedEvent? right) -> bool +static LibTmux.TmuxEventsDroppedEvent.operator ==(LibTmux.TmuxEventsDroppedEvent? left, LibTmux.TmuxEventsDroppedEvent? right) -> bool static LibTmux.TmuxExitEvent.operator !=(LibTmux.TmuxExitEvent? left, LibTmux.TmuxExitEvent? right) -> bool static LibTmux.TmuxExitEvent.operator ==(LibTmux.TmuxExitEvent? left, LibTmux.TmuxExitEvent? right) -> bool static LibTmux.TmuxHook.operator !=(LibTmux.TmuxHook? left, LibTmux.TmuxHook? right) -> bool diff --git a/src/LibTmux/Query/QueryPlanner.cs b/src/LibTmux/Query/QueryPlanner.cs index e701696..0c1fba0 100644 --- a/src/LibTmux/Query/QueryPlanner.cs +++ b/src/LibTmux/Query/QueryPlanner.cs @@ -2,13 +2,8 @@ namespace LibTmux; -/// Runs tmux-side filters and returns the surviving objects. -/// -/// These take a raw tmux filter rather than a translated document: tmux -/// evaluates the text, so the closed field catalog does not apply and a -/// malformed token yields no rows. Unlike the lenient listings, a failed -/// search throws rather than returning nothing. -/// +// Raw tmux filters bypass the closed field catalog; malformed filters yield no +// rows, while command failures propagate. public sealed partial class Server { /// Runs a tmux-side filter over every session. diff --git a/src/LibTmux/README.md b/src/LibTmux/README.md index 0acfe48..bc94c75 100644 --- a/src/LibTmux/README.md +++ b/src/LibTmux/README.md @@ -301,6 +301,15 @@ started and then died is `Unknown`, because tmux may have acted before the pipe broke, and `Unknown` is the default for exactly that reason. A `TmuxCommandException` is always `Dispatched`: it exists because tmux answered. +## Compatibility + +| | | +|---|---| +| 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 | +| 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 | Package | Adds | diff --git a/src/LibTmux/Server.Chaining.cs b/src/LibTmux/Server.Chaining.cs index 19f4f4b..65d83e8 100644 --- a/src/LibTmux/Server.Chaining.cs +++ b/src/LibTmux/Server.Chaining.cs @@ -3,7 +3,7 @@ namespace LibTmux; -/// Starts a chain of commands on this server. +// Starts a chain of commands on this server. public sealed partial class Server { /// Begins a chain that runs its commands in one tmux invocation. diff --git a/src/LibTmux/Server.Clients.cs b/src/LibTmux/Server.Clients.cs index 76a53fd..f1b7e32 100644 --- a/src/LibTmux/Server.Clients.cs +++ b/src/LibTmux/Server.Clients.cs @@ -4,7 +4,7 @@ namespace LibTmux; -/// Lists and administers the clients attached to a server. +// Lists and administers the clients attached to a server. public sealed partial class Server { private const string ClipboardQueryCapability = "refresh_client_clipboard_query"; diff --git a/src/LibTmux/Server.Collections.cs b/src/LibTmux/Server.Collections.cs index d5dee40..0054fb0 100644 --- a/src/LibTmux/Server.Collections.cs +++ b/src/LibTmux/Server.Collections.cs @@ -2,13 +2,8 @@ namespace LibTmux; -/// Reads server-wide collections of tmux objects. -/// -/// Leniency differs per accessor and is not a style choice. Session listings -/// answer "what is there", so any failure reads as nothing there. Window and -/// pane listings answer a narrower question, so only an absent daemon or -/// socket reads as empty and a real tmux error still surfaces. -/// +// Session listings preserve historical any-failure leniency; window and pane +// listings tolerate only a missing daemon or socket. public sealed partial class Server { /// Reads every session on this server. @@ -24,6 +19,16 @@ public Task> GetSessionsAsync( LenientListPolicy.AnyFailure, cancellationToken); + [UnsupportedOSPlatform("windows")] + internal Task> GetSessionsStrictAsync( + CancellationToken cancellationToken = default) => + ListAsync( + "list-sessions", + [], + static (owner, row) => RelationReader.ToSession(owner, row), + LenientListPolicy.None, + cancellationToken); + /// Reads every session with at least one attached client. /// Cancels the tmux command. /// The attached sessions, empty when the listing fails. @@ -60,6 +65,16 @@ public Task> GetWindowsAsync( LenientListPolicy.MissingDaemonOrSocket, cancellationToken); + [UnsupportedOSPlatform("windows")] + internal Task> GetWindowsStrictAsync( + CancellationToken cancellationToken = default) => + ListAsync( + "list-windows", + ["-a"], + static (owner, row) => RelationReader.ToWindow(owner, row), + LenientListPolicy.None, + cancellationToken); + /// Reads every pane on this server. /// Cancels the tmux command. /// The panes, empty when no daemon or socket is present. @@ -73,6 +88,16 @@ public Task> GetPanesAsync( LenientListPolicy.MissingDaemonOrSocket, cancellationToken); + [UnsupportedOSPlatform("windows")] + internal Task> GetPanesStrictAsync( + CancellationToken cancellationToken = default) => + ListAsync( + "list-panes", + ["-a"], + static (owner, row) => RelationReader.ToPane(owner, row), + LenientListPolicy.None, + cancellationToken); + [UnsupportedOSPlatform("windows")] private async Task> ListAsync( string listCommand, @@ -112,16 +137,25 @@ await ListRowsAsync(listCommand, extraArguments, policy, cancellationToken) private sealed class LenientListPolicy { private readonly bool _anyFailure; + private readonly bool _missingDaemonOrSocket; - private LenientListPolicy(bool anyFailure) => _anyFailure = anyFailure; + private LenientListPolicy(bool anyFailure, bool missingDaemonOrSocket) + { + _anyFailure = anyFailure; + _missingDaemonOrSocket = missingDaemonOrSocket; + } - internal static LenientListPolicy AnyFailure { get; } = new(anyFailure: true); + internal static LenientListPolicy AnyFailure { get; } = + new(anyFailure: true, missingDaemonOrSocket: true); internal static LenientListPolicy MissingDaemonOrSocket { get; } = - new(anyFailure: false); + new(anyFailure: false, missingDaemonOrSocket: true); + + internal static LenientListPolicy None { get; } = + new(anyFailure: false, missingDaemonOrSocket: false); internal bool Tolerates(LibTmuxException error) => - _anyFailure || IsMissingDaemonOrSocket(error); + _anyFailure || (_missingDaemonOrSocket && IsMissingDaemonOrSocket(error)); private static bool IsMissingDaemonOrSocket(LibTmuxException error) => error is TmuxCommandNotFoundException diff --git a/src/LibTmux/Server.Command.cs b/src/LibTmux/Server.Command.cs index f8d66bd..95a9d1d 100644 --- a/src/LibTmux/Server.Command.cs +++ b/src/LibTmux/Server.Command.cs @@ -3,7 +3,7 @@ namespace LibTmux; -/// Provides raw command execution for a tmux server endpoint. +// Provides raw command execution for a tmux server endpoint. public sealed partial class Server { private readonly TmuxCommandDispatcher _commandDispatcher; @@ -18,9 +18,6 @@ internal Server(TmuxCommandDispatcher commandDispatcher) [UnsupportedOSPlatform("windows")] public Task ExecuteCommandAsync( IReadOnlyList arguments, - CancellationToken cancellationToken = default) - { - PlatformGuard.ThrowIfWindows(); - return _commandDispatcher.ExecuteAsync(arguments, cancellationToken); - } + CancellationToken cancellationToken = default) => + _commandDispatcher.ExecuteAsync(arguments, cancellationToken); } diff --git a/src/LibTmux/Server.ControlMode.cs b/src/LibTmux/Server.ControlMode.cs index f70cb20..9419ada 100644 --- a/src/LibTmux/Server.ControlMode.cs +++ b/src/LibTmux/Server.ControlMode.cs @@ -3,7 +3,7 @@ namespace LibTmux; -/// Opens a live control client on this server. +// Opens a live control client on this server. public sealed partial class Server { /// Starts a tmux control client and keeps it running. @@ -26,7 +26,6 @@ public async Task EnterControlModeAsync( string? target = null, CancellationToken cancellationToken = default) { - PlatformGuard.ThrowIfWindows(); TmuxConnection connection = _connection ?? throw new InvalidOperationException("The server handle has no connection."); @@ -34,6 +33,11 @@ public async Task EnterControlModeAsync( // 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); + if (connection.IsPsmux) + { + throw new NotSupportedException( + "psmux control mode does not provide the attach readiness framing LibTmux requires."); + } ControlModeSession session = ControlModeSession.Start( connection.Options.TmuxBinaryPath, @@ -45,7 +49,23 @@ public async Task EnterControlModeAsync( // Attaching is asynchronous, and a caller who sends a command before // tmux has answered its own attach would be handed that answer. - await session.WaitForReadyAsync(cancellationToken).ConfigureAwait(false); - return session; + try + { + await session.WaitForReadyAsync(cancellationToken).ConfigureAwait(false); + return session; + } + catch (Exception startupFailure) + { + try + { + await session.DisposeAsync().ConfigureAwait(false); + } + catch (Exception cleanupFailure) + { + startupFailure.Data["LibTmux.ControlModeCleanupFailure"] = cleanupFailure; + } + + throw; + } } } diff --git a/src/LibTmux/Server.Environment.cs b/src/LibTmux/Server.Environment.cs index b12d73e..6d3dab9 100644 --- a/src/LibTmux/Server.Environment.cs +++ b/src/LibTmux/Server.Environment.cs @@ -4,7 +4,7 @@ namespace LibTmux; -/// Resolves a server from tmux's exported environment. +// Resolves a server from tmux's exported environment. public sealed partial class Server { /// Returns the server whose pane this process was spawned in. @@ -22,6 +22,14 @@ public sealed partial class Server public static Server FromEnvironment( IReadOnlyDictionary? environment = null) { + if (TmuxEnvironmentVariables.HasPsmuxMarker(environment) + || TmuxEnvironmentVariables.LooksLikePsmuxServer(environment)) + { + throw new TmuxObjectNotFoundException( + "A psmux environment cannot select the audited executable safely; open it with explicit connection options.", + TmuxEnvironmentVariables.ServerVariable); + } + if (!TmuxEnvironmentVariables.TryRead(environment, out TmuxServerLocation? entry)) { throw new TmuxObjectNotFoundException( diff --git a/src/LibTmux/Server.EnvironmentOperations.cs b/src/LibTmux/Server.EnvironmentOperations.cs index 864e6d2..facfaf9 100644 --- a/src/LibTmux/Server.EnvironmentOperations.cs +++ b/src/LibTmux/Server.EnvironmentOperations.cs @@ -2,7 +2,7 @@ namespace LibTmux; -/// Reaches the server's own environment. +// Reaches the server's own environment. public sealed partial class Server { private TmuxEnvironment? _environment; diff --git a/src/LibTmux/Server.Hooks.cs b/src/LibTmux/Server.Hooks.cs index 9847f93..907b39e 100644 --- a/src/LibTmux/Server.Hooks.cs +++ b/src/LibTmux/Server.Hooks.cs @@ -2,7 +2,7 @@ namespace LibTmux; -/// Reaches this server's hooks. +// Reaches this server's hooks. public sealed partial class Server { private TmuxHooks? _hooks; diff --git a/src/LibTmux/Server.Identity.cs b/src/LibTmux/Server.Identity.cs index 1852266..2899040 100644 --- a/src/LibTmux/Server.Identity.cs +++ b/src/LibTmux/Server.Identity.cs @@ -3,14 +3,14 @@ namespace LibTmux; -/// Provides server connection identity and typed lookup. +// Provides server connection identity and typed lookup. public sealed partial class Server { private readonly TmuxConnection? _connection; private readonly ServerGeneration? _generation; private readonly string? _rawVersion; - private Server( + internal Server( TmuxConnection connection, ServerGeneration? generation, string? rawVersion) @@ -46,17 +46,13 @@ public static Server Open(ServerConnectionOptions? options = null) [UnsupportedOSPlatform("windows")] public static Task ConnectAsync( ServerConnectionOptions? options = null, - CancellationToken cancellationToken = default) - { - PlatformGuard.ThrowIfWindows(); - return Open(options).ConnectAsync(cancellationToken); - } + CancellationToken cancellationToken = default) => + Open(options).ConnectAsync(cancellationToken); /// Materializes this connection and returns its immutable replacement. [UnsupportedOSPlatform("windows")] public async Task ConnectAsync(CancellationToken cancellationToken = default) { - PlatformGuard.ThrowIfWindows(); if (_connection is null) { throw new InvalidOperationException("This server has no connection identity."); @@ -67,9 +63,26 @@ public async Task ConnectAsync(CancellationToken cancellationToken = def return this; } + return await RediscoverCurrentGenerationAsync(cancellationToken).ConfigureAwait(false); + } + + [UnsupportedOSPlatform("windows")] + private async Task RediscoverCurrentGenerationAsync( + CancellationToken cancellationToken) + { + if (_connection is null) + { + throw new InvalidOperationException("This server has no connection identity."); + } + (ServerGeneration generation, string rawVersion) = await _connection .DiscoverAsync(cancellationToken) .ConfigureAwait(false); + if (_generation is ServerGeneration existing && existing == generation) + { + return this; + } + var materialized = new Server(_connection, generation, rawVersion); if (ConnectionOptions.InitializeAsync is not null) { @@ -86,7 +99,6 @@ public async Task GetSessionAsync( SessionId id, CancellationToken cancellationToken = default) { - PlatformGuard.ThrowIfWindows(); TmuxConnection connection = RequireMaterializedConnection(); (ServerGeneration Generation, SessionId Id)? identity = await connection .FindSessionAsync(id, cancellationToken) @@ -107,7 +119,6 @@ public async Task GetWindowAsync( WindowId id, CancellationToken cancellationToken = default) { - PlatformGuard.ThrowIfWindows(); TmuxConnection connection = RequireMaterializedConnection(); (ServerGeneration Generation, WindowId Id)? identity = await connection .FindWindowAsync(id, cancellationToken) @@ -128,7 +139,6 @@ public async Task GetPaneAsync( PaneId id, CancellationToken cancellationToken = default) { - PlatformGuard.ThrowIfWindows(); TmuxConnection connection = RequireMaterializedConnection(); (ServerGeneration Generation, PaneId Id)? identity = await connection .FindPaneAsync(id, cancellationToken) diff --git a/src/LibTmux/Server.Lifecycle.cs b/src/LibTmux/Server.Lifecycle.cs index 26a64ad..a0983b8 100644 --- a/src/LibTmux/Server.Lifecycle.cs +++ b/src/LibTmux/Server.Lifecycle.cs @@ -3,13 +3,8 @@ namespace LibTmux; -/// Starts, probes, and tears down a tmux server. -/// -/// Liveness and teardown answer different questions, so they fail differently. -/// Probing whether a server is alive can honestly answer "no"; asking one to -/// kill a named session cannot honestly answer anything if the request never -/// lands. -/// +// Liveness may answer false; teardown failures propagate because command +// delivery is part of the answer. public sealed partial class Server { private const int SettleAttempts = 200; @@ -127,58 +122,70 @@ public async Task CreateSessionAsync( CancellationToken cancellationToken = default) { NewSessionRequest options = request ?? new NewSessionRequest(); + var sequence = new TmuxMutationSequence(); if (options.Name is not null) { SessionName.Validate(options.Name); - // tmux has no replace flag. Its nearest offer, new-session -A, - // attaches to the existing session instead, which needs a terminal - // and so fails outright from a library caller. Replacing therefore - // means removing the old session first. + // tmux -A attaches and needs a terminal; replacement must kill first. if (options.ReplaceExisting && await HasSessionAsync(options.Name, true, cancellationToken) .ConfigureAwait(false)) { - await KillSessionAsync(options.Name, cancellationToken).ConfigureAwait(false); + await sequence + .MutateAsync(() => KillSessionAsync(options.Name, cancellationToken)) + .ConfigureAwait(false); } } - TmuxCommandResult result = await Dispatch( - [.. BuildNewSessionArguments(options)], - cancellationToken) + TmuxCommandResult result = await sequence.MutateAsync( + () => Dispatch([.. BuildNewSessionArguments(options)], cancellationToken), + value => + { + if (value.ExitCode != 0 + && options.Name is not null + && value.StandardErrorLines.Any(static line => + line.Contains("duplicate session", StringComparison.Ordinal))) + { + throw new TmuxSessionExistsException( + string.Join('\n', value.StandardErrorLines), + options.Name); + } + + TmuxCommandFailure.ThrowIfFailed(value, "new-session"); + }) .ConfigureAwait(false); - if (result.ExitCode != 0 - && options.Name is not null - && result.StandardErrorLines.Any(static line => - line.Contains("duplicate session", StringComparison.Ordinal))) - { - throw new TmuxSessionExistsException( - string.Join('\n', result.StandardErrorLines), - options.Name); - } - - TmuxCommandFailure.ThrowIfFailed(result, "new-session"); - string id = result.StandardOutputLines.Count > 0 - ? result.StandardOutputLines[0] - : throw new InvalidDataException("tmux reported no new session identifier."); - if (!SessionId.TryParse(id, out SessionId sessionId)) + SessionId sessionId = sequence.Observe(() => { - throw new InvalidDataException("tmux reported a malformed session identifier."); - } - - // Materializes and re-lists rather than resolving by id, so Name reads - // from the snapshot; skips GetSessionsAsync, whose lenient failure - // handling would turn a real listing error into a false negative. - Server materialized = await ConnectAsync(cancellationToken).ConfigureAwait(false); - IReadOnlyList> rows = await RelationReader - .ListAsync(materialized, "list-sessions", [], cancellationToken) + string id = result.StandardOutputLines.Count > 0 + ? result.StandardOutputLines[0] + : throw new InvalidDataException("tmux reported no new session identifier."); + return SessionId.TryParse(id, out SessionId parsed) + ? parsed + : throw new InvalidDataException("tmux reported a malformed session identifier."); + }); + + // Re-list directly so Name is materialized and listing errors remain failures. + // Replacing the last session may restart the daemon, so rediscover first. + Server materialized = await sequence + .ObserveAsync(() => RediscoverCurrentGenerationAsync(cancellationToken)) + .ConfigureAwait(false); + IReadOnlyList> rows = await sequence + .ObserveAsync(() => RelationReader.ListAsync( + materialized, + "list-sessions", + [], + cancellationToken)) .ConfigureAwait(false); - IEnumerable sessions = - rows.Select(row => RelationReader.ToSession(materialized, row)); - return sessions.FirstOrDefault(session => session.Id == sessionId) - ?? throw new TmuxObjectNotFoundException( - $"tmux did not report the created session '{sessionId}'.", - sessionId.ToString()); + return sequence.Observe(() => + { + IEnumerable sessions = + rows.Select(row => RelationReader.ToSession(materialized, row)); + return sessions.FirstOrDefault(session => session.Id == sessionId) + ?? throw new TmuxObjectNotFoundException( + $"tmux did not report the created session '{sessionId}'.", + sessionId.ToString()); + }); } /// Starts a server and takes ownership of it. @@ -196,9 +203,13 @@ public static async Task CreateOwnedAsync( CancellationToken cancellationToken = default) { Server endpoint = Open(options ?? ServerConnectionOptions.Default); - await endpoint.StartServerAsync(cancellationToken).ConfigureAwait(false); - await endpoint.WaitForSettledEndpointAsync(cancellationToken).ConfigureAwait(false); - return new OwnedServerScope(endpoint); + var sequence = new TmuxMutationSequence(); + await sequence.MutateAsync(() => endpoint.StartServerAsync(cancellationToken)) + .ConfigureAwait(false); + await sequence + .ObserveAsync(() => endpoint.WaitForSettledEndpointAsync(cancellationToken)) + .ConfigureAwait(false); + return sequence.Observe(() => new OwnedServerScope(endpoint)); } /// Creates a session and takes ownership of it. @@ -210,9 +221,11 @@ public async Task CreateOwnedSessionAsync( NewSessionRequest? request = null, CancellationToken cancellationToken = default) { - Session created = await CreateSessionAsync(request, cancellationToken) + var sequence = new TmuxMutationSequence(); + Session created = await sequence + .MutateAsync(() => CreateSessionAsync(request, cancellationToken)) .ConfigureAwait(false); - return new OwnedSessionScope(created); + return sequence.Observe(() => new OwnedSessionScope(created)); } /// Attaches a client to a session on this server. diff --git a/src/LibTmux/Server.Options.cs b/src/LibTmux/Server.Options.cs index 1ebfdcd..75bc071 100644 --- a/src/LibTmux/Server.Options.cs +++ b/src/LibTmux/Server.Options.cs @@ -3,7 +3,7 @@ namespace LibTmux; -/// Reaches the server's own option table. +// Reaches the server's own option table. public sealed partial class Server { private TmuxOptions? _options; diff --git a/src/LibTmux/Server.Snapshots.cs b/src/LibTmux/Server.Snapshots.cs index 1efdc9e..45dabd6 100644 --- a/src/LibTmux/Server.Snapshots.cs +++ b/src/LibTmux/Server.Snapshots.cs @@ -3,15 +3,8 @@ namespace LibTmux; -/// Reads what one capture of the server found. -/// -/// These say what a capture found, and nothing else. Reading one never reaches -/// tmux, so walking a server's sessions and each session's windows costs the -/// commands the capture ran and not one per step. A handle that has captured -/// nothing answers an uncaptured relation rather than an empty one, because -/// "nobody looked" and "there are none" are different answers and a caller -/// acting on the second when the first is true would be wrong. -/// +// Captured relations never query tmux and distinguish uncaptured data from an +// observed empty relation. public sealed partial class Server { private readonly ServerSnapshot? _snapshot; diff --git a/src/LibTmux/Server.Utilities.cs b/src/LibTmux/Server.Utilities.cs index 38077c7..e967244 100644 --- a/src/LibTmux/Server.Utilities.cs +++ b/src/LibTmux/Server.Utilities.cs @@ -18,13 +18,8 @@ public enum ShowMessagesMode Terminals, } -/// Keys, prompts, menus, buffers, and shell automation. -/// -/// These are the tmux commands that belong to the server rather than to -/// anything inside it. Several grew flags over the supported range: where a -/// flag is missing the request is still sent once without it and a warning -/// says so, and where a whole command is missing nothing is sent at all. -/// +// 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. diff --git a/src/LibTmux/Server.Version.cs b/src/LibTmux/Server.Version.cs index 68d8f86..75e91a3 100644 --- a/src/LibTmux/Server.Version.cs +++ b/src/LibTmux/Server.Version.cs @@ -1,6 +1,6 @@ namespace LibTmux; -/// Provides captured tmux version metadata. +// Provides captured tmux version metadata. public sealed partial class Server { /// Gets the captured tmux version. diff --git a/src/LibTmux/Session.Command.cs b/src/LibTmux/Session.Command.cs index 6beb28b..8b6762d 100644 --- a/src/LibTmux/Session.Command.cs +++ b/src/LibTmux/Session.Command.cs @@ -3,7 +3,7 @@ namespace LibTmux; -/// Provides raw command execution for a tmux session. +// Provides raw command execution for a tmux session. public sealed partial class Session { private readonly TmuxCommandDispatcher _commandDispatcher; @@ -24,7 +24,6 @@ public Task ExecuteCommandAsync( string? targetOverride = null, CancellationToken cancellationToken = default) { - PlatformGuard.ThrowIfWindows(); return TargetedCommandArguments.ExecuteAsync( _commandDispatcher, arguments, @@ -42,7 +41,6 @@ internal static Task ExecuteAsync( string target, CancellationToken cancellationToken) { - PlatformGuard.ThrowIfWindows(); TmuxCommandDispatcher.ValidateArguments(arguments); RejectRawTargetOptions(arguments); ArgumentException.ThrowIfNullOrWhiteSpace(target); diff --git a/src/LibTmux/Session.Environment.cs b/src/LibTmux/Session.Environment.cs index bcdc188..b78dff2 100644 --- a/src/LibTmux/Session.Environment.cs +++ b/src/LibTmux/Session.Environment.cs @@ -2,7 +2,7 @@ namespace LibTmux; -/// Resolves a session from tmux's exported environment. +// Resolves a session from tmux's exported environment. public sealed partial class Session { /// Returns the session holding the pane this process runs in. diff --git a/src/LibTmux/Session.EnvironmentOperations.cs b/src/LibTmux/Session.EnvironmentOperations.cs index b1888bf..1092732 100644 --- a/src/LibTmux/Session.EnvironmentOperations.cs +++ b/src/LibTmux/Session.EnvironmentOperations.cs @@ -2,7 +2,7 @@ namespace LibTmux; -/// Reaches this session's environment. +// Reaches this session's environment. public sealed partial class Session { private TmuxEnvironment? _environment; diff --git a/src/LibTmux/Session.Hooks.cs b/src/LibTmux/Session.Hooks.cs index d743499..1fecd59 100644 --- a/src/LibTmux/Session.Hooks.cs +++ b/src/LibTmux/Session.Hooks.cs @@ -2,7 +2,7 @@ namespace LibTmux; -/// Reaches this session's hooks. +// Reaches this session's hooks. public sealed partial class Session { private TmuxHooks? _hooks; diff --git a/src/LibTmux/Session.Identity.cs b/src/LibTmux/Session.Identity.cs index dcd1cee..ae962be 100644 --- a/src/LibTmux/Session.Identity.cs +++ b/src/LibTmux/Session.Identity.cs @@ -3,7 +3,7 @@ namespace LibTmux; -/// Provides typed session identity. +// Provides typed session identity. public sealed partial class Session { private readonly SessionId _id; diff --git a/src/LibTmux/Session.Lifecycle.cs b/src/LibTmux/Session.Lifecycle.cs index 3cb24b8..0859f16 100644 --- a/src/LibTmux/Session.Lifecycle.cs +++ b/src/LibTmux/Session.Lifecycle.cs @@ -4,12 +4,8 @@ namespace LibTmux; -/// Renames, refreshes, and tears down a session. -/// -/// Handles are immutable, so an operation that changes tmux state returns a -/// replacement rather than mutating the receiver. A stale handle stays a -/// truthful record of what was read. -/// +// Session mutations return replacement handles; stale handles remain immutable +// observations of what was read. public sealed partial class Session { private const string GroupKillCapability = "kill_session_group"; @@ -72,9 +68,10 @@ public async Task RenameAsync( CancellationToken cancellationToken = default) { SessionName.Validate(name); - await RunAsync(["rename-session", "-t", _id.ToString(), name], cancellationToken) + return await TmuxMutationSequence.RunAsync( + () => RunAsync(["rename-session", "-t", _id.ToString(), name], cancellationToken), + () => RefreshAsync(cancellationToken)) .ConfigureAwait(false); - return await RefreshAsync(cancellationToken).ConfigureAwait(false); } /// Stops this session. @@ -153,9 +150,10 @@ public async Task SelectWindowAsync( CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(target); - await RunAsync(["select-window", "-t", Scoped(target)], cancellationToken) + return await TmuxMutationSequence.RunAsync( + () => RunAsync(["select-window", "-t", Scoped(target)], cancellationToken), + () => ActiveWindowAsync(cancellationToken)) .ConfigureAwait(false); - return await ActiveWindowAsync(cancellationToken).ConfigureAwait(false); } /// Selects the next window. @@ -164,9 +162,10 @@ await RunAsync(["select-window", "-t", Scoped(target)], cancellationToken) [UnsupportedOSPlatform("windows")] public async Task SelectNextWindowAsync(CancellationToken cancellationToken = default) { - await RunAsync(["next-window", "-t", _id.ToString()], cancellationToken) + return await TmuxMutationSequence.RunAsync( + () => RunAsync(["next-window", "-t", _id.ToString()], cancellationToken), + () => ActiveWindowAsync(cancellationToken)) .ConfigureAwait(false); - return await ActiveWindowAsync(cancellationToken).ConfigureAwait(false); } /// Selects the previous window. @@ -176,9 +175,10 @@ await RunAsync(["next-window", "-t", _id.ToString()], cancellationToken) public async Task SelectPreviousWindowAsync( CancellationToken cancellationToken = default) { - await RunAsync(["previous-window", "-t", _id.ToString()], cancellationToken) + return await TmuxMutationSequence.RunAsync( + () => RunAsync(["previous-window", "-t", _id.ToString()], cancellationToken), + () => ActiveWindowAsync(cancellationToken)) .ConfigureAwait(false); - return await ActiveWindowAsync(cancellationToken).ConfigureAwait(false); } /// Stops one window in this session. @@ -198,9 +198,10 @@ public Task KillWindowAsync( [UnsupportedOSPlatform("windows")] public async Task SwitchClientAsync(CancellationToken cancellationToken = default) { - await RunAsync(["switch-client", "-t", _id.ToString()], cancellationToken) + return await TmuxMutationSequence.RunAsync( + () => RunAsync(["switch-client", "-t", _id.ToString()], cancellationToken), + () => RefreshAsync(cancellationToken)) .ConfigureAwait(false); - return await RefreshAsync(cancellationToken).ConfigureAwait(false); } /// Runs a tmux-side filter over this session's windows. @@ -259,11 +260,12 @@ public async Task AttachAsync( CancellationToken cancellationToken = default) { AttachSessionRequest options = request ?? new AttachSessionRequest(); - await RunAsync( - [.. BuildAttachArguments(options, _id.ToString())], - cancellationToken) + return await TmuxMutationSequence.RunAsync( + () => RunAsync( + [.. BuildAttachArguments(options, _id.ToString())], + cancellationToken), + () => RefreshAsync(cancellationToken)) .ConfigureAwait(false); - return await RefreshAsync(cancellationToken).ConfigureAwait(false); } /// Creates a window in this session. @@ -277,22 +279,68 @@ public async Task CreateWindowAsync( { NewWindowRequest options = request ?? new NewWindowRequest(); Server owner = RequireOwner("windows"); - TmuxCommandResult result = await _commandDispatcher - .ExecuteAsync([.. BuildNewWindowArguments(options, _id.ToString())], cancellationToken) + bool maySelectExisting = options.SelectExisting + && options.Name is not null + && options.Index is null + && options.TargetWindow is null; + string? selectedName = maySelectExisting + ? await ExpandWindowNameAsync(options.Name!, cancellationToken).ConfigureAwait(false) + : null; + var sequence = new TmuxMutationSequence(); + TmuxCommandResult result = await sequence.MutateAsync( + () => _commandDispatcher.ExecuteAsync( + [.. BuildNewWindowArguments(options, _id.ToString())], + cancellationToken), + static value => TmuxCommandFailure.ThrowIfFailed(value, "new-window")) .ConfigureAwait(false); - TmuxCommandFailure.ThrowIfFailed(result, "new-window"); - if (result.StandardOutputLines.Count == 0 - || !WindowId.TryParse(result.StandardOutputLines[0], out WindowId created)) + + if (result.StandardOutputLines.Count == 0 && selectedName is not null) { - throw new InvalidDataException("tmux reported no new window identifier."); + IReadOnlyList selectedWindows = await sequence + .ObserveAsync(() => GetWindowsAsync(cancellationToken)) + .ConfigureAwait(false); + return sequence.Observe(() => + { + Window[] matches = + [.. selectedWindows.Where(window => + string.Equals(window.Name, selectedName, StringComparison.Ordinal))]; + return matches.Length == 1 + ? matches[0] + : throw new InvalidDataException( + $"tmux did not report exactly one selected window named '{selectedName}'."); + }); } - IReadOnlyList windows = await owner.GetWindowsAsync(cancellationToken) + 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 windows.FirstOrDefault(window => window.Id == created) - ?? throw new TmuxObjectNotFoundException( - $"tmux did not report the created window '{created}'.", - created.ToString()); + return sequence.Observe(() => + windows.FirstOrDefault(window => window.Id == created) + ?? throw new TmuxObjectNotFoundException( + $"tmux did not report the created window '{created}'.", + created.ToString())); + } + + [UnsupportedOSPlatform("windows")] + private async Task ExpandWindowNameAsync( + string name, + CancellationToken cancellationToken) + { + TmuxCommandResult result = await _commandDispatcher.ExecuteAsync( + ["display-message", "-p", "-t", _id.ToString(), "--", name], + cancellationToken) + .ConfigureAwait(false); + TmuxCommandFailure.ThrowIfFailed(result, "display-message"); + return result.StandardOutputLines.Count == 1 + ? result.StandardOutputLines[0] + : throw new InvalidDataException( + "tmux did not report exactly one expanded window name."); } internal static IEnumerable BuildAttachArguments( @@ -429,10 +477,8 @@ private async Task ActiveWindowAsync(CancellationToken cancellationToken _id.ToString()); } - // tmux resolves a bare window name against the caller's current session, so - // every target is anchored here. Anchoring is unconditional because tmux - // accepts ':' inside a window name: treating one as "already qualified" - // sends "a:b" and tmux then looks for window b in a session named a. + // Always anchor: tmux resolves bare names in the current session, while ':' + // may be part of a window name rather than an already-qualified target. private string Scoped(string target) => $"{_id}:{target}"; [UnsupportedOSPlatform("windows")] diff --git a/src/LibTmux/Session.Options.cs b/src/LibTmux/Session.Options.cs index d613f02..2dbbb52 100644 --- a/src/LibTmux/Session.Options.cs +++ b/src/LibTmux/Session.Options.cs @@ -3,7 +3,7 @@ namespace LibTmux; -/// Reaches this session's option table. +// Reaches this session's option table. public sealed partial class Session { private TmuxOptions? _options; diff --git a/src/LibTmux/Session.Relations.cs b/src/LibTmux/Session.Relations.cs index 023605f..057b5ae 100644 --- a/src/LibTmux/Session.Relations.cs +++ b/src/LibTmux/Session.Relations.cs @@ -3,7 +3,7 @@ namespace LibTmux; -/// Provides session hierarchy relations. +// Provides session hierarchy relations. public sealed partial class Session { private readonly Server? _owner; diff --git a/src/LibTmux/Session.WindowNavigation.cs b/src/LibTmux/Session.WindowNavigation.cs index d02e4ab..0cdd635 100644 --- a/src/LibTmux/Session.WindowNavigation.cs +++ b/src/LibTmux/Session.WindowNavigation.cs @@ -1,8 +1,9 @@ using System.Runtime.Versioning; +using LibTmux.Internal; namespace LibTmux; -/// Moves between a session's windows and owns ones it creates. +// Moves between a session's windows and owns ones it creates. public sealed partial class Session { /// Selects the window that was last active. @@ -11,9 +12,10 @@ public sealed partial class Session [UnsupportedOSPlatform("windows")] public async Task SelectLastWindowAsync(CancellationToken cancellationToken = default) { - await RunAsync(["last-window", "-t", _id.ToString()], cancellationToken) + return await TmuxMutationSequence.RunAsync( + () => RunAsync(["last-window", "-t", _id.ToString()], cancellationToken), + () => ActiveWindowAsync(cancellationToken)) .ConfigureAwait(false); - return await ActiveWindowAsync(cancellationToken).ConfigureAwait(false); } /// Creates a window in this session and takes ownership of it. @@ -25,8 +27,11 @@ public async Task CreateOwnedWindowAsync( NewWindowRequest? request = null, CancellationToken cancellationToken = default) { - Window created = await CreateWindowAsync(request, cancellationToken).ConfigureAwait(false); - return new OwnedWindowScope(created); + var sequence = new TmuxMutationSequence(); + Window created = await sequence + .MutateAsync(() => CreateWindowAsync(request, cancellationToken)) + .ConfigureAwait(false); + return sequence.Observe(() => new OwnedWindowScope(created)); } } diff --git a/src/LibTmux/Testing/TemporaryScopeCleanup.cs b/src/LibTmux/Testing/TemporaryScopeCleanup.cs new file mode 100644 index 0000000..022c07c --- /dev/null +++ b/src/LibTmux/Testing/TemporaryScopeCleanup.cs @@ -0,0 +1,61 @@ +using System.Runtime.ExceptionServices; + +namespace LibTmux.Testing; + +internal static class TemporaryScopeCleanup +{ + private const string SecondaryFailureDataKey = "LibTmux.Testing.SecondaryCleanupFailure"; + + internal static async ValueTask DisposeAsync( + IAsyncDisposable value, + IAsyncDisposable? parent) + { + Exception? failure = null; + try + { + await value.DisposeAsync().ConfigureAwait(false); + } + catch (Exception error) + { + failure = error; + } + + if (parent is not null) + { + try + { + await parent.DisposeAsync().ConfigureAwait(false); + } + catch (Exception error) + { + if (failure is null) + { + failure = error; + } + else + { + failure.Data[SecondaryFailureDataKey] = error; + } + } + } + + if (failure is not null) + { + ExceptionDispatchInfo.Capture(failure).Throw(); + } + } + + internal static async Task DisposeAfterFailureAsync( + IAsyncDisposable value, + Exception primaryFailure) + { + try + { + await value.DisposeAsync().ConfigureAwait(false); + } + catch (Exception cleanupFailure) + { + primaryFailure.Data[SecondaryFailureDataKey] = cleanupFailure; + } + } +} diff --git a/src/LibTmux/Testing/TemporarySessionScope.cs b/src/LibTmux/Testing/TemporarySessionScope.cs index 01084d6..5dd83ff 100644 --- a/src/LibTmux/Testing/TemporarySessionScope.cs +++ b/src/LibTmux/Testing/TemporarySessionScope.cs @@ -2,15 +2,18 @@ namespace LibTmux.Testing; -/// Creates a throwaway session for a test and stops it afterwards. +/// Owns a throwaway session and any private server created with it. [UnsupportedOSPlatform("windows")] public sealed class TemporarySessionScope : IAsyncDisposable { private readonly OwnedSessionScope _owned; + private readonly IAsyncDisposable? _parent; + private int _disposed; - private TemporarySessionScope(OwnedSessionScope owned) + private TemporarySessionScope(OwnedSessionScope owned, IAsyncDisposable? parent) { _owned = owned; + _parent = parent; Session = owned.Value; } @@ -20,21 +23,31 @@ private TemporarySessionScope(OwnedSessionScope owned) /// Creates a temporary session on a running server. /// The server to create the session on. /// The session to create. + /// The parent scope transferred into this scope. /// Cancels the tmux commands. /// The scope owning the session. [UnsupportedOSPlatform("windows")] internal static async Task StartAsync( Server server, NewSessionRequest? request = null, + IAsyncDisposable? parent = null, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(server); OwnedSessionScope owned = await server .CreateOwnedSessionAsync(request, cancellationToken) .ConfigureAwait(false); - return new TemporarySessionScope(owned); + return new TemporarySessionScope(owned, parent); } /// - public ValueTask DisposeAsync() => _owned.DisposeAsync(); + public async ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + await TemporaryScopeCleanup.DisposeAsync(_owned, _parent).ConfigureAwait(false); + } } diff --git a/src/LibTmux/Testing/TemporaryWindowScope.cs b/src/LibTmux/Testing/TemporaryWindowScope.cs index 1c4093e..9c6a483 100644 --- a/src/LibTmux/Testing/TemporaryWindowScope.cs +++ b/src/LibTmux/Testing/TemporaryWindowScope.cs @@ -2,15 +2,18 @@ namespace LibTmux.Testing; -/// Creates a throwaway window for a test and stops it afterwards. +/// Owns a throwaway window and any private session and server created with it. [UnsupportedOSPlatform("windows")] public sealed class TemporaryWindowScope : IAsyncDisposable { private readonly OwnedWindowScope _owned; + private readonly IAsyncDisposable? _parent; + private int _disposed; - private TemporaryWindowScope(OwnedWindowScope owned) + private TemporaryWindowScope(OwnedWindowScope owned, IAsyncDisposable? parent) { _owned = owned; + _parent = parent; Window = owned.Value; } @@ -20,21 +23,31 @@ private TemporaryWindowScope(OwnedWindowScope owned) /// Creates a temporary window in a session. /// The session to create the window in. /// The window to create. + /// The parent scope transferred into this scope. /// Cancels the tmux commands. /// The scope owning the window. [UnsupportedOSPlatform("windows")] internal static async Task StartAsync( Session session, NewWindowRequest? request = null, + IAsyncDisposable? parent = null, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(session); OwnedWindowScope owned = await session .CreateOwnedWindowAsync(request, cancellationToken) .ConfigureAwait(false); - return new TemporaryWindowScope(owned); + return new TemporaryWindowScope(owned, parent); } /// - public ValueTask DisposeAsync() => _owned.DisposeAsync(); + public async ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + { + return; + } + + await TemporaryScopeCleanup.DisposeAsync(_owned, _parent).ConfigureAwait(false); + } } diff --git a/src/LibTmux/Testing/TmuxTestFactory.cs b/src/LibTmux/Testing/TmuxTestFactory.cs index b76db47..700608e 100644 --- a/src/LibTmux/Testing/TmuxTestFactory.cs +++ b/src/LibTmux/Testing/TmuxTestFactory.cs @@ -63,8 +63,28 @@ public async Task CreateSessionAsync( TmuxTestOptions settings = options ?? TmuxTestOptions.Default; TemporaryServerScope scope = await CreateServerAsync(settings, cancellationToken) .ConfigureAwait(false); - return await CreateSessionAsync(scope.Server, settings, cancellationToken) - .ConfigureAwait(false); + try + { + string name = await _names + .CreateAvailableSessionNameAsync( + scope.Server, + settings.SessionNamePrefix, + cancellationToken) + .ConfigureAwait(false); + return await TemporarySessionScope + .StartAsync( + scope.Server, + new NewSessionRequest(name: name), + scope, + cancellationToken) + .ConfigureAwait(false); + } + catch (Exception error) + { + await TemporaryScopeCleanup.DisposeAfterFailureAsync(scope, error) + .ConfigureAwait(false); + throw; + } } /// Starts a session on a server the caller already has. @@ -86,7 +106,10 @@ public async Task CreateSessionAsync( cancellationToken) .ConfigureAwait(false); return await TemporarySessionScope - .StartAsync(server, new NewSessionRequest(name: name), cancellationToken) + .StartAsync( + server, + new NewSessionRequest(name: name), + cancellationToken: cancellationToken) .ConfigureAwait(false); } @@ -101,8 +124,28 @@ public async Task CreateWindowAsync( TmuxTestOptions settings = options ?? TmuxTestOptions.Default; TemporarySessionScope session = await CreateSessionAsync(settings, cancellationToken) .ConfigureAwait(false); - return await CreateWindowAsync(session.Session, settings, cancellationToken) - .ConfigureAwait(false); + try + { + string name = await _names + .CreateAvailableWindowNameAsync( + session.Session, + settings.SessionNamePrefix, + cancellationToken) + .ConfigureAwait(false); + return await TemporaryWindowScope + .StartAsync( + session.Session, + new NewWindowRequest(name: name), + session, + cancellationToken) + .ConfigureAwait(false); + } + catch (Exception error) + { + await TemporaryScopeCleanup.DisposeAfterFailureAsync(session, error) + .ConfigureAwait(false); + throw; + } } /// Starts a window in a session the caller already has. @@ -124,7 +167,10 @@ public async Task CreateWindowAsync( cancellationToken) .ConfigureAwait(false); return await TemporaryWindowScope - .StartAsync(session, new NewWindowRequest(name: name), cancellationToken) + .StartAsync( + session, + new NewWindowRequest(name: name), + cancellationToken: cancellationToken) .ConfigureAwait(false); } diff --git a/src/LibTmux/Transport/TmuxCommandDispatcher.cs b/src/LibTmux/Transport/TmuxCommandDispatcher.cs index 3159886..f69d339 100644 --- a/src/LibTmux/Transport/TmuxCommandDispatcher.cs +++ b/src/LibTmux/Transport/TmuxCommandDispatcher.cs @@ -44,7 +44,6 @@ internal async Task ExecuteGroupAsync( IReadOnlyList> commands, CancellationToken cancellationToken = default) { - PlatformGuard.ThrowIfWindows(); ArgumentNullException.ThrowIfNull(commands); if (_executeGroup is null) { @@ -71,7 +70,6 @@ internal async Task ExecuteAsync( IReadOnlyList arguments, CancellationToken cancellationToken = default) { - PlatformGuard.ThrowIfWindows(); ValidateArguments(arguments); string[] copy = [.. arguments]; TmuxCommandResult result = await _execute(copy, cancellationToken).ConfigureAwait(false); diff --git a/src/LibTmux/Transport/TmuxProcessTransport.cs b/src/LibTmux/Transport/TmuxProcessTransport.cs index 1ab0cb4..53d997f 100644 --- a/src/LibTmux/Transport/TmuxProcessTransport.cs +++ b/src/LibTmux/Transport/TmuxProcessTransport.cs @@ -1,6 +1,5 @@ using System.ComponentModel; using System.Diagnostics; -using System.Runtime.Versioning; namespace LibTmux.Internal; @@ -33,19 +32,21 @@ internal sealed class TmuxProcessTransport private readonly TmuxTransportLimits _limits; private readonly ITmuxProcessLauncher _launcher; private readonly TimeProvider _timeProvider; - + private readonly Func? _beforeStart; internal TmuxProcessTransport( string executablePath, IReadOnlyList? prefixArguments = null, TmuxTransportLimits? limits = null, Func? launcher = null, - TimeProvider? timeProvider = null) + TimeProvider? timeProvider = null, + Func? beforeStart = null) : this( executablePath, launcher is null ? new SystemProcessLauncher() : new DelegateProcessLauncher(launcher), prefixArguments, limits, - timeProvider) + timeProvider, + beforeStart) { } @@ -54,7 +55,8 @@ internal TmuxProcessTransport( ITmuxProcessLauncher launcher, IReadOnlyList? prefixArguments = null, TmuxTransportLimits? limits = null, - TimeProvider? timeProvider = null) + TimeProvider? timeProvider = null, + Func? beforeStart = null) { ArgumentException.ThrowIfNullOrWhiteSpace(executablePath); _executablePath = executablePath; @@ -62,20 +64,18 @@ internal TmuxProcessTransport( _limits = limits ?? new TmuxTransportLimits(); _launcher = launcher ?? throw new ArgumentNullException(nameof(launcher)); _timeProvider = timeProvider ?? TimeProvider.System; + _beforeStart = beforeStart; } - [UnsupportedOSPlatform("windows")] internal Task ExecuteAsync( IReadOnlyList arguments, CancellationToken cancellationToken = default) => ExecuteAsync(TmuxCommandRequest.Single(arguments), cancellationToken); - [UnsupportedOSPlatform("windows")] internal async Task ExecuteAsync( TmuxCommandRequest request, CancellationToken cancellationToken = default) { - PlatformGuard.ThrowIfWindows(); ArgumentNullException.ThrowIfNull(request); cancellationToken.ThrowIfCancellationRequested(); IReadOnlyList encodedArguments = request.EncodeArguments(); @@ -92,8 +92,18 @@ internal async Task ExecuteAsync( ITmuxProcessHandle process; try { + if (_beforeStart is not null) + { + await _beforeStart(startInfo, cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + } + process = _launcher.Start(startInfo); } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } catch (Win32Exception error) when (error.NativeErrorCode is 2 or 3) { throw new TmuxCommandNotFoundException( @@ -514,15 +524,3 @@ public ValueTask DisposeAsync() } } } - -internal static class PlatformGuard -{ - internal static void ThrowIfWindows() - { - if (OperatingSystem.IsWindows()) - { - throw new PlatformNotSupportedException( - "Process-backed LibTmux operations are not supported on Windows."); - } - } -} diff --git a/src/LibTmux/Versioning/TmuxVersion.cs b/src/LibTmux/Versioning/TmuxVersion.cs index 00ce888..3375e31 100644 --- a/src/LibTmux/Versioning/TmuxVersion.cs +++ b/src/LibTmux/Versioning/TmuxVersion.cs @@ -103,7 +103,8 @@ public int CompareTo(TmuxVersion other) { VersionKind.Development or VersionKind.ReleaseCandidate => _sequence.CompareTo(other._sequence), - VersionKind.Release => ComparePatch(_patch, other._patch), + VersionKind.MicroRelease => _sequence.CompareTo(other._sequence), + VersionKind.PatchRelease => ComparePatch(_patch, other._patch), _ => 0, }; if (comparison != 0) @@ -141,8 +142,17 @@ public static async Task DetectStringAsync( string tmuxBinaryPath = "tmux", CancellationToken cancellationToken = default) { - PlatformGuard.ThrowIfWindows(); ArgumentException.ThrowIfNullOrWhiteSpace(tmuxBinaryPath); + if (OperatingSystem.IsWindows() + || string.Equals( + Path.GetExtension(tmuxBinaryPath), + ".exe", + StringComparison.OrdinalIgnoreCase)) + { + throw new PlatformNotSupportedException( + "Standalone version detection does not launch Windows executables."); + } + var transport = new TmuxProcessTransport(tmuxBinaryPath); TmuxCommandResult result = await transport .ExecuteAsync(["-V"], cancellationToken) @@ -286,7 +296,7 @@ public static async Task EnsureMinimumSupportedVersionAsync( left.CompareTo(right) >= 0; [GeneratedRegex( - "\\A(?:next-(?0|[1-9][0-9]*)\\.(?0|[1-9][0-9]*)|(?0|[1-9][0-9]*)\\.(?0|[1-9][0-9]*)(?:(?[a-z]+)(?-openbsd)?|(?-openbsd)|-rc(?[1-9][0-9]*)|-dev(?:\\.(?0|[1-9][0-9]*))?)?)\\z", + "\\A(?:next-(?0|[1-9][0-9]*)\\.(?0|[1-9][0-9]*)|(?0|[1-9][0-9]*)\\.(?0|[1-9][0-9]*)(?:\\.(?0|[1-9][0-9]*)|(?[a-z]+)(?-openbsd)?|(?-openbsd)|-rc(?[1-9][0-9]*)|-dev(?:\\.(?0|[1-9][0-9]*))?)?)\\z", RegexOptions.CultureInvariant)] private static partial Regex VersionRegex(); @@ -349,10 +359,21 @@ private static bool TryParseParts(string text, out ParsedVersion result) suffix = "dev"; } } + else if (match.Groups["micro"].Success) + { + if (!TryParseComponent(match.Groups["micro"].Value, out sequence)) + { + result = default; + return false; + } + + kind = VersionKind.MicroRelease; + suffix = sequence.ToString(CultureInfo.InvariantCulture); + } else { - kind = VersionKind.Release; patch = match.Groups["patch"].Success ? match.Groups["patch"].Value : null; + kind = patch is null ? VersionKind.Release : VersionKind.PatchRelease; vendor = match.Groups["patchVendor"].Success || match.Groups["finalVendor"].Success; suffix = patch is null @@ -410,6 +431,8 @@ private enum VersionKind Development = 1, ReleaseCandidate = 2, Release = 3, + MicroRelease = 4, + PatchRelease = 5, } private readonly record struct ParsedVersion( diff --git a/src/LibTmux/Window.Command.cs b/src/LibTmux/Window.Command.cs index 6487c32..da98060 100644 --- a/src/LibTmux/Window.Command.cs +++ b/src/LibTmux/Window.Command.cs @@ -3,7 +3,7 @@ namespace LibTmux; -/// Provides raw command execution for a tmux window. +// Provides raw command execution for a tmux window. public sealed partial class Window { private readonly TmuxCommandDispatcher _commandDispatcher; @@ -24,7 +24,6 @@ public Task ExecuteCommandAsync( string? targetOverride = null, CancellationToken cancellationToken = default) { - PlatformGuard.ThrowIfWindows(); return TargetedCommandArguments.ExecuteAsync( _commandDispatcher, arguments, diff --git a/src/LibTmux/Window.Environment.cs b/src/LibTmux/Window.Environment.cs index 044be03..799675c 100644 --- a/src/LibTmux/Window.Environment.cs +++ b/src/LibTmux/Window.Environment.cs @@ -2,7 +2,7 @@ namespace LibTmux; -/// Resolves a window from tmux's exported environment. +// Resolves a window from tmux's exported environment. public sealed partial class Window { /// Returns the window holding the pane this process runs in. diff --git a/src/LibTmux/Window.Hooks.cs b/src/LibTmux/Window.Hooks.cs index 1031852..5ab77b3 100644 --- a/src/LibTmux/Window.Hooks.cs +++ b/src/LibTmux/Window.Hooks.cs @@ -2,7 +2,7 @@ namespace LibTmux; -/// Reaches this window's hooks. +// Reaches this window's hooks. public sealed partial class Window { private TmuxHooks? _hooks; diff --git a/src/LibTmux/Window.Identity.cs b/src/LibTmux/Window.Identity.cs index 5ed1a6f..457584c 100644 --- a/src/LibTmux/Window.Identity.cs +++ b/src/LibTmux/Window.Identity.cs @@ -3,7 +3,7 @@ namespace LibTmux; -/// Provides typed window identity. +// Provides typed window identity. public sealed partial class Window { private readonly WindowId _id; diff --git a/src/LibTmux/Window.Options.cs b/src/LibTmux/Window.Options.cs index 9121a05..b4f2c65 100644 --- a/src/LibTmux/Window.Options.cs +++ b/src/LibTmux/Window.Options.cs @@ -3,7 +3,7 @@ namespace LibTmux; -/// Reaches this window's option table. +// Reaches this window's option table. public sealed partial class Window { private TmuxOptions? _options; diff --git a/src/LibTmux/Window.PaneNavigation.cs b/src/LibTmux/Window.PaneNavigation.cs index 2e10512..1927b87 100644 --- a/src/LibTmux/Window.PaneNavigation.cs +++ b/src/LibTmux/Window.PaneNavigation.cs @@ -1,4 +1,5 @@ using System.Runtime.Versioning; +using LibTmux.Internal; namespace LibTmux; @@ -12,7 +13,7 @@ public enum PaneInputMode Disable = 1, } -/// Moves between a window's panes. +// Moves between a window's panes. public sealed partial class Window { /// Selects the pane that was last active. @@ -42,8 +43,14 @@ public sealed partial class Window arguments.Add("-Z"); } - await RunAsync(arguments, cancellationToken).ConfigureAwait(false); - IReadOnlyList panes = await GetPanesAsync(cancellationToken).ConfigureAwait(false); - return panes.FirstOrDefault(pane => pane.Snapshot?["pane_active"] == "1"); + 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.Relations.cs b/src/LibTmux/Window.Relations.cs index cf4482a..ace6b6a 100644 --- a/src/LibTmux/Window.Relations.cs +++ b/src/LibTmux/Window.Relations.cs @@ -4,7 +4,7 @@ namespace LibTmux; -/// Provides window hierarchy relations. +// Provides window hierarchy relations. public sealed partial class Window { private readonly Server? _owner; diff --git a/src/LibTmux/Window.Topology.cs b/src/LibTmux/Window.Topology.cs index 2fba53b..bf999f3 100644 --- a/src/LibTmux/Window.Topology.cs +++ b/src/LibTmux/Window.Topology.cs @@ -15,13 +15,8 @@ public enum WindowRotationDirection Down = 1, } -/// Lays out, moves, links, and tears down a window. -/// -/// Handles are immutable, so an operation that changes tmux state returns a -/// replacement rather than mutating the receiver. Operations that destroy or -/// re-home a window return nothing, because there is no truthful replacement -/// to hand back. -/// +// 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"; @@ -130,9 +125,10 @@ public async Task RenameAsync( CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(name); - await RunAsync(["rename-window", "-t", Target, name], cancellationToken) + return await TmuxMutationSequence.RunAsync( + () => RunAsync(["rename-window", "-t", Target, name], cancellationToken), + () => RefreshAsync(cancellationToken)) .ConfigureAwait(false); - return await RefreshAsync(cancellationToken).ConfigureAwait(false); } /// Selects this window in its session. @@ -141,8 +137,10 @@ await RunAsync(["rename-window", "-t", Target, name], cancellationToken) [UnsupportedOSPlatform("windows")] public async Task SelectAsync(CancellationToken cancellationToken = default) { - await RunAsync(["select-window", "-t", Target], cancellationToken).ConfigureAwait(false); - return await RefreshAsync(cancellationToken).ConfigureAwait(false); + return await TmuxMutationSequence.RunAsync( + () => RunAsync(["select-window", "-t", Target], cancellationToken), + () => RefreshAsync(cancellationToken)) + .ConfigureAwait(false); } /// Stops this window. @@ -282,8 +280,10 @@ public async Task MoveAsync( ArgumentNullException.ThrowIfNull(request); List arguments = BuildMoveWindowArguments(request); - await RunAsync(arguments, cancellationToken).ConfigureAwait(false); - return await RefreshAsync(cancellationToken).ConfigureAwait(false); + return await TmuxMutationSequence.RunAsync( + () => RunAsync(arguments, cancellationToken), + () => RefreshAsync(cancellationToken)) + .ConfigureAwait(false); } /// Swaps this window with another. @@ -327,8 +327,10 @@ public async Task ResizeAsync( ArgumentNullException.ThrowIfNull(request); List arguments = BuildResizeWindowArguments(request); - await RunAsync(arguments, cancellationToken).ConfigureAwait(false); - return await RefreshAsync(cancellationToken).ConfigureAwait(false); + return await TmuxMutationSequence.RunAsync( + () => RunAsync(arguments, cancellationToken), + () => RefreshAsync(cancellationToken)) + .ConfigureAwait(false); } /// Rotates the panes in this window. @@ -353,8 +355,10 @@ public async Task RotateAsync( arguments.Add("-Z"); } - await RunAsync(arguments, cancellationToken).ConfigureAwait(false); - return await RefreshAsync(cancellationToken).ConfigureAwait(false); + return await TmuxMutationSequence.RunAsync( + () => RunAsync(arguments, cancellationToken), + () => RefreshAsync(cancellationToken)) + .ConfigureAwait(false); } /// Restarts the command running in this window. @@ -422,31 +426,26 @@ public async Task CreateWindowAsync( arguments.Add(options.Command); } - TmuxCommandResult result = await _commandDispatcher - .ExecuteAsync(arguments, cancellationToken) + var sequence = new TmuxMutationSequence(); + TmuxCommandResult result = await sequence.MutateAsync( + () => _commandDispatcher.ExecuteAsync(arguments, cancellationToken), + static value => TmuxCommandFailure.ThrowIfFailed(value, "new-window")) .ConfigureAwait(false); - TmuxCommandFailure.ThrowIfFailed(result, "new-window"); - // -S selects an existing window of the same name and returns before - // tmux prints anything, so an empty listing there is the documented - // outcome rather than a protocol error. - if (result.StandardOutputLines.Count == 0 && options.SelectExisting) - { - return await RefreshActiveAsync(owner, cancellationToken).ConfigureAwait(false); - } - - if (result.StandardOutputLines.Count == 0 - || !WindowId.TryParse(result.StandardOutputLines[0], out WindowId created)) - { - throw new InvalidDataException("tmux reported no new window identifier."); - } + 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 owner.GetWindowsAsync(cancellationToken) + IReadOnlyList windows = await sequence + .ObserveAsync(() => owner.GetWindowsAsync(cancellationToken)) .ConfigureAwait(false); - return windows.FirstOrDefault(window => window.Id == created) - ?? throw new TmuxObjectNotFoundException( - $"tmux did not report the created window '{created}'.", - created.ToString()); + 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) @@ -518,8 +517,10 @@ public async Task SelectLayoutAsync( SelectLayoutRequest options = request ?? new SelectLayoutRequest(); List arguments = BuildSelectLayoutArguments(options); - await RunAsync(arguments, cancellationToken).ConfigureAwait(false); - return await RefreshAsync(cancellationToken).ConfigureAwait(false); + return await TmuxMutationSequence.RunAsync( + () => RunAsync(arguments, cancellationToken), + () => RefreshAsync(cancellationToken)) + .ConfigureAwait(false); } /// Moves to the next layout. @@ -529,8 +530,10 @@ public async Task SelectLayoutAsync( public async Task SelectNextLayoutAsync( CancellationToken cancellationToken = default) { - await RunAsync(["next-layout", "-t", Target], cancellationToken).ConfigureAwait(false); - return await RefreshAsync(cancellationToken).ConfigureAwait(false); + return await TmuxMutationSequence.RunAsync( + () => RunAsync(["next-layout", "-t", Target], cancellationToken), + () => RefreshAsync(cancellationToken)) + .ConfigureAwait(false); } /// Moves to the previous layout. @@ -540,8 +543,10 @@ public async Task SelectNextLayoutAsync( public async Task SelectPreviousLayoutAsync( CancellationToken cancellationToken = default) { - await RunAsync(["previous-layout", "-t", Target], cancellationToken).ConfigureAwait(false); - return await RefreshAsync(cancellationToken).ConfigureAwait(false); + return await TmuxMutationSequence.RunAsync( + () => RunAsync(["previous-layout", "-t", Target], cancellationToken), + () => RefreshAsync(cancellationToken)) + .ConfigureAwait(false); } /// Selects a pane in this window. @@ -559,9 +564,15 @@ public async Task SelectPreviousLayoutAsync( List arguments = target is "-l" or "-U" or "-D" or "-L" or "-R" ? ["select-pane", "-t", Target, target] : ["select-pane", "-t", $"{Target}.{target}"]; - await RunAsync(arguments, cancellationToken).ConfigureAwait(false); - IReadOnlyList panes = await GetPanesAsync(cancellationToken).ConfigureAwait(false); - return panes.FirstOrDefault(pane => pane.Snapshot?["pane_active"] == "1"); + 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. @@ -637,21 +648,25 @@ options.Percentage is int share arguments.Add(options.Command); } - TmuxCommandResult result = await _commandDispatcher - .ExecuteAsync(arguments, cancellationToken) + var sequence = new TmuxMutationSequence(); + TmuxCommandResult result = await sequence.MutateAsync( + () => _commandDispatcher.ExecuteAsync(arguments, cancellationToken), + static value => TmuxCommandFailure.ThrowIfFailed(value, "split-window")) .ConfigureAwait(false); - TmuxCommandFailure.ThrowIfFailed(result, "split-window"); - if (result.StandardOutputLines.Count == 0 - || !PaneId.TryParse(result.StandardOutputLines[0], out PaneId created)) - { - throw new InvalidDataException("tmux reported no new pane identifier."); - } - - IReadOnlyList panes = await GetPanesAsync(cancellationToken).ConfigureAwait(false); - return panes.FirstOrDefault(pane => pane.Id == created) - ?? throw new TmuxObjectNotFoundException( - $"tmux did not report the created pane '{created}'.", - created.ToString()); + 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. @@ -722,21 +737,25 @@ public async Task CreatePaneAsync( arguments.Add(options.Command); } - TmuxCommandResult result = await _commandDispatcher - .ExecuteAsync(arguments, cancellationToken) + var sequence = new TmuxMutationSequence(); + TmuxCommandResult result = await sequence.MutateAsync( + () => _commandDispatcher.ExecuteAsync(arguments, cancellationToken), + static value => TmuxCommandFailure.ThrowIfFailed(value, "new-pane")) .ConfigureAwait(false); - TmuxCommandFailure.ThrowIfFailed(result, "new-pane"); - if (result.StandardOutputLines.Count == 0 - || !PaneId.TryParse(result.StandardOutputLines[0], out PaneId created)) - { - throw new InvalidDataException("tmux reported no new pane identifier."); - } - - IReadOnlyList panes = await GetPanesAsync(cancellationToken).ConfigureAwait(false); - return panes.FirstOrDefault(pane => pane.Id == created) - ?? throw new TmuxObjectNotFoundException( - $"tmux did not report the created pane '{created}'.", - created.ToString()); + 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. @@ -1010,22 +1029,6 @@ private void ValidateLayout(string layout) _id); } - [UnsupportedOSPlatform("windows")] - private async Task RefreshActiveAsync( - Server owner, - CancellationToken cancellationToken) - { - IReadOnlyList windows = await owner.GetWindowsAsync(cancellationToken) - .ConfigureAwait(false); - string? session = ReadSnapshot("session_id"); - return windows.FirstOrDefault(window => - window.ReadSnapshot("session_id") == session - && window.ReadSnapshot("window_active") == "1") - ?? throw new TmuxObjectNotFoundException( - "tmux reported no active window after selecting one.", - _id.ToString()); - } - private string Target => _id.ToString(); // A bare window id lets tmux choose which link it means, so any operation diff --git a/src/LibTmux/packages.lock.json b/src/LibTmux/packages.lock.json index 0b2a681..75e0060 100644 --- a/src/LibTmux/packages.lock.json +++ b/src/LibTmux/packages.lock.json @@ -39,12 +39,11 @@ }, "Microsoft.Extensions.Logging.Abstractions": { "type": "Direct", - "requested": "[10.0.11, )", - "resolved": "10.0.11", - "contentHash": "Ljd0Uxoq5XpScD2Bg0nM/r3mwx7Ao5Uq24eo2ARxbGvqJ7Zht6rt2cJtwVRH4Cv+1ZVMdXz6TB43KbpmsxRrvQ==", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "arDBqTgFCyS0EvRV7O3MZturChstm50OJ0y9bDJvAcmEPJm0FFpFyjU/JLYyStNGGey081DvnQYlncNX5SJJGA==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", - "System.Diagnostics.DiagnosticSource": "10.0.11" + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0" } }, "Microsoft.NET.ILLink.Tasks": { @@ -55,13 +54,8 @@ }, "Microsoft.Extensions.DependencyInjection.Abstractions": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "/a1aJz4m7ylhEDf25ugQChLQoN5XwoGjWw/BoR/ZWWKsO1v4DdJElS1uyngahz4B/eOzjFk1KNTkarRLE5wsIg==" - }, - "System.Diagnostics.DiagnosticSource": { - "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "QoXcAdDhBudxorzYF1F5Uo7FY0CSWgmTjqVRJjcQaYAkbO3dCPXDJajJwyApTGHCJ+T5PfVyKZqXEKZ8PH+UWQ==" + "resolved": "8.0.0", + "contentHash": "cjWrLkJXK0rs4zofsK4bSdg+jhDLTaxrkXu4gS6Y7MAlCvRyNNgwY/lJi5RDlQOnSZweHqoyvgvbdvQsRIW+hg==" } }, "net8.0/linux-x64": {} diff --git a/tests/LibTmux.AotSmoke/packages.lock.json b/tests/LibTmux.AotSmoke/packages.lock.json index 4a3b9b7..f49a8cc 100644 --- a/tests/LibTmux.AotSmoke/packages.lock.json +++ b/tests/LibTmux.AotSmoke/packages.lock.json @@ -66,28 +66,22 @@ }, "Microsoft.Extensions.DependencyInjection.Abstractions": { "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "/a1aJz4m7ylhEDf25ugQChLQoN5XwoGjWw/BoR/ZWWKsO1v4DdJElS1uyngahz4B/eOzjFk1KNTkarRLE5wsIg==" - }, - "System.Diagnostics.DiagnosticSource": { - "type": "Transitive", - "resolved": "10.0.11", - "contentHash": "QoXcAdDhBudxorzYF1F5Uo7FY0CSWgmTjqVRJjcQaYAkbO3dCPXDJajJwyApTGHCJ+T5PfVyKZqXEKZ8PH+UWQ==" + "resolved": "8.0.0", + "contentHash": "cjWrLkJXK0rs4zofsK4bSdg+jhDLTaxrkXu4gS6Y7MAlCvRyNNgwY/lJi5RDlQOnSZweHqoyvgvbdvQsRIW+hg==" }, "libtmux": { "type": "Project", "dependencies": { - "Microsoft.Extensions.Logging.Abstractions": "[10.0.11, )" + "Microsoft.Extensions.Logging.Abstractions": "[8.0.0, )" } }, "Microsoft.Extensions.Logging.Abstractions": { "type": "CentralTransitive", - "requested": "[10.0.11, )", - "resolved": "10.0.11", - "contentHash": "Ljd0Uxoq5XpScD2Bg0nM/r3mwx7Ao5Uq24eo2ARxbGvqJ7Zht6rt2cJtwVRH4Cv+1ZVMdXz6TB43KbpmsxRrvQ==", + "requested": "[8.0.0, )", + "resolved": "8.0.0", + "contentHash": "arDBqTgFCyS0EvRV7O3MZturChstm50OJ0y9bDJvAcmEPJm0FFpFyjU/JLYyStNGGey081DvnQYlncNX5SJJGA==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.11", - "System.Diagnostics.DiagnosticSource": "10.0.11" + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0" } } }, diff --git a/tests/LibTmux.ExampleTests/ExampleSuite.cs b/tests/LibTmux.ExampleTests/ExampleSuite.cs index 44db7d6..635054f 100644 --- a/tests/LibTmux.ExampleTests/ExampleSuite.cs +++ b/tests/LibTmux.ExampleTests/ExampleSuite.cs @@ -7,7 +7,7 @@ namespace LibTmux.ExampleTests; [CollectionDefinition("Examples", DisableParallelization = true)] public sealed class OneExampleAtATime; -/// Runs every documented example against live tmux, one test each. +/// Runs every ordinary tmux example against live tmux, one test each. [Collection("Examples")] [UnsupportedOSPlatform("windows")] public sealed class ExampleSuite diff --git a/tests/LibTmux.ExampleTests/SnippetContractTests.cs b/tests/LibTmux.ExampleTests/SnippetContractTests.cs index b4aea9b..77cb61c 100644 --- a/tests/LibTmux.ExampleTests/SnippetContractTests.cs +++ b/tests/LibTmux.ExampleTests/SnippetContractTests.cs @@ -1,3 +1,4 @@ +using System.Reflection; using System.Runtime.Versioning; using System.Text.RegularExpressions; using LibTmux.Examples; @@ -13,11 +14,15 @@ public sealed class SnippetContractTests RegexOptions.Multiline | RegexOptions.Compiled); [Fact] - public void Every_published_region_is_an_example_that_runs() + public void Every_published_region_is_an_explicit_example() { HashSet examples = [ - .. ExampleCase.Discover().Select(example => example.Id), + .. typeof(ExampleCase).Assembly + .GetTypes() + .SelectMany(type => type.GetMethods(BindingFlags.Public | BindingFlags.Static)) + .Where(method => method.GetCustomAttribute() is not null) + .Select(method => method.Name), ]; List orphans = []; @@ -35,7 +40,7 @@ .. ExampleCase.Discover().Select(example => example.Id), Assert.True( orphans.Count == 0, - "These regions are published but no [Example] method runs them:\n " + "These regions are published but have no [Example] method:\n " + string.Join("\n ", orphans)); } diff --git a/tests/LibTmux.ExampleTests/packages.lock.json b/tests/LibTmux.ExampleTests/packages.lock.json index 5e21cf8..f1cb9f1 100644 --- a/tests/LibTmux.ExampleTests/packages.lock.json +++ b/tests/LibTmux.ExampleTests/packages.lock.json @@ -433,14 +433,14 @@ "libtmux.examples": { "type": "Project", "dependencies": { - "LibTmux": "[0.0.0-alpha.6, )", - "LibTmux.Mcp": "[0.0.0-alpha.6, )" + "LibTmux": "[0.0.0-alpha.8, )", + "LibTmux.Mcp": "[0.0.0-alpha.8, )" } }, "libtmux.mcp": { "type": "Project", "dependencies": { - "LibTmux": "[0.0.0-alpha.6, )", + "LibTmux": "[0.0.0-alpha.8, )", "Microsoft.Extensions.Hosting": "[10.0.11, )", "ModelContextProtocol": "[2.2.0, )", "ModelContextProtocol.Extensions.Tasks": "[2.2.0, )" @@ -507,4 +507,4 @@ } } } -} \ No newline at end of file +} diff --git a/tests/LibTmux.IntegrationTests/Chaining/ChainGenerationTests.cs b/tests/LibTmux.IntegrationTests/Chaining/ChainGenerationTests.cs index 953be64..b0085de 100644 --- a/tests/LibTmux.IntegrationTests/Chaining/ChainGenerationTests.cs +++ b/tests/LibTmux.IntegrationTests/Chaining/ChainGenerationTests.cs @@ -26,9 +26,17 @@ public async Task A_chained_entity_command_is_refused_after_the_server_restarts( Pane pane = (await window.GetPanesAsync(token))[0]; // The server this pane was read from goes away, and a new one takes the - // same socket and hands out the same IDs. + // same socket and hands out the same IDs. The old server has to be gone + // first: it unlinks the socket as it exits, which would take the + // replacement's socket with it. await first.KillAsync(token); - await raw.ExecuteAsync(["new-session", "-d", "-s", "replacement"], token); + await raw.WaitForServerExitAsync(token); + RawTmuxResult replacement = await raw.ExecuteAsync( + ["new-session", "-d", "-s", "replacement"], + token); + Assert.True( + replacement.ExitCode == 0, + $"the replacement server did not start: {replacement.StandardErrorText}"); // Starting a server and being able to talk to it are not the same // instant, and how far apart they are depends on the machine. diff --git a/tests/LibTmux.IntegrationTests/ControlMode/ControlModeSessionTests.cs b/tests/LibTmux.IntegrationTests/ControlMode/ControlModeSessionTests.cs index 66834ca..11c6893 100644 --- a/tests/LibTmux.IntegrationTests/ControlMode/ControlModeSessionTests.cs +++ b/tests/LibTmux.IntegrationTests/ControlMode/ControlModeSessionTests.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using System.Runtime.Versioning; using LibTmux.IntegrationTests.Infrastructure; using LibTmux.IntegrationTests.Transport; @@ -140,6 +141,75 @@ public async Task The_event_stream_ends_with_an_exit() await control.DisposeAsync(); } + [UnixFact] + public async Task A_canceled_attach_is_disposed_before_the_call_returns() + { + await using RawTmuxTestContext raw = await RawTmuxTestContext.StartAsync( + TestContext.Current.CancellationToken); + string directory = Path.Combine( + Path.GetTempPath(), + $"libtmux-control-start-{Guid.NewGuid():N}"); + string wrapper = Path.Combine(directory, "tmux-wrapper"); + string pidFile = Path.Combine(directory, "client.pid"); + Directory.CreateDirectory(directory); + int clientPid = 0; + + try + { + string script = $""" + #!/bin/sh + for argument in "$@"; do + if [ "$argument" = "-C" ]; then + echo "$$" > {ShellQuote(pidFile)} + IFS= read -r ignored + 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); + + Server server = await Server.ConnectAsync( + new ServerConnectionOptions( + tmuxBinaryPath: wrapper, + socketPath: raw.SocketPath, + configurationFile: "/dev/null"), + TestContext.Current.CancellationToken); + using var startupCancellation = CancellationTokenSource.CreateLinkedTokenSource( + TestContext.Current.CancellationToken); + Task startup = server.EnterControlModeAsync( + cancellationToken: startupCancellation.Token); + + await WaitUntilAsync( + () => TryReadProcessId(pidFile, out clientPid), + TestContext.Current.CancellationToken); + startupCancellation.Cancel(); + + await Assert.ThrowsAnyAsync(async () => await startup); + await WaitUntilAsync( + () => !IsProcessAlive(clientPid), + TestContext.Current.CancellationToken); + Assert.False(IsProcessAlive(clientPid)); + } + finally + { + if (IsProcessAlive(clientPid)) + { + using Process process = Process.GetProcessById(clientPid); + process.Kill(entireProcessTree: false); + await process.WaitForExitAsync(TestContext.Current.CancellationToken); + } + + Directory.Delete(directory, recursive: true); + } + } + private static Task ConnectAsync( RawTmuxTestContext raw, CancellationToken token) => @@ -149,4 +219,55 @@ private static Task ConnectAsync( socketPath: raw.SocketPath, configurationFile: "/dev/null"), token); + + private static bool IsProcessAlive(int processId) + { + if (processId <= 0) + { + return false; + } + + try + { + using Process process = Process.GetProcessById(processId); + return !process.HasExited; + } + catch (ArgumentException) + { + return false; + } + } + + private static bool TryReadProcessId(string path, out int processId) + { + processId = 0; + try + { + return int.TryParse( + File.ReadAllText(path), + System.Globalization.NumberStyles.Integer, + System.Globalization.CultureInfo.InvariantCulture, + out processId); + } + catch (IOException) + { + return false; + } + } + + private static string ShellQuote(string value) => + $"'{value.Replace("'", "'\"'\"'", StringComparison.Ordinal)}'"; + + private static async Task WaitUntilAsync( + Func condition, + CancellationToken cancellationToken) + { + using CancellationTokenSource timeout = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken); + timeout.CancelAfter(TimeSpan.FromSeconds(10)); + while (!condition()) + { + await Task.Delay(TimeSpan.FromMilliseconds(20), timeout.Token); + } + } } diff --git a/tests/LibTmux.IntegrationTests/Documentation/ReadmeExampleTests.cs b/tests/LibTmux.IntegrationTests/Documentation/ReadmeExampleTests.cs index 17478c6..24aa427 100644 --- a/tests/LibTmux.IntegrationTests/Documentation/ReadmeExampleTests.cs +++ b/tests/LibTmux.IntegrationTests/Documentation/ReadmeExampleTests.cs @@ -35,6 +35,7 @@ public sealed class ReadmeExampleTests "src/LibTmux.Query.Json/README.md", "src/LibTmux.Workspace/README.md", "src/LibTmux.Mcp/README.md", + "docs/mcp/README.md", "docs/modes/one-shot.md", "docs/modes/control-mode.md", "docs/modes/chaining.md", diff --git a/tests/LibTmux.IntegrationTests/Infrastructure/RawTmuxTestContext.cs b/tests/LibTmux.IntegrationTests/Infrastructure/RawTmuxTestContext.cs index 22c88a5..66545d6 100644 --- a/tests/LibTmux.IntegrationTests/Infrastructure/RawTmuxTestContext.cs +++ b/tests/LibTmux.IntegrationTests/Infrastructure/RawTmuxTestContext.cs @@ -6,6 +6,8 @@ namespace LibTmux.IntegrationTests.Infrastructure; internal sealed class RawTmuxTestContext : IAsyncDisposable { + private const int ExitPollAttempts = 400; + private static readonly TimeSpan ExitPollInterval = TimeSpan.FromMilliseconds(25); private static readonly TimeSpan CleanupTimeout = TimeSpan.FromSeconds(5); private int disposed; private int serverProcessId; @@ -185,6 +187,31 @@ internal static void ConfigureEnvironment(ProcessStartInfo startInfo) startInfo.Environment["TERM"] = "xterm-256color"; } + /// Waits until the server this context started has really exited. + /// + /// tmux answers kill-server when the command lands rather than when + /// the server goes, so a session created in that window is created on the + /// dying server and dies with it, leaving the socket with no server at all. + /// The socket file is no signal here: it outlives the server that made it. + /// + internal async Task WaitForServerExitAsync(CancellationToken cancellationToken) + { + for (int attempt = 0; attempt < ExitPollAttempts; attempt++) + { + RawTmuxResult probe = await ExecuteAsync(["list-sessions"], cancellationToken); + if (probe.ExitCode != 0 + && (serverProcessId <= 0 || !IsProcessAlive(serverProcessId))) + { + return; + } + + await Task.Delay(ExitPollInterval, cancellationToken); + } + + throw new InvalidOperationException( + "The tmux test server was still running after kill-server."); + } + public async ValueTask DisposeAsync() { if (Interlocked.Exchange(ref disposed, 1) != 0) diff --git a/tests/LibTmux.IntegrationTests/Mcp/HierarchyWatcherTests.cs b/tests/LibTmux.IntegrationTests/Mcp/HierarchyWatcherTests.cs index 2a65472..f7dcc1a 100644 --- a/tests/LibTmux.IntegrationTests/Mcp/HierarchyWatcherTests.cs +++ b/tests/LibTmux.IntegrationTests/Mcp/HierarchyWatcherTests.cs @@ -105,6 +105,86 @@ await watcher.SubscribeAsync( Assert.Empty(clients); } + [UnixFact] + public async Task Overlapping_subscribers_are_distinct_and_duplicates_are_idempotent() + { + 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 using HierarchyWatcher watcher = new(); + object firstKey = new(); + object secondKey = new(); + TaskCompletionSource firstTold = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource secondTold = new(TaskCreationOptions.RunContinuationsAsynchronously); + + await watcher.SubscribeAsync( + "tmux://hierarchy", + firstKey, + changed => + { + if (changed.Contains("tmux://hierarchy")) + { + firstTold.TrySetResult(); + } + + return Task.CompletedTask; + }, + scope.Session.Server, + token); + await watcher.SubscribeAsync( + "tmux://hierarchy", + secondKey, + changed => + { + if (changed.Contains("tmux://hierarchy")) + { + secondTold.TrySetResult(); + } + + return Task.CompletedTask; + }, + scope.Session.Server, + token); + await watcher.SubscribeAsync( + "tmux://hierarchy", + secondKey, + _ => Task.CompletedTask, + scope.Session.Server, + token); + + await scope.Session.CreateWindowAsync(new NewWindowRequest(name: "appeared"), token); + + Task bothTold = Task.WhenAll(firstTold.Task, secondTold.Task); + Assert.True( + await Task.WhenAny(bothTold, Task.Delay(TimeSpan.FromSeconds(20), token)) == bothTold, + "one overlapping subscriber did not receive the hierarchy change"); + + await watcher.UnsubscribeAsync("tmux://hierarchy", firstKey); + IReadOnlyList oneReference = await TmuxWait.UntilAsync( + cancellation => scope.Session.Server.GetClientsAsync(cancellation), + current => current.Count == 1, + TimeSpan.FromSeconds(10), + TimeSpan.FromMilliseconds(250), + token); + Assert.Single(oneReference); + + await watcher.UnsubscribeAsync("tmux://hierarchy", secondKey); + IReadOnlyList noReferences = await TmuxWait.UntilAsync( + cancellation => scope.Session.Server.GetClientsAsync(cancellation), + current => current.Count == 0, + TimeSpan.FromSeconds(10), + TimeSpan.FromMilliseconds(250), + token); + Assert.Empty(noReferences); + } + [Theory] [InlineData("window-add", true)] [InlineData("layout-change", true)] @@ -116,4 +196,8 @@ await watcher.SubscribeAsync( // subscription replaces. public void Only_a_change_to_what_exists_wakes_a_subscriber(string name, bool expected) => Assert.Equal(expected, HierarchyWatcher.IsStructural(name)); + + [Fact] + public void Lost_control_events_invalidate_the_hierarchy() => + Assert.True(HierarchyWatcher.InvalidatesHierarchy(new TmuxEventsDroppedEvent(1, 1))); } diff --git a/tests/LibTmux.IntegrationTests/Mcp/McpProtocolTests.cs b/tests/LibTmux.IntegrationTests/Mcp/McpProtocolTests.cs index 822050b..aa112e6 100644 --- a/tests/LibTmux.IntegrationTests/Mcp/McpProtocolTests.cs +++ b/tests/LibTmux.IntegrationTests/Mcp/McpProtocolTests.cs @@ -38,11 +38,21 @@ public async Task Reading_tools_are_annotated_so_a_client_does_not_prompt_for_a_ Assert.True(listing.ProtocolTool.Annotations?.ReadOnlyHint); - // A mutating tool has to say it is not destructive, because the spec - // default for destructiveHint is true and a client that gates on it - // would otherwise prompt before a split. + // The MCP spec defines destructive=false as additive-only. Every + // mutating tmux tool can replace state or run caller-supplied input. + Assert.All( + tools.Where(tool => tool.ProtocolTool.Annotations?.ReadOnlyHint != true), + tool => Assert.True( + tool.ProtocolTool.Annotations?.DestructiveHint, + $"{tool.Name} can change non-additive state but is not marked destructive")); + McpClientTool split = tools.Single(tool => tool.Name == "tmux_split_pane"); - Assert.False(split.ProtocolTool.Annotations?.DestructiveHint ?? true); + Assert.True(split.ProtocolTool.Annotations?.DestructiveHint); + Assert.True(split.ProtocolTool.Annotations?.OpenWorldHint); + Assert.True(tools.Single(tool => tool.Name == "tmux_create_session") + .ProtocolTool.Annotations?.OpenWorldHint); + Assert.True(tools.Single(tool => tool.Name == "tmux_create_window") + .ProtocolTool.Annotations?.OpenWorldHint); } [UnixFact] @@ -89,6 +99,36 @@ public async Task A_result_arrives_as_structured_content() Assert.NotNull(listed.StructuredContent); } + [UnixFact] + public async Task Nullable_session_fields_still_satisfy_the_advertised_output_schema() + { + CancellationToken token = TestContext.Current.CancellationToken; + await using ProtocolHarness harness = await ProtocolHarness.StartAsync(token); + + await harness.Client.CallToolAsync( + "tmux_create_session", + new Dictionary { ["name"] = "schema" }, + cancellationToken: token); + + CallToolResult listed = await harness.Client.CallToolAsync( + "tmux_list_sessions", + cancellationToken: token); + CallToolResult hierarchy = await harness.Client.CallToolAsync( + "tmux_hierarchy", + cancellationToken: token); + + Assert.NotEqual(true, listed.IsError); + Assert.NotEqual(true, hierarchy.IsError); + + JsonElement listedSession = listed.StructuredContent!.Value[0]; + JsonElement hierarchySession = hierarchy.StructuredContent!.Value + .GetProperty("sessions")[0]; + Assert.True(listedSession.TryGetProperty("width", out _)); + Assert.True(listedSession.TryGetProperty("height", out _)); + Assert.True(hierarchySession.TryGetProperty("width", out _)); + Assert.True(hierarchySession.TryGetProperty("height", out _)); + } + [UnixFact] public async Task The_destructive_tier_is_absent_unless_the_operator_asks_for_it() { @@ -222,26 +262,32 @@ await harness.Client.CallToolAsync( // awaited, and cancelled to end the subscription. using CancellationTokenSource listening = CancellationTokenSource .CreateLinkedTokenSource(token); - Task stream = harness.Client.SendRequestAsync( - new JsonRpcRequest - { - Method = RequestMethods.SubscriptionsListen, - Params = JsonSerializer.SerializeToNode( - new SubscriptionsListenRequestParams + JsonRpcRequest listenRequest = new() + { + Method = RequestMethods.SubscriptionsListen, + Params = JsonSerializer.SerializeToNode( + new SubscriptionsListenRequestParams + { + Notifications = new SubscriptionsListenNotifications { - Notifications = new SubscriptionsListenNotifications - { - ResourceSubscriptions = ["tmux://hierarchy"], - }, + ResourceSubscriptions = ["tmux://hierarchy"], }, - McpJsonUtilities.DefaultOptions), - }, - listening.Token); + }, + McpJsonUtilities.DefaultOptions), + }; + Task stream = harness.Client.SendRequestAsync(listenRequest, listening.Token); + + Task acknowledgementDeadline = Task.Delay(TimeSpan.FromSeconds(15), token); + Task acknowledgementOutcome = await Task.WhenAny( + acknowledged.Task, + stream, + acknowledgementDeadline); + if (acknowledgementOutcome == stream) + { + await stream; + } - Assert.True( - await Task.WhenAny(acknowledged.Task, Task.Delay(TimeSpan.FromSeconds(15), token)) - == acknowledged.Task, - "the server never acknowledged the subscription"); + Assert.Same(acknowledged.Task, acknowledgementOutcome); // A window appearing is a structural change, which is what tmux // reports to a control client without being asked. @@ -262,9 +308,63 @@ await Task.WhenAny(updated.Task, Task.Delay(TimeSpan.FromSeconds(20), token)) // Tagged with the stream it belongs to, which is what lets a client // sharing one channel tell two subscriptions apart. - Assert.NotNull( - Assert.IsType(parameters)["_meta"]? - ["io.modelcontextprotocol/subscriptionId"]); + JsonNode subscriptionId = Assert.IsType(parameters)["_meta"]? + ["io.modelcontextprotocol/subscriptionId"] + ?? throw new Xunit.Sdk.XunitException("the event has no subscription id"); + _ = subscriptionId.GetValue(); + + await listening.CancelAsync(); + await Assert.ThrowsAnyAsync(() => stream); + } + + [UnixFact] + public async Task A_subscription_acknowledgement_preserves_a_string_request_id() + { + CancellationToken token = TestContext.Current.CancellationToken; + await using ProtocolHarness harness = await ProtocolHarness.StartAsync(token); + TaskCompletionSource acknowledged = new( + TaskCreationOptions.RunContinuationsAsynchronously); + + await using IAsyncDisposable ack = harness.Client.RegisterNotificationHandler( + NotificationMethods.SubscriptionsAcknowledgedNotification, + (notification, _) => + { + acknowledged.TrySetResult(notification.Params); + return default; + }); + using CancellationTokenSource listening = CancellationTokenSource + .CreateLinkedTokenSource(token); + const string expectedId = "listen-string-id"; + Task stream = harness.Client.SendRequestAsync( + new JsonRpcRequest + { + Id = new RequestId(expectedId), + Method = RequestMethods.SubscriptionsListen, + Params = JsonSerializer.SerializeToNode( + new SubscriptionsListenRequestParams + { + Notifications = new SubscriptionsListenNotifications(), + }, + McpJsonUtilities.DefaultOptions), + }, + listening.Token); + + Task acknowledgementDeadline = Task.Delay(TimeSpan.FromSeconds(15), token); + Task acknowledgementOutcome = await Task.WhenAny( + acknowledged.Task, + stream, + acknowledgementDeadline); + if (acknowledgementOutcome == stream) + { + await stream; + } + + Assert.Same(acknowledged.Task, acknowledgementOutcome); + JsonNode? parameters = await acknowledged.Task; + JsonNode subscriptionId = Assert.IsType(parameters)["_meta"]? + ["io.modelcontextprotocol/subscriptionId"] + ?? throw new Xunit.Sdk.XunitException("the acknowledgement has no subscription id"); + Assert.Equal(expectedId, subscriptionId.GetValue()); await listening.CancelAsync(); await Assert.ThrowsAnyAsync(() => stream); @@ -282,23 +382,64 @@ public async Task A_waiting_tool_can_be_started_as_a_task_and_collected_later() cancellationToken: token); string pane = made.StructuredContent!.Value.GetProperty("paneId").GetString()!; - // Started, not awaited: the point of a task is that the caller gets a - // handle back before the work is done. + CallToolResult started = await harness.Client.CallToolAsync( + "tmux_start_job", + new Dictionary + { + ["command"] = "echo TASKED && exit 7", + ["paneId"] = pane, + }, + cancellationToken: token); + string jobId = started.StructuredContent!.Value + .GetProperty("jobId") + .GetString()!; + CallToolResult finished = await harness.Client.CallToolWithPollingAsync( + new CallToolRequestParams + { + Name = "tmux_job", + Arguments = new Dictionary + { + ["jobId"] = JsonSerializer.SerializeToElement(jobId), + ["waitSeconds"] = JsonSerializer.SerializeToElement(20), + }, + }, + cancellationToken: token); + + Assert.NotEqual(true, finished.IsError); + Assert.Equal( + 7, + finished.StructuredContent!.Value + .GetProperty("job") + .GetProperty("exitStatus") + .GetInt32()); + } + + [UnixFact] + public async Task Run_stays_synchronous_when_a_client_requests_a_task() + { + CancellationToken token = TestContext.Current.CancellationToken; + await using ProtocolHarness harness = await ProtocolHarness.StartAsync(token); + CallToolResult made = await harness.Client.CallToolAsync( + "tmux_create_session", + new Dictionary { ["name"] = "run-sync" }, + cancellationToken: token); + string pane = made.StructuredContent!.Value.GetProperty("paneId").GetString()!; + + ResultOrCreatedTask answered = await harness.Client.CallToolAsTaskAsync( new CallToolRequestParams { Name = "tmux_run", Arguments = new Dictionary { - ["command"] = JsonSerializer.SerializeToElement("echo TASKED && exit 7"), + ["command"] = JsonSerializer.SerializeToElement("exit 0"), ["paneId"] = JsonSerializer.SerializeToElement(pane), - ["timeoutSeconds"] = JsonSerializer.SerializeToElement(20), }, }, cancellationToken: token); - Assert.NotEqual(true, finished.IsError); - Assert.Equal(7, finished.StructuredContent!.Value.GetProperty("exitStatus").GetInt32()); + Assert.False(answered.IsTask); + Assert.NotNull(answered.Result); } [UnixFact] diff --git a/tests/LibTmux.IntegrationTests/Testing/TestingHelpersTests.cs b/tests/LibTmux.IntegrationTests/Testing/TestingHelpersTests.cs index cc0d122..3c95ce2 100644 --- a/tests/LibTmux.IntegrationTests/Testing/TestingHelpersTests.cs +++ b/tests/LibTmux.IntegrationTests/Testing/TestingHelpersTests.cs @@ -89,6 +89,46 @@ await scope.Pane.CaptureAsync(cancellationToken: cancellation)), == true); } + [UnixFact] + public async Task Self_contained_session_scope_stops_its_private_server() + { + CancellationToken token = TestContext.Current.CancellationToken; + TmuxTestFactory factory = new(); + TemporarySessionScope scope = await factory.CreateSessionAsync(HarnessOptions(), token); + ServerConnectionOptions endpoint = scope.Session.Server.ConnectionOptions; + try + { + Assert.True(await scope.Session.Server.IsAliveAsync(token)); + } + finally + { + await scope.DisposeAsync(); + } + + await Assert.ThrowsAnyAsync( + () => Server.ConnectAsync(endpoint, token)); + } + + [UnixFact] + public async Task Self_contained_window_scope_stops_its_private_server() + { + CancellationToken token = TestContext.Current.CancellationToken; + TmuxTestFactory factory = new(); + TemporaryWindowScope scope = await factory.CreateWindowAsync(HarnessOptions(), token); + ServerConnectionOptions endpoint = scope.Window.Server.ConnectionOptions; + try + { + Assert.True(await scope.Window.Server.IsAliveAsync(token)); + } + finally + { + await scope.DisposeAsync(); + } + + await Assert.ThrowsAnyAsync( + () => Server.ConnectAsync(endpoint, token)); + } + [UnixFact] public async Task Generated_names_do_not_collide() { diff --git a/tests/LibTmux.IntegrationTests/packages.lock.json b/tests/LibTmux.IntegrationTests/packages.lock.json index 36a67ef..f6902b5 100644 --- a/tests/LibTmux.IntegrationTests/packages.lock.json +++ b/tests/LibTmux.IntegrationTests/packages.lock.json @@ -465,7 +465,7 @@ "libtmux.mcp": { "type": "Project", "dependencies": { - "LibTmux": "[0.0.0-alpha.6, )", + "LibTmux": "[0.0.0-alpha.8, )", "Microsoft.Extensions.Hosting": "[10.0.11, )", "ModelContextProtocol": "[2.2.0, )", "ModelContextProtocol.Extensions.Tasks": "[2.2.0, )" @@ -474,13 +474,13 @@ "libtmux.query.json": { "type": "Project", "dependencies": { - "LibTmux": "[0.0.0-alpha.6, )" + "LibTmux": "[0.0.0-alpha.8, )" } }, "libtmux.workspace": { "type": "Project", "dependencies": { - "LibTmux": "[0.0.0-alpha.6, )", + "LibTmux": "[0.0.0-alpha.8, )", "YamlDotNet": "[18.1.0, )" } }, @@ -1054,13 +1054,13 @@ "libtmux": { "type": "Project", "dependencies": { - "Microsoft.Extensions.Logging.Abstractions": "[10.0.11, )" + "Microsoft.Extensions.Logging.Abstractions": "[8.0.0, )" } }, "libtmux.mcp": { "type": "Project", "dependencies": { - "LibTmux": "[0.0.0-alpha.6, )", + "LibTmux": "[0.0.0-alpha.8, )", "Microsoft.Extensions.Hosting": "[10.0.11, )", "ModelContextProtocol": "[2.2.0, )", "ModelContextProtocol.Extensions.Tasks": "[2.2.0, )" @@ -1069,13 +1069,13 @@ "libtmux.query.json": { "type": "Project", "dependencies": { - "LibTmux": "[0.0.0-alpha.6, )" + "LibTmux": "[0.0.0-alpha.8, )" } }, "libtmux.workspace": { "type": "Project", "dependencies": { - "LibTmux": "[0.0.0-alpha.6, )", + "LibTmux": "[0.0.0-alpha.8, )", "YamlDotNet": "[18.1.0, )" } }, @@ -1111,7 +1111,7 @@ }, "Microsoft.Extensions.Logging.Abstractions": { "type": "CentralTransitive", - "requested": "[10.0.11, )", + "requested": "[8.0.0, )", "resolved": "10.0.11", "contentHash": "Ljd0Uxoq5XpScD2Bg0nM/r3mwx7Ao5Uq24eo2ARxbGvqJ7Zht6rt2cJtwVRH4Cv+1ZVMdXz6TB43KbpmsxRrvQ==", "dependencies": { @@ -1138,4 +1138,4 @@ } } } -} \ No newline at end of file +} diff --git a/tests/LibTmux.PackageConsumer/Program.cs b/tests/LibTmux.PackageConsumer/Program.cs index c9b4bfd..07fdeee 100644 --- a/tests/LibTmux.PackageConsumer/Program.cs +++ b/tests/LibTmux.PackageConsumer/Program.cs @@ -1,25 +1,77 @@ using System.Runtime.Versioning; +using System.Text; using LibTmux.Testing; namespace LibTmux.PackageConsumer; -/// Uses the library the way a downstream project would. +/// Uses the packed library the way a downstream project would. /// /// Reaches the library through the built package, not a project reference, to /// catch a missing assembly, wrong target framework, or gap invisible from /// inside the repository. /// -[UnsupportedOSPlatform("windows")] internal static class Program { - private static async Task Main() + private static async Task Main(string[] args) { + if (args is ["--psmux"]) + { + Console.OutputEncoding = new UTF8Encoding(false, true); + return await RunPsmuxAsync(); + } + + if (args.Length != 0) + { + Console.Error.WriteLine("usage: LibTmux.PackageConsumer [--psmux]"); + return 2; + } + if (OperatingSystem.IsWindows()) { Console.Error.WriteLine("tmux does not run on Windows."); return 1; } + return await RunTmuxAsync(); + } + + private static async Task RunPsmuxAsync() + { + string executable = Environment.GetEnvironmentVariable("LIBTMUX_PSMUX_BINARY") + ?? throw new InvalidOperationException("LIBTMUX_PSMUX_BINARY is required."); + string dataDirectory = Environment.GetEnvironmentVariable("PSMUX_DATA_DIR") + ?? throw new InvalidOperationException("PSMUX_DATA_DIR is required."); + string namespaceName = Environment.GetEnvironmentVariable("LIBTMUX_PSMUX_NAMESPACE") + ?? throw new InvalidOperationException("LIBTMUX_PSMUX_NAMESPACE is required."); + string expectedText = Environment.GetEnvironmentVariable("LIBTMUX_PSMUX_EXPECTED_TEXT") + ?? throw new InvalidOperationException("LIBTMUX_PSMUX_EXPECTED_TEXT is required."); + + using var budget = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + PsmuxServer server = await PsmuxServer.ConnectAsync( + new PsmuxConnectionOptions( + executable, + PsmuxServer.SupportedBinarySha256, + dataDirectory, + namespaceName), + budget.Token); + PsmuxSession session = await server.GetSessionAsync(budget.Token); + PsmuxWindow window = AssertSingle(await session.GetWindowsAsync(budget.Token), "window"); + PsmuxPane pane = AssertSingle(await window.GetPanesAsync(budget.Token), "pane"); + IReadOnlyList lines = await pane.CaptureAsync( + new PsmuxCaptureOptions(joinWrappedLines: true), + budget.Token); + if (!lines.Any(line => line.Contains(expectedText, StringComparison.Ordinal))) + { + throw new InvalidOperationException("The packed psmux query did not capture the fixture text."); + } + + Console.WriteLine($"package psmux {session.Id} {window.Id} {pane.Id} {expectedText}"); + return 0; + } + + [UnsupportedOSPlatform("windows")] + private static async Task RunTmuxAsync() + { TmuxTestFactory factory = new(); TmuxTestOptions options = new(new ServerConnectionOptions( tmuxBinaryPath: Environment.GetEnvironmentVariable("LIBTMUX_TMUX") ?? "tmux", @@ -42,4 +94,10 @@ await scope.Pane.CaptureAsync(cancellationToken: token)), Console.WriteLine($"captured {text.Contains("consumed-from-the-package", StringComparison.Ordinal)}"); return 0; } + + private static T AssertSingle(IReadOnlyList values, string kind) => + values.Count == 1 + ? values[0] + : throw new InvalidOperationException( + $"The psmux package smoke expected one {kind}, but found {values.Count}."); } diff --git a/tests/LibTmux.UnitTests/Connection/PsmuxConnectionTests.cs b/tests/LibTmux.UnitTests/Connection/PsmuxConnectionTests.cs new file mode 100644 index 0000000..851dd02 --- /dev/null +++ b/tests/LibTmux.UnitTests/Connection/PsmuxConnectionTests.cs @@ -0,0 +1,971 @@ +using System.Runtime.Versioning; +using System.Text; +using LibTmux.Internal; + +namespace LibTmux.UnitTests.Connection; + +[UnsupportedOSPlatform("windows")] +public sealed class PsmuxConnectionTests +{ + private const string AuditedBanner = + "tmux 3.3.7\npsmux 3.3.7 (aa26cd3 2026-08-17)\n"; + private const string TestBinarySha256 = + "1abd0eaa3de1ed5491a4f744c8b3db492ae9ac94e9e9a8fea9da217c744ba94e"; + private static readonly string TestBinaryPath = Path.Combine( + Path.GetTempPath(), + "libtmux-audited-psmux.exe"); + private const string TestDataDirectory = "C:\\libtmux-psmux-unit-data"; + private const string TestNamespace = "libtmux_unit_0001"; + + [Fact] + public void Public_options_require_a_pinned_client_and_isolated_endpoint() + { + Assert.Throws( + () => new PsmuxConnectionOptions( + "\\\\server\\share\\psmux.exe", + TestBinarySha256, + TestDataDirectory, + TestNamespace)); + Assert.Throws( + () => new PsmuxConnectionOptions( + TestBinaryPath, + "00", + TestDataDirectory, + TestNamespace)); + Assert.Throws( + () => new PsmuxConnectionOptions( + TestBinaryPath, + new string('a', 64), + TestDataDirectory, + TestNamespace)); + Assert.Throws( + () => new PsmuxConnectionOptions( + TestBinaryPath, + TestBinarySha256, + "relative\\data", + TestNamespace)); + Assert.Throws( + () => new PsmuxConnectionOptions( + TestBinaryPath, + TestBinarySha256, + "/tmp/data", + TestNamespace)); + Assert.Throws( + () => new PsmuxConnectionOptions( + TestBinaryPath, + TestBinarySha256, + "C:\\", + TestNamespace)); + Assert.Throws( + () => new PsmuxConnectionOptions( + TestBinaryPath, + TestBinarySha256, + "\\\\server\\share", + TestNamespace)); + Assert.Throws( + () => new PsmuxConnectionOptions( + TestBinaryPath, + TestBinarySha256, + "\\\\server\\share\\isolated", + TestNamespace)); + Assert.Throws( + () => new PsmuxConnectionOptions( + TestBinaryPath, + TestBinarySha256, + "C:\\temp\\CON", + TestNamespace)); + Assert.Throws( + () => new PsmuxConnectionOptions( + TestBinaryPath, + TestBinarySha256, + TestDataDirectory, + "default")); + Assert.Throws( + () => new PsmuxConnectionOptions( + TestBinaryPath, + TestBinarySha256, + TestDataDirectory, + "too-short")); + Assert.Throws( + () => new PsmuxConnectionOptions( + TestBinaryPath, + TestBinarySha256, + TestDataDirectory, + "libtmux_Unit_0001")); + + var options = new PsmuxConnectionOptions( + TestBinaryPath, + TestBinarySha256.ToUpperInvariant(), + "c:/libtmux-psmux-unit-data/", + TestNamespace); + Assert.Equal(TestBinarySha256, options.ExpectedBinarySha256); + Assert.Equal(TestBinarySha256, PsmuxServer.SupportedBinarySha256); + Assert.Equal(TestDataDirectory, options.DataDirectory); + Assert.Equal(TestNamespace, options.NamespaceName); + } + + [Fact] + public void Psmux_endpoint_identity_includes_the_frozen_data_directory() + { + Assert.Equal(PsmuxOptions(), PsmuxOptions()); + Server first = Server.Open(PsmuxOptions()); + Server same = Server.Open(PsmuxOptions()); + Server sameCaseVariant = Server.Open(PsmuxOptions( + dataDirectory: "c:\\LIBTMUX-PSMUX-UNIT-DATA\\")); + Server other = Server.Open(PsmuxOptions( + dataDirectory: "C:\\libtmux-psmux-other-data")); + + Assert.Equal(first, same); + Assert.Equal(first, sameCaseVariant); + Assert.NotEqual(first, other); + } + + [Fact] + public async Task Binary_trust_rejects_missing_build_markers() + { + string binary = Path.Combine( + Path.GetTempPath(), + $"libtmux-old-psmux-{Guid.NewGuid():N}.exe"); + byte[] contents = Encoding.UTF8.GetBytes("psmux 3.3.7 05cc5d4 2026-07-20"); + await File.WriteAllBytesAsync(binary, contents, TestContext.Current.CancellationToken); + string hash = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(contents)); + try + { + NotSupportedException error = await Assert.ThrowsAsync( + () => PsmuxBinaryTrust.VerifyAsync( + binary, + hash, + TestContext.Current.CancellationToken)); + + Assert.Contains("audited build markers", error.Message, StringComparison.Ordinal); + } + finally + { + File.Delete(binary); + } + } + + [Fact] + public async Task Binary_trust_streams_hash_and_markers_across_buffer_boundaries() + { + string binary = Path.Combine( + Path.GetTempPath(), + $"libtmux-streamed-psmux-{Guid.NewGuid():N}.exe"); + byte[] contents = new byte[82032]; + Array.Fill(contents, (byte)'x'); + "aa26cd3"u8.CopyTo(contents.AsSpan(81917)); + "2026-08-17"u8.CopyTo(contents.AsSpan(82000)); + await File.WriteAllBytesAsync(binary, contents, TestContext.Current.CancellationToken); + string hash = Convert.ToHexString( + System.Security.Cryptography.SHA256.HashData(contents)); + try + { + await PsmuxBinaryTrust.VerifyAsync( + binary, + hash, + TestContext.Current.CancellationToken); + } + finally + { + File.Delete(binary); + } + } + + [Fact] + public async Task Binary_trust_does_not_capture_the_callers_synchronization_context() + { + string binary = Path.Combine( + Path.GetTempPath(), + $"libtmux-context-psmux-{Guid.NewGuid():N}.exe"); + byte[] contents = "aa26cd3 2026-08-17"u8.ToArray(); + await File.WriteAllBytesAsync(binary, contents, TestContext.Current.CancellationToken); + string hash = Convert.ToHexString( + System.Security.Cryptography.SHA256.HashData(contents)); + var context = new RecordingSynchronizationContext(); + SynchronizationContext? previous = SynchronizationContext.Current; + Task verification; + try + { + SynchronizationContext.SetSynchronizationContext(context); + verification = PsmuxBinaryTrust.VerifyAsync( + binary, + hash, + TestContext.Current.CancellationToken); + } + finally + { + SynchronizationContext.SetSynchronizationContext(previous); + } + + try + { + await verification; + Assert.Equal(0, context.PostCalls); + } + finally + { + File.Delete(binary); + } + } + + [Fact] + public async Task Unknown_backend_detects_once_before_rejecting_an_unsafe_argument() + { + int calls = 0; + var connection = new TmuxConnection( + PsmuxOptions(), + (request, _) => + { + calls++; + Assert.Equal(["-V"], request.LogicalArguments); + return Task.FromResult(Result(request.LogicalArguments, AuditedBanner)); + }, + implementation: TmuxImplementation.Unknown); + + await Assert.ThrowsAsync( + () => connection.ServerDispatcher.ExecuteAsync( + ["display-message", "-p", "literal;"], + TestContext.Current.CancellationToken)); + + Assert.Equal(1, calls); + } + + [Fact] + public async Task Two_line_banner_selects_psmux_and_uses_the_sole_session_generation() + { + var calls = new List(); + var connection = new TmuxConnection( + PsmuxOptions(), + (request, _) => + { + string[] arguments = [.. request.LogicalArguments]; + calls.Add(arguments); + return Task.FromResult(arguments[0] switch + { + "-V" => Result(arguments, AuditedBanner), + "list-sessions" => Result(arguments, "41:100\t$7\talpha\n"), + "display-message" when IsSelectedGenerationProbe(request) => + Result(arguments, "41:100\t$7\talpha\n"), + "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); + + Assert.True(connection.IsPsmux); + Assert.Equal(new ServerGeneration(41, 100), generation); + Assert.Equal("tmux 3.3.7", rawVersion); + Assert.Equal(["-V"], calls[0]); + Assert.Equal("list-sessions", calls[1][0]); + Assert.Equal( + ["display-message", "-p", "-t", "alpha", "#{pid}:#{start_time}\t#{session_id}\t#{session_name}"], + calls[2]); + Assert.Equal( + ["display-message", "-p", "#{pid}:#{start_time}\t#{session_id}\t#{session_name}"], + calls[3]); + } + + [Fact] + public async Task Entity_dispatch_rewrites_namespaced_session_ids_and_preserves_arguments() + { + var requests = new List(); + var connection = PsmuxConnection((request, _) => + { + requests.Add(request); + return Task.FromResult(OneSessionResult(request, "literal-value\n")); + }); + string[] logical = ["display-message", "-p", "-t", "$7:%1", "literal-value"]; + + TmuxCommandResult result = await connection + .CreateEntityDispatcher(new ServerGeneration(41, 100)) + .ExecuteAsync(logical, TestContext.Current.CancellationToken); + + Assert.Equal( + ["display-message", "-p", "-t", "alpha:.%1", "literal-value"], + Assert.Single(requests, request => request.LogicalArguments.SequenceEqual( + ["display-message", "-p", "-t", "alpha:.%1", "literal-value"])).LogicalArguments); + TmuxCommandRequest dispatched = Assert.Single( + requests, + request => request.LogicalArguments.SequenceEqual( + ["display-message", "-p", "-t", "alpha:.%1", "literal-value"])); + Assert.Equal("literal-value", dispatched.EncodeArguments()[^1]); + Assert.Equal(logical, result.Arguments); + } + + [Fact] + public async Task Bare_window_and_pane_ids_are_qualified_with_the_visible_session() + { + var requests = new List(); + var connection = PsmuxConnection((request, _) => + { + requests.Add(request); + return Task.FromResult(OneSessionResult(request)); + }); + TmuxCommandDispatcher dispatcher = connection.CreateEntityDispatcher( + new ServerGeneration(41, 100)); + + await dispatcher.ExecuteAsync( + ["display-message", "-p", "-t", "%1", "#{pane_id}"], + TestContext.Current.CancellationToken); + await dispatcher.ExecuteAsync( + ["display-message", "-p", "-t", "@1", "#{window_id}"], + TestContext.Current.CancellationToken); + + Assert.Equal( + ["display-message", "-p", "-t", "alpha:.%1", "#{pane_id}"], + Assert.Single(requests, request => request.LogicalArguments.SequenceEqual( + ["display-message", "-p", "-t", "alpha:.%1", "#{pane_id}"])).LogicalArguments); + Assert.Equal( + ["display-message", "-p", "-t", "alpha:@1", "#{window_id}"], + Assert.Single(requests, request => request.LogicalArguments.SequenceEqual( + ["display-message", "-p", "-t", "alpha:@1", "#{window_id}"])).LogicalArguments); + } + + [Fact] + public async Task All_scope_window_and_pane_queries_are_routed_to_the_exact_session() + { + var requests = new List(); + var connection = PsmuxConnection((request, _) => + { + requests.Add(request); + return Task.FromResult(OneSessionResult(request)); + }); + + await connection.ServerDispatcher.ExecuteAsync( + ["list-windows", "-a", "-F", "#{window_id}"], + TestContext.Current.CancellationToken); + await connection.ServerDispatcher.ExecuteAsync( + ["list-panes", "-a", "-F", "#{pane_id}"], + TestContext.Current.CancellationToken); + await connection.ServerDispatcher.ExecuteAsync( + ["list-windows", "-F", "-a"], + TestContext.Current.CancellationToken); + await connection.ServerDispatcher.ExecuteAsync( + ["list-panes", "-F", "-s", "-a"], + TestContext.Current.CancellationToken); + + Assert.Single(requests, request => request.LogicalArguments.SequenceEqual( + ["list-windows", "-t", "alpha", "-F", "#{window_id}"])); + Assert.Single(requests, request => request.LogicalArguments.SequenceEqual( + ["list-panes", "-t", "alpha", "-s", "-F", "#{pane_id}"])); + Assert.Single(requests, request => request.LogicalArguments.SequenceEqual( + ["list-windows", "-t", "alpha", "-F", "-a"])); + Assert.Single(requests, request => request.LogicalArguments.SequenceEqual( + ["list-panes", "-t", "alpha", "-s", "-F", "-s"])); + } + + [Fact] + public async Task Every_targetable_query_is_bound_to_the_visible_session() + { + var requests = new List(); + var connection = PsmuxConnection((request, _) => + { + requests.Add(request); + return Task.FromResult(OneSessionResult(request)); + }); + + await connection.ServerDispatcher.ExecuteAsync( + ["has-session"], + TestContext.Current.CancellationToken); + await connection.ServerDispatcher.ExecuteAsync( + ["list-windows", "-F", "#{window_id}"], + TestContext.Current.CancellationToken); + await connection.ServerDispatcher.ExecuteAsync( + ["list-panes", "-s", "-F", "#{pane_id}"], + TestContext.Current.CancellationToken); + await connection.ServerDispatcher.ExecuteAsync( + ["display-message", "-p", "message"], + TestContext.Current.CancellationToken); + await connection.ServerDispatcher.ExecuteAsync( + ["capture-pane", "-p"], + TestContext.Current.CancellationToken); + + string[][] expected = + [ + ["has-session", "-t", "alpha"], + ["list-windows", "-t", "alpha", "-F", "#{window_id}"], + ["list-panes", "-t", "alpha", "-s", "-F", "#{pane_id}"], + ["display-message", "-t", "alpha", "-p", "message"], + ["capture-pane", "-t", "alpha", "-p"], + ]; + foreach (string[] command in expected) + { + Assert.Single(requests, request => request.LogicalArguments.SequenceEqual(command)); + } + } + + [Theory] + [MemberData(nameof(MismatchedSessionTargets))] + public async Task Mismatched_session_targets_are_rejected_without_query_dispatch( + string[] command) + { + var requests = new List(); + var connection = PsmuxConnection((request, _) => + { + requests.Add(request); + return Task.FromResult(OneSessionResult(request)); + }); + + await Assert.ThrowsAsync( + () => connection.ServerDispatcher.ExecuteAsync( + command, + TestContext.Current.CancellationToken)); + + Assert.DoesNotContain(requests, request => request.LogicalArguments.SequenceEqual(command)); + Assert.DoesNotContain(requests, request => + request.LogicalArguments.Contains("beta", StringComparer.Ordinal) + || request.LogicalArguments.Contains("$999", StringComparer.Ordinal) + || request.LogicalArguments.Contains("=$999", StringComparer.Ordinal)); + } + + public static TheoryData MismatchedSessionTargets => + new() + { + { ["has-session", "-t", "$999"] }, + { ["has-session", "-t", "=$999"] }, + { ["has-session", "-t", "beta"] }, + { ["has-session", "-t", "=beta"] }, + { ["display-message", "-p", "-t", "beta:%1", "#{pane_id}"] }, + { ["display-message", "-p", "-t", "=beta:%1", "#{pane_id}"] }, + }; + + [Fact] + public async Task Missing_object_target_fails_before_the_query_can_fall_back_to_active() + { + var requests = new List(); + var connection = PsmuxConnection((request, _) => + { + requests.Add(request); + if (request.LogicalArguments[0] == "list-panes") + { + return Task.FromResult(Result(request.LogicalArguments)); + } + + return Task.FromResult(OneSessionResult(request)); + }); + + await Assert.ThrowsAsync( + () => connection.CreateEntityDispatcher(new ServerGeneration(41, 100)).ExecuteAsync( + ["capture-pane", "-p", "-t", "%99"], + TestContext.Current.CancellationToken)); + + Assert.DoesNotContain(requests, request => request.LogicalArguments.SequenceEqual( + ["capture-pane", "-p", "-t", "alpha:.%99"])); + } + + [Fact] + public async Task Ambiguous_target_tokens_are_rejected_before_session_discovery() + { + int calls = 0; + var connection = PsmuxConnection((request, _) => + { + calls++; + return Task.FromResult(Result(request.LogicalArguments)); + }); + TmuxCommandDispatcher dispatcher = connection.CreateEntityDispatcher( + new ServerGeneration(41, 100)); + string[][] commands = + [ + ["send-keys", "-t", "%1", "-t", "$0"], + ["send-keys", "--", "-t", "$0"], + ["display-message", "-p", "-t", "child__alpha:%1"], + ["display-message", "-p", "-t", "alpha:.+"], + ["capture-pane", "-p", "-t", "alpha:.{last}"], + ["capture-pane", "-p", "-t", ":.+"], + ]; + + foreach (string[] command in commands) + { + await Assert.ThrowsAsync( + () => dispatcher.ExecuteAsync( + command, + TestContext.Current.CancellationToken)); + } + + Assert.Equal(0, calls); + } + + [Fact] + public async Task Multiple_sessions_fail_before_entity_dispatch_and_grouped_commands_are_rejected() + { + int calls = 0; + var connection = PsmuxConnection((request, _) => + { + calls++; + return Task.FromResult(Result( + request.LogicalArguments, + "41:100\t$7\talpha\n42:101\t$8\tbeta\n")); + }); + var generation = new ServerGeneration(41, 100); + + await Assert.ThrowsAsync( + () => connection.CreateEntityDispatcher(generation).ExecuteAsync( + ["display-message", "-p", "ok"], + TestContext.Current.CancellationToken)); + Assert.Equal(1, calls); + + await Assert.ThrowsAsync( + () => connection.ExecuteGuardedGroupAsync( + generation, + [["display-message", "-p", "one"], ["display-message", "-p", "two"]], + TestContext.Current.CancellationToken)); + Assert.Equal(1, calls); + } + + [Fact] + public async Task Empty_namespace_normalizes_list_and_has_session_as_dead() + { + var connection = PsmuxConnection((request, _) => + Task.FromResult(Result(request.LogicalArguments))); + + TmuxCommandResult listed = await connection.ServerDispatcher.ExecuteAsync( + ["list-sessions"], + TestContext.Current.CancellationToken); + TmuxCommandResult has = await connection.ServerDispatcher.ExecuteAsync( + ["has-session", "-t", "alpha"], + TestContext.Current.CancellationToken); + + Assert.Equal(1, listed.ExitCode); + Assert.Equal(1, has.ExitCode); + Assert.Equal( + ["no server running on selected psmux namespace"], + listed.StandardErrorLines); + } + + [Fact] + public async Task Has_session_accepts_the_exact_name_marker() + { + var requests = new List(); + var connection = PsmuxConnection((request, _) => + { + requests.Add(request); + return Task.FromResult(OneSessionResult(request)); + }); + + TmuxCommandResult result = await connection.ServerDispatcher.ExecuteAsync( + ["has-session", "-t", "=alpha"], + TestContext.Current.CancellationToken); + await connection.ServerDispatcher.ExecuteAsync( + ["has-session", "-t", "=$7"], + TestContext.Current.CancellationToken); + + Assert.Equal(0, result.ExitCode); + Assert.Equal( + 2, + requests.Count(request => request.LogicalArguments.SequenceEqual( + ["has-session", "-t", "=alpha"]))); + } + + [Fact] + public async Task Mutating_commands_and_aliases_are_rejected_before_dispatch() + { + int calls = 0; + var connection = PsmuxConnection((request, _) => + { + calls++; + return Task.FromResult(Result(request.LogicalArguments)); + }); + string[][] commands = + [ + ["new-session", "-d", "-s", "alpha"], + ["new", "-d", "-s", "alpha"], + ["start-server"], + ["kill-server", "-Z"], + ["kill-ses", "-aC", "-t", "$7"], + ["killw", "-a", "-t", "@1"], + ["killp", "-a", "-t", "%1"], + ["unlinkw", "-k", "-t", "@1"], + ["respawnw", "-k", "-t", "@1"], + ["respawnp", "-k", "-t", "%1"], + ["detach"], + ["rename-session", "-t", "$7", "renamed"], + ["send-keys", "-t", "%1", "text"], + ["set-option", "-g", "status", "off"], + ["capture-pane", "-t", "%1", "-b", "buffer"], + ["capture-pane", "-p", "-pb", "buffer"], + ["capture-pane", "-p", "-N"], + ["capture-pane", "-p", "-S", "0 -t beta"], + ["capture-pane", "-p", "-E", "0\t-t\tbeta"], + ["capture-pane", "-p", "-S", "00"], + ["capture-pane", "-p", "-S", "0", "-S", "1"], + ["capture-pane", "-p", "-p"], + ["display-message", "-t", "%1", "message"], + ["display-message", "-p", "-N", "message"], + ["display-message", "-p", "-F", "#{pane_id}"], + ["display-message", "-p", "-d", "1 -t beta", "message"], + ["display-message", "-p", "-p", "message"], + ["has-session", "-Z"], + ["list-sessions", "-f", "#{session_attached}"], + ["list-windows", "-f", "#{window_active}"], + ["list-panes", "-Z"], + ["list-panes", "-F#{pane_id}"], + ["display-message", "-p", "#(cmd /c echo unsafe)"], + ["list-sessions", "-F", "#(cmd /c echo unsafe)"], + ["list-windows", "-F", "#(cmd /c echo unsafe)"], + ["list-panes", "-F", "#(cmd /c echo unsafe)"], + ["display-message", "-p", "#{E:unsafe}"], + ["display-message", "-p", "#{T:unsafe}"], + ["display-message", "-p", "#{Efoo:@unsafe}"], + ["display-message", "-p", "#{Tfoo:@unsafe}"], + ]; + + foreach (string[] command in commands) + { + await Assert.ThrowsAsync( + () => connection.ServerDispatcher.ExecuteAsync( + command, + TestContext.Current.CancellationToken)); + } + + Assert.Equal(0, calls); + } + + [Fact] + public async Task Command_arguments_known_to_be_corrupted_are_rejected_before_dispatch() + { + int calls = 0; + var connection = PsmuxConnection((request, _) => + { + calls++; + return Task.FromResult(Result(request.LogicalArguments)); + }); + + string[] values = + [ + string.Empty, + "nul\0value", + "cr\rvalue", + "lf\nvalue", + "single'quote", + "double\"quote", + "double\\\\slash", + "trailing\\", + "literal;", + "0; kill-server", + "a;b", + "text ; kill-server", + "text \\; kill-server", + ]; + foreach (string value in values) + { + await Assert.ThrowsAsync( + () => connection.ServerDispatcher.ExecuteAsync( + ["display-message", "-p", value], + TestContext.Current.CancellationToken)); + } + + Assert.Equal(0, calls); + } + + [Fact] + public async Task Namespace_prefix_collisions_that_cannot_be_targeted_exactly_fail_closed() + { + int calls = 0; + var connection = PsmuxConnection((request, _) => + { + calls++; + return Task.FromResult(request.LogicalArguments[0] == "list-sessions" + ? Result(request.LogicalArguments, "41:100\t$7\tsecondary\n") + : Result( + request.LogicalArguments, + standardError: "can't find session\n", + exitCode: 1)); + }); + + await Assert.ThrowsAsync( + () => connection.ServerDispatcher.ExecuteAsync( + ["list-sessions"], + TestContext.Current.CancellationToken)); + Assert.Equal(2, calls); + } + + [Fact] + public void Psmux_preview_requires_an_explicit_namespace() + { + ArgumentException error = Assert.Throws( + () => new PsmuxConnectionOptions( + TestBinaryPath, + TestBinarySha256, + TestDataDirectory, + "")); + + Assert.Equal("namespaceName", error.ParamName); + } + + [Fact] + public void Psmux_public_surface_omits_unbounded_connection_settings() + { + string[] names = typeof(PsmuxConnectionOptions) + .GetProperties() + .Select(property => property.Name) + .ToArray(); + + Assert.DoesNotContain("SocketPath", names); + Assert.DoesNotContain("ConfigurationFile", names); + Assert.DoesNotContain("ColorMode", names); + Assert.DoesNotContain("ChildEnvironment", names); + Assert.DoesNotContain("InitializeAsync", names); + } + + [Fact] + public void Psmux_public_surface_exposes_queries_only() + { + static string[] Methods(Type type) => + [ + .. type.GetMethods( + System.Reflection.BindingFlags.Public + | System.Reflection.BindingFlags.Static + | System.Reflection.BindingFlags.Instance + | System.Reflection.BindingFlags.DeclaredOnly) + .Where(method => !method.IsSpecialName) + .Select(method => method.Name) + .Distinct(StringComparer.Ordinal) + .Order(StringComparer.Ordinal), + ]; + + Assert.Equal( + ["ConnectAsync", "GetPanesAsync", "GetSessionAsync", "GetWindowsAsync", "RefreshAsync"], + Methods(typeof(PsmuxServer))); + Assert.Equal( + ["GetPanesAsync", "GetWindowsAsync"], + Methods(typeof(PsmuxSession))); + Assert.Equal(["GetPanesAsync"], Methods(typeof(PsmuxWindow))); + Assert.Equal(["CaptureAsync"], Methods(typeof(PsmuxPane))); + } + + [Fact] + public async Task Psmux_facade_preserves_strict_transport_failures() + { + bool failFinalQuery = false; + string[]? routedArguments = null; + var connection = new TmuxConnection( + PsmuxOptions(), + (request, _) => + { + if (failFinalQuery && request.LogicalArguments[0] == "list-windows") + { + routedArguments = [.. request.LogicalArguments]; + throw new TmuxTransportException( + "the verified client disappeared", + request.LogicalArguments, + TmuxDispatchState.NotDispatched); + } + + 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( + new PsmuxConnectionOptions( + TestBinaryPath, + TestBinarySha256, + TestDataDirectory, + TestNamespace), + new Server(connection, generation, rawVersion)); + failFinalQuery = true; + + TmuxTransportException error = await Assert.ThrowsAsync( + () => facade.GetWindowsAsync(TestContext.Current.CancellationToken)); + + Assert.Equal("the verified client disappeared", error.Message); + Assert.Equal(TmuxDispatchState.NotDispatched, error.Dispatch); + Assert.NotNull(routedArguments); + Assert.Equal( + ["list-windows", "-a", "-F", routedArguments[^1]], + error.Arguments); + Assert.Equal(["list-windows", "-t", "alpha", "-F", routedArguments[^1]], routedArguments); + } + + [Fact] + public void Psmux_capture_options_map_only_the_audited_flags() + { + var options = new PsmuxCaptureOptions( + startLine: new CapturePanePosition(-25), + endLine: CapturePanePosition.EndOfVisiblePane, + escapeSequences: true, + joinWrappedLines: true); + + CapturePaneRequest request = options.ToRequest(); + + Assert.Equal(-25, request.StartLine?.LineNumber); + Assert.Null(request.EndLine?.LineNumber); + Assert.True(request.EscapeSequences); + Assert.True(request.JoinWrappedLines); + Assert.False(request.AlternateScreen); + Assert.False(request.Pending); + } + + [Theory] + [InlineData("3.3.6")] + [InlineData("3.3.8")] + public async Task Psmux_connection_rejects_versions_outside_the_audited_allowlist( + string version) + { + int calls = 0; + var connection = new TmuxConnection( + PsmuxOptions(), + (request, _) => + { + calls++; + return Task.FromResult(Result( + request.LogicalArguments, + $"tmux {version}\npsmux {version}\n")); + }, + implementation: TmuxImplementation.Unknown); + + await Assert.ThrowsAsync( + () => connection.DiscoverAsync(TestContext.Current.CancellationToken)); + Assert.Equal(1, calls); + } + + [Theory] + [InlineData("psmux 3.3.7")] + [InlineData("psmux 3.3.7 (05cc5d4 2026-07-20)")] + [InlineData("psmux 3.3.7 (aa26cd3 2026-08-17 dirty)")] + [InlineData("psmux 3.3.7 (aa26cd3 2026-08-17, dirty)")] + public async Task Psmux_connection_requires_the_audited_build_provenance(string secondLine) + { + int calls = 0; + var connection = new TmuxConnection( + PsmuxOptions(), + (request, _) => + { + calls++; + return Task.FromResult(Result( + request.LogicalArguments, + $"tmux 3.3.7\n{secondLine}\n")); + }, + implementation: TmuxImplementation.Unknown); + + await Assert.ThrowsAsync( + () => connection.DiscoverAsync(TestContext.Current.CancellationToken)); + Assert.Equal(1, calls); + } + + [Theory] + [InlineData("unit__nested")] + [InlineData("unit\0nested")] + [InlineData("unit\rnested")] + [InlineData("unit\nnested")] + public void Psmux_rejects_ambiguous_or_unsafe_namespace_names(string socketName) + { + Assert.Throws(() => new PsmuxConnectionOptions( + TestBinaryPath, + TestBinarySha256, + TestDataDirectory, + socketName)); + } + + [Theory] + [InlineData("child__alpha")] + [InlineData("x ; kill-server")] + public async Task Psmux_rejects_unsafe_session_names_before_targeted_probes(string sessionName) + { + int calls = 0; + var connection = PsmuxConnection((request, _) => + { + calls++; + return Task.FromResult(Result(request.LogicalArguments, $"41:100\t$7\t{sessionName}\n")); + }); + + await Assert.ThrowsAsync( + () => connection.ServerDispatcher.ExecuteAsync( + ["list-sessions"], + TestContext.Current.CancellationToken)); + Assert.Equal(1, calls); + } + + private static TmuxConnection PsmuxConnection( + Func> execute) => + new( + PsmuxOptions(), + execute, + implementation: TmuxImplementation.Psmux); + + private static ServerConnectionOptions PsmuxOptions( + string? binaryPath = null, + string? dataDirectory = null, + string? namespaceName = null) => + ServerConnectionOptions.ForPsmux(new PsmuxConnectionOptions( + binaryPath ?? TestBinaryPath, + TestBinarySha256, + dataDirectory ?? TestDataDirectory, + namespaceName ?? TestNamespace)); + + private static bool IsGenerationProbe(TmuxCommandRequest request) => + request.LogicalArguments.SequenceEqual( + ["display-message", "-p", "-t", "alpha", "#{pid}:#{start_time}\t#{session_id}\t#{session_name}"]); + + private static bool IsSelectedGenerationProbe(TmuxCommandRequest request) => + request.LogicalArguments.SequenceEqual( + ["display-message", "-p", "#{pid}:#{start_time}\t#{session_id}\t#{session_name}"]); + + private static TmuxCommandResult OneSessionResult( + TmuxCommandRequest request, + string commandOutput = "") + { + IReadOnlyList arguments = request.LogicalArguments; + if (arguments[0] == "list-sessions") + { + return Result(arguments, "41:100\t$7\talpha\n"); + } + + if (IsGenerationProbe(request)) + { + return Result(arguments, "41:100\t$7\talpha\n"); + } + + if (IsSelectedGenerationProbe(request)) + { + return Result(arguments, "41:100\t$7\talpha\n"); + } + + if (arguments[0] == "list-panes") + { + return Result(arguments, "%1\n"); + } + + if (arguments[0] == "list-windows") + { + return Result(arguments, "@1\n"); + } + + return Result(arguments, commandOutput); + } + + private static TmuxCommandResult Result( + IReadOnlyList arguments, + string standardOutput = "", + string standardError = "", + int exitCode = 0) + { + byte[] output = Encoding.UTF8.GetBytes(standardOutput); + byte[] error = Encoding.UTF8.GetBytes(standardError); + return new TmuxCommandResult( + arguments, + exitCode, + output, + error, + Utf8BackslashDecoder.ProjectOutputLines(output), + Utf8BackslashDecoder.ProjectErrorLines(error)); + } + + private sealed class RecordingSynchronizationContext : SynchronizationContext + { + private int _postCalls; + + internal int PostCalls => Volatile.Read(ref _postCalls); + + public override void Post(SendOrPostCallback callback, object? state) + { + Interlocked.Increment(ref _postCalls); + base.Post(callback, state); + } + } +} diff --git a/tests/LibTmux.UnitTests/Connection/PsmuxProcessSmokeTests.cs b/tests/LibTmux.UnitTests/Connection/PsmuxProcessSmokeTests.cs new file mode 100644 index 0000000..4931595 --- /dev/null +++ b/tests/LibTmux.UnitTests/Connection/PsmuxProcessSmokeTests.cs @@ -0,0 +1,65 @@ +namespace LibTmux.UnitTests.Connection; + +internal static class PsmuxSmokeEnvironment +{ + public static bool IsEnabled => + string.Equals( + Environment.GetEnvironmentVariable("LIBTMUX_PSMUX_SMOKE"), + "1", + StringComparison.Ordinal); +} + +public sealed class PsmuxProcessSmokeTests +{ + [Fact( + Skip = "Requires an explicitly provisioned one-session psmux namespace.", + SkipType = typeof(PsmuxSmokeEnvironment), + SkipUnless = nameof(PsmuxSmokeEnvironment.IsEnabled))] + public async Task Connect_and_typed_queries_use_audited_psmux() + { + string binary = Environment.GetEnvironmentVariable("LIBTMUX_PSMUX_BINARY") + ?? throw new InvalidOperationException("LIBTMUX_PSMUX_BINARY is required."); + string binarySha256 = Environment.GetEnvironmentVariable("LIBTMUX_PSMUX_SHA256") + ?? throw new InvalidOperationException("LIBTMUX_PSMUX_SHA256 is required."); + string dataDirectory = Environment.GetEnvironmentVariable("PSMUX_DATA_DIR") + ?? throw new InvalidOperationException("PSMUX_DATA_DIR is required."); + string socketName = Environment.GetEnvironmentVariable("LIBTMUX_PSMUX_NAMESPACE") + ?? throw new InvalidOperationException("LIBTMUX_PSMUX_NAMESPACE is required."); + string expectedText = Environment.GetEnvironmentVariable("LIBTMUX_PSMUX_EXPECTED_TEXT") + ?? throw new InvalidOperationException("LIBTMUX_PSMUX_EXPECTED_TEXT is required."); + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + PsmuxServer server = await PsmuxServer.ConnectAsync( + new PsmuxConnectionOptions( + binary, + binarySha256, + dataDirectory, + socketName), + cancellationToken); + + Assert.Equal(TmuxVersion.Parse("3.3.7"), server.Version); + PsmuxServer refreshed = await server.RefreshAsync(cancellationToken); + Assert.Equal(server.Version, refreshed.Version); + + PsmuxSession session = await refreshed.GetSessionAsync(cancellationToken); + PsmuxWindow window = Assert.Single(await session.GetWindowsAsync(cancellationToken)); + PsmuxWindow serverWindow = Assert.Single( + await refreshed.GetWindowsAsync(cancellationToken)); + Assert.Equal(window.Id, serverWindow.Id); + + PsmuxPane pane = Assert.Single(await window.GetPanesAsync(cancellationToken)); + PsmuxPane sessionPane = Assert.Single(await session.GetPanesAsync(cancellationToken)); + PsmuxPane serverPane = Assert.Single(await refreshed.GetPanesAsync(cancellationToken)); + Assert.Equal(pane.Id, sessionPane.Id); + Assert.Equal(pane.Id, serverPane.Id); + IReadOnlyList captured = await pane.CaptureAsync( + new PsmuxCaptureOptions(joinWrappedLines: true), + cancellationToken); + + Assert.False(string.IsNullOrWhiteSpace(session.Name)); + Assert.True(window.Width > 0); + Assert.True(window.Height > 0); + Assert.True(pane.Width > 0); + Assert.True(pane.Height > 0); + Assert.Contains(captured, line => line.Contains(expectedText, StringComparison.Ordinal)); + } +} diff --git a/tests/LibTmux.UnitTests/Connection/TmuxConnectionTests.cs b/tests/LibTmux.UnitTests/Connection/TmuxConnectionTests.cs index f59cfe2..51df9aa 100644 --- a/tests/LibTmux.UnitTests/Connection/TmuxConnectionTests.cs +++ b/tests/LibTmux.UnitTests/Connection/TmuxConnectionTests.cs @@ -160,6 +160,8 @@ public void Child_environment_removes_inherited_tmux_and_honors_an_explicit_over { var startInfo = new ProcessStartInfo("tmux"); startInfo.Environment["TMUX"] = "inherited"; + startInfo.Environment["PSMUX_SESSION"] = "inherited"; + startInfo.Environment["PSMUX_TARGET_FULL"] = "$9"; startInfo.Environment["REMOVE"] = "value"; string? processTmux = Environment.GetEnvironmentVariable("TMUX"); var overrides = new Dictionary @@ -171,6 +173,8 @@ public void Child_environment_removes_inherited_tmux_and_honors_an_explicit_over TmuxConnection.ApplyChildEnvironment(startInfo, overrides); Assert.False(startInfo.Environment.ContainsKey("TMUX")); + Assert.False(startInfo.Environment.ContainsKey("PSMUX_SESSION")); + Assert.False(startInfo.Environment.ContainsKey("PSMUX_TARGET_FULL")); Assert.False(startInfo.Environment.ContainsKey("REMOVE")); Assert.Equal("child", startInfo.Environment["ADD"]); Assert.Equal(processTmux, Environment.GetEnvironmentVariable("TMUX")); @@ -180,9 +184,14 @@ public void Child_environment_removes_inherited_tmux_and_honors_an_explicit_over TmuxConnection.ApplyChildEnvironment( overriddenStartInfo, - new Dictionary { ["TMUX"] = "explicit" }); + new Dictionary + { + ["TMUX"] = "explicit", + ["PSMUX_SESSION"] = "explicit-session", + }); Assert.Equal("explicit", overriddenStartInfo.Environment["TMUX"]); + Assert.Equal("explicit-session", overriddenStartInfo.Environment["PSMUX_SESSION"]); Assert.Equal(processTmux, Environment.GetEnvironmentVariable("TMUX")); var emptyStartInfo = new ProcessStartInfo("tmux"); @@ -200,6 +209,50 @@ public void Child_environment_removes_inherited_tmux_and_honors_an_explicit_over Assert.False(removedStartInfo.Environment.ContainsKey("TMUX")); } + [Fact] + public void Psmux_child_environment_is_forwarded_through_wslenv_without_routing_state() + { + var startInfo = new ProcessStartInfo("psmux.exe"); + startInfo.Environment["WSLENV"] = + "PATH/p:psmux_session:PSMUX_DATA_DIR/p:TMUX:OTHER/l:tmux_pane:psmux_data_dir/u:psmux_route_debug/u"; + startInfo.Environment["PSMUX_SESSION"] = "inherited"; + startInfo.Environment["Psmux_Route_Debug"] = "1"; + startInfo.Environment["TMUX"] = "inherited"; + startInfo.Environment["tmux_pane"] = "%99"; + + TmuxConnection.ApplyChildEnvironment( + startInfo, + new Dictionary + { + ["PSMUX_DATA_DIR"] = "C:\\isolated\\psmux", + }, + forwardPsmuxDataDirectoryThroughWsl: true); + + Assert.Equal("C:\\isolated\\psmux", startInfo.Environment["PSMUX_DATA_DIR"]); + Assert.Equal("PATH/p:OTHER/l:PSMUX_DATA_DIR/w", startInfo.Environment["WSLENV"]); + Assert.False(startInfo.Environment.ContainsKey("PSMUX_SESSION")); + Assert.False(startInfo.Environment.ContainsKey("Psmux_Route_Debug")); + Assert.False(startInfo.Environment.ContainsKey("TMUX")); + Assert.False(startInfo.Environment.ContainsKey("tmux_pane")); + } + + [Fact] + public void Psmux_child_environment_creates_wslenv_when_none_is_inherited() + { + var startInfo = new ProcessStartInfo("psmux.exe"); + startInfo.Environment.Remove("WSLENV"); + + TmuxConnection.ApplyChildEnvironment( + startInfo, + new Dictionary + { + ["PSMUX_DATA_DIR"] = "C:\\isolated\\psmux", + }, + forwardPsmuxDataDirectoryThroughWsl: true); + + Assert.Equal("PSMUX_DATA_DIR/w", startInfo.Environment["WSLENV"]); + } + public static TheoryData PrefixCases => new() { @@ -780,6 +833,7 @@ public async Task Transport_exception_arguments_are_remapped_to_the_logical_targ (request, _) => throw new TmuxTransportException( "transport failed", request.LogicalArguments, + TmuxDispatchState.NotDispatched, root), () => "libtmux_guard_abcd1234"); string[] logical = ["select-pane", "-t", "%0", "-P", "hostile;value"]; @@ -790,6 +844,7 @@ public async Task Transport_exception_arguments_are_remapped_to_the_logical_targ .ExecuteAsync(logical, TestContext.Current.CancellationToken)); Assert.Equal(logical, error.Arguments); + Assert.Equal(TmuxDispatchState.NotDispatched, error.Dispatch); Assert.Same(root, error.InnerException); Assert.DoesNotContain(error.Arguments, argument => argument.Contains("guard", StringComparison.Ordinal)); } @@ -911,14 +966,13 @@ or nameof(Server.GetPaneAsync)) [SuppressMessage( "Interoperability", "CA1416:Validate platform compatibility", - Justification = "This Windows-only test verifies the runtime platform guard.")] - public async Task Process_backed_server_members_throw_on_windows() + Justification = "This Windows-only test verifies the production trust gate.")] + public async Task Process_backed_server_requires_preview_opt_in_on_windows() { - Server server = Server.Open(); + string missing = Path.Combine(Path.GetTempPath(), $"missing-tmux-{Guid.NewGuid():N}.exe"); + Server server = Server.Open(new ServerConnectionOptions(tmuxBinaryPath: missing)); await Assert.ThrowsAsync( () => server.ConnectAsync(TestContext.Current.CancellationToken)); - await Assert.ThrowsAsync( - () => server.GetSessionAsync(default, TestContext.Current.CancellationToken)); } } diff --git a/tests/LibTmux.UnitTests/ControlMode/ControlModeEventBufferTests.cs b/tests/LibTmux.UnitTests/ControlMode/ControlModeEventBufferTests.cs new file mode 100644 index 0000000..0a7782f --- /dev/null +++ b/tests/LibTmux.UnitTests/ControlMode/ControlModeEventBufferTests.cs @@ -0,0 +1,87 @@ +using System.Globalization; +using System.Runtime.Versioning; +using LibTmux.Internal; + +namespace LibTmux.UnitTests.ControlMode; + +[UnsupportedOSPlatform("windows")] +public sealed class ControlModeEventBufferTests +{ + [Fact] + public async Task Overflow_is_reported_without_blocking_the_writer() + { + const int ExtraEvents = 11; + var buffer = new ControlModeEventBuffer(ControlModeSession.EventBufferCapacity); + + for (int index = 0; + index < ControlModeSession.EventBufferCapacity + ExtraEvents; + index++) + { + Assert.True(buffer.TryWrite(new TmuxNotificationEvent( + index.ToString(CultureInfo.InvariantCulture), + []))); + } + buffer.Complete(); + + var observed = new List(); + await foreach (TmuxEvent item in buffer.ReadAllAsync( + TestContext.Current.CancellationToken)) + { + observed.Add(item); + } + + TmuxEventsDroppedEvent loss = Assert.IsType(observed[0]); + Assert.Equal(ExtraEvents, loss.Count); + Assert.Equal(ExtraEvents, loss.TotalDropped); + TmuxNotificationEvent firstRetained = Assert.IsType(observed[1]); + Assert.Equal(ExtraEvents.ToString(CultureInfo.InvariantCulture), firstRetained.Name); + Assert.Equal(ControlModeSession.EventBufferCapacity + 1, observed.Count); + } + + [Fact] + public async Task A_drop_after_dequeue_is_reported_after_the_held_event() + { + CancellationToken token = TestContext.Current.CancellationToken; + using var consumerDequeued = new ManualResetEventSlim(); + using var producerAttempted = new ManualResetEventSlim(); + var buffer = new ControlModeEventBuffer( + capacity: 2, + afterDequeue: () => + { + consumerDequeued.Set(); + producerAttempted.Wait(token); + }); + Assert.True(buffer.TryWrite(Notification("before"))); + Assert.True(buffer.TryWrite(Notification("will-drop"))); + + Task producer = Task.Run( + () => + { + consumerDequeued.Wait(token); + producerAttempted.Set(); + Assert.True(buffer.TryWrite(Notification("retained-1"))); + Assert.True(buffer.TryWrite(Notification("retained-2"))); + }, + token); + + await using IAsyncEnumerator reader = + buffer.ReadAllAsync(token).GetAsyncEnumerator(token); + Assert.True(await reader.MoveNextAsync()); + Assert.Equal("before", Assert.IsType(reader.Current).Name); + + await producer.WaitAsync(token); + buffer.Complete(); + + Assert.True(await reader.MoveNextAsync()); + TmuxEventsDroppedEvent loss = Assert.IsType(reader.Current); + Assert.Equal(1, loss.Count); + Assert.Equal(1, loss.TotalDropped); + Assert.True(await reader.MoveNextAsync()); + Assert.Equal("retained-1", Assert.IsType(reader.Current).Name); + Assert.True(await reader.MoveNextAsync()); + Assert.Equal("retained-2", Assert.IsType(reader.Current).Name); + Assert.False(await reader.MoveNextAsync()); + } + + private static TmuxNotificationEvent Notification(string name) => new(name, []); +} diff --git a/tests/LibTmux.UnitTests/ControlMode/ControlModeSessionFailureTests.cs b/tests/LibTmux.UnitTests/ControlMode/ControlModeSessionFailureTests.cs new file mode 100644 index 0000000..63d34ed --- /dev/null +++ b/tests/LibTmux.UnitTests/ControlMode/ControlModeSessionFailureTests.cs @@ -0,0 +1,837 @@ +using System.Globalization; +using System.Runtime.ExceptionServices; +using System.Runtime.Versioning; +using System.Threading.Channels; + +namespace LibTmux.UnitTests.ControlMode; + +[UnsupportedOSPlatform("windows")] +public sealed class ControlModeSessionFailureTests +{ + [Fact] + public async Task A_faulted_pump_cannot_skip_process_and_write_lock_disposal() + { + CancellationToken token = TestContext.Current.CancellationToken; + var pumpFailure = new IOException("control output failed"); + var process = new FaultedPumpProcess(pumpFailure); + var writeLock = new SemaphoreSlim(1, 1); + var session = new ControlModeSession(process, writeLock); + + IOException readinessFailure = await Assert.ThrowsAsync( + () => session.WaitForReadyAsync(token)); + IOException observed = await Assert.ThrowsAsync( + () => session.DisposeAsync().AsTask()); + + Assert.Same(pumpFailure, readinessFailure); + Assert.Same(pumpFailure, observed); + Assert.True(process.DisposeCalled); + Assert.Throws(() => + { + _ = writeLock.Wait(0, token); + }); + } + + [Fact] + public async Task Pump_and_cleanup_failures_are_both_preserved() + { + CancellationToken token = TestContext.Current.CancellationToken; + var pumpFailure = new IOException("control output failed"); + var cleanupFailure = new InvalidOperationException("process dispose failed"); + var process = new FaultedPumpProcess(pumpFailure, cleanupFailure); + var writeLock = new SemaphoreSlim(1, 1); + var session = new ControlModeSession(process, writeLock); + + IOException readinessFailure = await Assert.ThrowsAsync( + () => session.WaitForReadyAsync(token)); + AggregateException observed = await Assert.ThrowsAsync( + () => session.DisposeAsync().AsTask()); + + Assert.Same(pumpFailure, readinessFailure); + Assert.Equal(2, observed.InnerExceptions.Count); + Assert.Same(pumpFailure, observed.InnerExceptions[0]); + Assert.Same(cleanupFailure, observed.InnerExceptions[1]); + Assert.True(process.DisposeCalled); + Assert.Throws(() => + { + _ = writeLock.Wait(0, token); + }); + } + + [Fact] + public async Task Disposal_kills_a_client_whose_write_holds_the_dispatch_lock() + { + CancellationToken token = TestContext.Current.CancellationToken; + var process = new StalledWriteProcess(); + var writeLock = new SemaphoreSlim(1, 1); + var session = new ControlModeSession( + process, + writeLock, + TimeSpan.FromMilliseconds(25)); + + await session.WaitForReadyAsync(token); + Task> send = session.SendAsync( + "display-message -p stuck", + token); + await process.WriteStarted.Task.WaitAsync(token); + + await session.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(2), token); + IOException writeFailure = await Assert.ThrowsAsync(async () => await send); + + Assert.Equal("The client was killed during its write.", writeFailure.Message); + Assert.True(process.KillCalled); + Assert.True(process.DisposeCalled); + Assert.False(session.IsRunning); + Assert.Throws(() => + { + _ = writeLock.Wait(0, token); + }); + } + + [Fact] + public async Task Terminal_eof_rejects_commands_when_the_process_still_claims_to_run() + { + CancellationToken token = TestContext.Current.CancellationToken; + var process = new TerminalWhileRunningProcess(); + var session = new ControlModeSession(process); + + await session.WaitForReadyAsync(token); + Task eventsCompleted = DrainEventsAsync(session.Events, token); + process.EndOutput(); + await eventsCompleted.WaitAsync(token); + + Assert.False(session.IsRunning); + await Assert.ThrowsAsync( + () => session.SendAsync("display-message -p too-late", token)); + await session.DisposeAsync(); + Assert.True(process.DisposeCalled); + } + + [Fact] + public async Task Terminal_fault_rejects_commands_when_the_process_still_claims_to_run() + { + CancellationToken token = TestContext.Current.CancellationToken; + var pumpFailure = new IOException("control output failed after attach"); + var process = new TerminalWhileRunningProcess(); + var session = new ControlModeSession(process); + + await session.WaitForReadyAsync(token); + Task eventsCompleted = DrainEventsAsync(session.Events, token); + process.EndOutput(pumpFailure); + await eventsCompleted.WaitAsync(token); + + Assert.False(session.IsRunning); + await Assert.ThrowsAsync( + () => session.SendAsync("display-message -p too-late", token)); + IOException disposalFailure = await Assert.ThrowsAsync( + () => session.DisposeAsync().AsTask()); + Assert.Same(pumpFailure, disposalFailure); + Assert.True(process.DisposeCalled); + } + + [Fact] + public async Task Terminal_eof_during_final_check_cannot_escape_the_pending_sweep() + { + CancellationToken token = TestContext.Current.CancellationToken; + var process = new TerminalWhileRunningProcess(endDuringFinalCheck: true); + var session = new ControlModeSession(process); + + await session.WaitForReadyAsync(token); + InvalidOperationException terminalFailure = + await Assert.ThrowsAsync(async () => + await session.SendAsync("display-message -p racing", 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); + await session.DisposeAsync(); + Assert.True(process.DisposeCalled); + } + + [Fact] + public async Task A_truncated_attach_block_never_marks_the_session_ready() + { + CancellationToken token = TestContext.Current.CancellationToken; + TruncatedBlockProcess process = TruncatedBlockProcess.ForAttach(); + var session = new ControlModeSession(process); + + InvalidDataException readinessFailure = + await Assert.ThrowsAsync( + () => session.WaitForReadyAsync(token)); + InvalidDataException disposalFailure = + await Assert.ThrowsAsync( + () => session.DisposeAsync().AsTask()); + + Assert.Same(readinessFailure, disposalFailure); + Assert.True(process.DisposeCalled); + } + + [Fact] + public async Task A_terminated_error_attach_block_fails_readiness_with_tmux_output() + { + CancellationToken token = TestContext.Current.CancellationToken; + TruncatedBlockProcess process = TruncatedBlockProcess.ForAttachError(); + var session = new ControlModeSession(process); + + TmuxCommandException 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); + } + + [Fact] + public async Task A_truncated_command_block_fails_every_pending_command() + { + CancellationToken token = TestContext.Current.CancellationToken; + TruncatedBlockProcess process = TruncatedBlockProcess.ForCommands(); + var session = new ControlModeSession(process); + + await session.WaitForReadyAsync(token); + Task> first = session.SendAsync("first", token); + Task> second = session.SendAsync("second", token); + await process.TwoCommandsDispatched.Task.WaitAsync(token); + + process.EndCommandBlockEarly(); + + InvalidDataException firstFailure = + await Assert.ThrowsAsync(async () => await first); + InvalidDataException secondFailure = + await Assert.ThrowsAsync(async () => await second); + InvalidDataException disposalFailure = + await Assert.ThrowsAsync( + () => session.DisposeAsync().AsTask()); + + Assert.Same(firstFailure, secondFailure); + Assert.Same(firstFailure, disposalFailure); + Assert.Equal(["first", "second"], process.WriteAttempts); + Assert.True(process.DisposeCalled); + } + + [Fact] + public async Task An_event_burst_drops_oldest_without_blocking_a_reply_or_exit() + { + CancellationToken token = TestContext.Current.CancellationToken; + const int ExtraNotifications = 56; + int notificationCount = ControlModeSession.EventBufferCapacity + ExtraNotifications; + var process = new BurstOutputProcess(notificationCount); + var session = new ControlModeSession(process); + + await session.WaitForReadyAsync(token); + IReadOnlyList reply = await session.SendAsync("display-message -p reply", token); + await session.DisposeAsync(); + + var observed = new List(); + await foreach (TmuxEvent item in session.Events.WithCancellation(token)) + { + observed.Add(item); + } + + int expectedDropped = notificationCount + 1 - ControlModeSession.EventBufferCapacity; + Assert.Equal(["reply-ok"], reply); + Assert.Equal(ControlModeSession.EventBufferCapacity + 1, observed.Count); + TmuxEventsDroppedEvent loss = Assert.IsType(observed[0]); + Assert.Equal(expectedDropped, loss.Count); + Assert.Equal(expectedDropped, loss.TotalDropped); + + for (int offset = 0; offset < ControlModeSession.EventBufferCapacity - 1; offset++) + { + TmuxNotificationEvent notification = + Assert.IsType(observed[offset + 1]); + Assert.Equal("burst", notification.Name); + Assert.Equal( + [(expectedDropped + offset).ToString(CultureInfo.InvariantCulture)], + notification.Arguments); + } + + TmuxExitEvent exit = Assert.IsType(observed[^1]); + Assert.Equal("done", exit.Reason); + Assert.True(process.DisposeCalled); + } + + [Theory] + [InlineData(DispatchFailurePoint.PartialWrite)] + [InlineData(DispatchFailurePoint.Flush)] + public async Task An_ambiguous_dispatch_fails_pending_and_rejects_the_next_command( + DispatchFailurePoint failurePoint) + { + CancellationToken token = TestContext.Current.CancellationToken; + 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"; + + try + { + await session.WaitForReadyAsync(token); + Task> pending = session.SendAsync(PendingCommand, token); + await process.FirstDispatchCompleted.Task.WaitAsync(token); + + Task> ambiguous = session.SendAsync(AmbiguousCommand, token); + await process.FailureEntered.Task.WaitAsync(token); + Task> next = session.SendAsync(NextCommand, token); + + process.ReleaseFailure(); + + InvalidOperationException pendingError = + await Assert.ThrowsAsync(async () => await pending); + IOException ambiguousError = + await Assert.ThrowsAsync(async () => await ambiguous); + await Assert.ThrowsAsync(async () => await next); + + Assert.Same(dispatchFailure, pendingError.InnerException); + Assert.Same(dispatchFailure, ambiguousError); + Assert.Equal([PendingCommand, AmbiguousCommand], process.WriteAttempts); + Assert.DoesNotContain(NextCommand, process.WriteAttempts); + Assert.Equal( + failurePoint == DispatchFailurePoint.PartialWrite + ? AmbiguousCommand[..8] + : AmbiguousCommand, + process.AmbiguousAcceptedText); + Assert.True(process.InputClosed); + Assert.True(process.DisposeCalled); + Assert.False(session.IsRunning); + } + finally + { + process.ReleaseFailure(); + await session.DisposeAsync(); + } + } + + public enum DispatchFailurePoint + { + PartialWrite, + Flush, + } + + private static async Task DrainEventsAsync( + IAsyncEnumerable events, + CancellationToken cancellationToken) + { + await foreach (TmuxEvent _ in events.WithCancellation(cancellationToken)) + { + } + } + + private sealed class TruncatedBlockProcess : IControlModeProcess + { + private readonly Channel _output = Channel.CreateUnbounded( + new UnboundedChannelOptions + { + SingleReader = true, + SingleWriter = true, + }); + private readonly TaskCompletionSource _exited = new( + TaskCreationOptions.RunContinuationsAsynchronously); + private int _flushCalls; + private int _hasExited; + + private TruncatedBlockProcess() + { + } + + internal bool DisposeCalled { get; private set; } + + internal TaskCompletionSource TwoCommandsDispatched { get; } = new( + TaskCreationOptions.RunContinuationsAsynchronously); + + internal List WriteAttempts { get; } = []; + + public bool HasExited => Volatile.Read(ref _hasExited) != 0; + + internal static TruncatedBlockProcess ForAttach() + { + var process = new TruncatedBlockProcess(); + process._output.Writer.TryWrite("%begin 1 1 0"); + process._output.Writer.TryWrite("partial attach output"); + process.CompleteOutput(); + return process; + } + + internal static TruncatedBlockProcess ForCommands() + { + var process = new TruncatedBlockProcess(); + process._output.Writer.TryWrite("%begin 1 1 0"); + process._output.Writer.TryWrite("%end 1 1 0"); + return process; + } + + internal static TruncatedBlockProcess ForAttachError() + { + var process = new TruncatedBlockProcess(); + process._output.Writer.TryWrite("%begin 1 1 0"); + process._output.Writer.TryWrite("can't find pane: missing"); + process._output.Writer.TryWrite("%error 1 1 0"); + return process; + } + + public Task WriteLineAsync( + ReadOnlyMemory command, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + WriteAttempts.Add(command.ToString()); + return Task.CompletedTask; + } + + public Task FlushAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + if (Interlocked.Increment(ref _flushCalls) == 2) + { + TwoCommandsDispatched.TrySetResult(); + } + + return Task.CompletedTask; + } + + public async Task ReadLineAsync() + { + try + { + return await _output.Reader.ReadAsync().ConfigureAwait(false); + } + catch (ChannelClosedException) + { + return null; + } + } + + public void CloseInput() => CompleteOutput(); + + public void Kill() => CompleteOutput(); + + public Task WaitForExitAsync(CancellationToken cancellationToken = default) => + _exited.Task.WaitAsync(cancellationToken); + + public void Dispose() => DisposeCalled = true; + + internal void EndCommandBlockEarly() + { + _output.Writer.TryWrite("%begin 2 2 0"); + _output.Writer.TryWrite("partial command output"); + CompleteOutput(); + } + + private void CompleteOutput() + { + Volatile.Write(ref _hasExited, 1); + _output.Writer.TryComplete(); + _exited.TrySetResult(); + } + } + + private sealed class BurstOutputProcess : IControlModeProcess + { + private readonly Channel _output = Channel.CreateUnbounded( + new UnboundedChannelOptions + { + SingleReader = true, + SingleWriter = true, + }); + private readonly TaskCompletionSource _exited = new( + TaskCreationOptions.RunContinuationsAsynchronously); + private readonly int _notificationCount; + private int _hasExited; + + internal BurstOutputProcess(int notificationCount) + { + _notificationCount = notificationCount; + _output.Writer.TryWrite("%begin 1 1 0"); + _output.Writer.TryWrite("%end 1 1 0"); + } + + internal bool DisposeCalled { get; private set; } + + public bool HasExited => Volatile.Read(ref _hasExited) != 0; + + public Task WriteLineAsync( + ReadOnlyMemory command, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.CompletedTask; + } + + public Task FlushAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + int replyAfter = ControlModeSession.EventBufferCapacity + 17; + for (int index = 0; index < _notificationCount; index++) + { + if (index == replyAfter) + { + QueueReply(); + } + + _output.Writer.TryWrite( + "%burst " + index.ToString(CultureInfo.InvariantCulture)); + } + + _output.Writer.TryWrite("%exit done"); + CompleteOutput(); + return Task.CompletedTask; + } + + public async Task ReadLineAsync() + { + try + { + return await _output.Reader.ReadAsync().ConfigureAwait(false); + } + catch (ChannelClosedException) + { + return null; + } + } + + public void CloseInput() => CompleteOutput(); + + public void Kill() => CompleteOutput(); + + public Task WaitForExitAsync(CancellationToken cancellationToken = default) => + _exited.Task.WaitAsync(cancellationToken); + + public void Dispose() => DisposeCalled = true; + + private void QueueReply() + { + _output.Writer.TryWrite("%begin 2 2 0"); + _output.Writer.TryWrite("reply-ok"); + _output.Writer.TryWrite("%end 2 2 0"); + } + + private void CompleteOutput() + { + Volatile.Write(ref _hasExited, 1); + _output.Writer.TryComplete(); + _exited.TrySetResult(); + } + } + + private sealed class FaultedPumpProcess( + Exception pumpFailure, + Exception? disposeFailure = null) : IControlModeProcess + { + internal bool DisposeCalled { get; private set; } + + public bool HasExited => true; + + public Task WriteLineAsync( + ReadOnlyMemory command, + CancellationToken cancellationToken) => + throw new InvalidOperationException("No command should be written."); + + public Task FlushAsync(CancellationToken cancellationToken) => + throw new InvalidOperationException("No command should be flushed."); + + public Task ReadLineAsync() => Task.FromException(pumpFailure); + + public void CloseInput() => throw new InvalidOperationException( + "An exited process should not have its input closed."); + + public void Kill() => throw new InvalidOperationException( + "An exited process should not be killed."); + + public Task WaitForExitAsync(CancellationToken cancellationToken = default) => + Task.CompletedTask; + + public void Dispose() + { + DisposeCalled = true; + if (disposeFailure is not null) + { + throw disposeFailure; + } + } + } + + private sealed class TerminalWhileRunningProcess : IControlModeProcess + { + private readonly Channel _output = Channel.CreateUnbounded( + new UnboundedChannelOptions + { + SingleReader = true, + SingleWriter = false, + }); + private readonly TaskCompletionSource _exited = new( + TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _terminalRead = new( + TaskCreationOptions.RunContinuationsAsynchronously); + private readonly bool _endDuringFinalCheck; + private int _hasExitedReads; + + internal TerminalWhileRunningProcess(bool endDuringFinalCheck = false) + { + _endDuringFinalCheck = endDuringFinalCheck; + _output.Writer.TryWrite("%begin 1 1 0"); + _output.Writer.TryWrite("%end 1 1 0"); + } + + internal bool DisposeCalled { get; private set; } + + internal List WriteAttempts { get; } = []; + + public bool HasExited + { + get + { + if (_endDuringFinalCheck && + Interlocked.Increment(ref _hasExitedReads) == 2) + { + EndOutput(); + _terminalRead.Task.GetAwaiter().GetResult(); + } + + return false; + } + } + + public Task WriteLineAsync( + ReadOnlyMemory command, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + WriteAttempts.Add(command.ToString()); + return Task.CompletedTask; + } + + public Task FlushAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.CompletedTask; + } + + public async Task ReadLineAsync() + { + try + { + return await _output.Reader.ReadAsync().ConfigureAwait(false); + } + catch (ChannelClosedException error) + { + _terminalRead.TrySetResult(); + if (error.InnerException is not null) + { + ExceptionDispatchInfo.Capture(error.InnerException).Throw(); + } + + return null; + } + } + + public void CloseInput() + { + _output.Writer.TryComplete(); + _exited.TrySetResult(); + } + + public void Kill() => CloseInput(); + + public Task WaitForExitAsync(CancellationToken cancellationToken = default) => + _exited.Task.WaitAsync(cancellationToken); + + public void Dispose() => DisposeCalled = true; + + internal void EndOutput(Exception? failure = null) => + _output.Writer.TryComplete(failure); + } + + private sealed class StalledWriteProcess : IControlModeProcess + { + private readonly Channel _output = Channel.CreateUnbounded( + new UnboundedChannelOptions + { + SingleReader = true, + SingleWriter = true, + }); + private readonly TaskCompletionSource _exited = new( + TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _releaseWrite = new( + TaskCreationOptions.RunContinuationsAsynchronously); + private int _hasExited; + + internal StalledWriteProcess() + { + _output.Writer.TryWrite("%begin 1 1 0"); + _output.Writer.TryWrite("%end 1 1 0"); + } + + internal bool DisposeCalled { get; private set; } + + internal bool KillCalled { get; private set; } + + internal TaskCompletionSource WriteStarted { get; } = new( + TaskCreationOptions.RunContinuationsAsynchronously); + + public bool HasExited => Volatile.Read(ref _hasExited) != 0; + + public async Task WriteLineAsync( + ReadOnlyMemory command, + CancellationToken cancellationToken) + { + WriteStarted.TrySetResult(); + await _releaseWrite.Task.ConfigureAwait(false); + 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 async Task ReadLineAsync() + { + try + { + return await _output.Reader.ReadAsync().ConfigureAwait(false); + } + catch (ChannelClosedException) + { + return null; + } + } + + public void CloseInput() => throw new InvalidOperationException( + "Forced disposal must kill rather than close behind an active writer."); + + public void Kill() + { + KillCalled = true; + Volatile.Write(ref _hasExited, 1); + _releaseWrite.TrySetResult(); + _output.Writer.TryComplete(); + _exited.TrySetResult(); + } + + public Task WaitForExitAsync(CancellationToken cancellationToken = default) => + _exited.Task.WaitAsync(cancellationToken); + + public void Dispose() => DisposeCalled = true; + } + + private sealed class AmbiguousDispatchProcess : IControlModeProcess + { + private readonly Channel _output = Channel.CreateUnbounded( + new UnboundedChannelOptions + { + SingleReader = true, + SingleWriter = true, + }); + private readonly TaskCompletionSource _allowFailure = new( + TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _exited = new( + TaskCreationOptions.RunContinuationsAsynchronously); + private readonly Exception _dispatchFailure; + private readonly DispatchFailurePoint _failurePoint; + private int _flushCalls; + private int _hasExited; + private int _writeCalls; + + internal AmbiguousDispatchProcess( + DispatchFailurePoint failurePoint, + Exception dispatchFailure) + { + _failurePoint = failurePoint; + _dispatchFailure = dispatchFailure; + QueueAttachReply(); + } + + internal string AmbiguousAcceptedText { get; private set; } = string.Empty; + + internal bool DisposeCalled { get; private set; } + + internal TaskCompletionSource FailureEntered { get; } = new( + TaskCreationOptions.RunContinuationsAsynchronously); + + internal TaskCompletionSource FirstDispatchCompleted { get; } = new( + TaskCreationOptions.RunContinuationsAsynchronously); + + internal bool InputClosed { get; private set; } + + internal List WriteAttempts { get; } = []; + + public bool HasExited => Volatile.Read(ref _hasExited) != 0; + + public async Task WriteLineAsync( + ReadOnlyMemory command, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + string text = command.ToString(); + WriteAttempts.Add(text); + int call = Interlocked.Increment(ref _writeCalls); + if (call != 2 || _failurePoint != DispatchFailurePoint.PartialWrite) + { + if (call == 2) + { + AmbiguousAcceptedText = text; + } + + return; + } + + AmbiguousAcceptedText = text[..Math.Min(8, text.Length)]; + FailureEntered.TrySetResult(); + await _allowFailure.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + throw _dispatchFailure; + } + + public async Task FlushAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + int call = Interlocked.Increment(ref _flushCalls); + if (call == 1) + { + FirstDispatchCompleted.TrySetResult(); + return; + } + + if (call == 2 && _failurePoint == DispatchFailurePoint.Flush) + { + FailureEntered.TrySetResult(); + await _allowFailure.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + throw _dispatchFailure; + } + } + + public async Task ReadLineAsync() + { + try + { + return await _output.Reader.ReadAsync().ConfigureAwait(false); + } + catch (ChannelClosedException) + { + return null; + } + } + + public void CloseInput() + { + InputClosed = true; + Volatile.Write(ref _hasExited, 1); + _output.Writer.TryComplete(); + _exited.TrySetResult(); + } + + public void Kill() => CloseInput(); + + public Task WaitForExitAsync(CancellationToken cancellationToken = default) => + _exited.Task.WaitAsync(cancellationToken); + + public void Dispose() => DisposeCalled = true; + + internal void ReleaseFailure() => _allowFailure.TrySetResult(); + + private void QueueAttachReply() + { + _output.Writer.TryWrite("%begin 1 1 0"); + _output.Writer.TryWrite("%end 1 1 0"); + } + } +} diff --git a/tests/LibTmux.UnitTests/ControlMode/PendingCommandOrderingTests.cs b/tests/LibTmux.UnitTests/ControlMode/PendingCommandOrderingTests.cs deleted file mode 100644 index e51d659..0000000 --- a/tests/LibTmux.UnitTests/ControlMode/PendingCommandOrderingTests.cs +++ /dev/null @@ -1,66 +0,0 @@ -namespace LibTmux.UnitTests.ControlMode; - -/// Proves a command tmux never saw does not take another command's answer. -/// -/// A waiter queues before its command is written; a failed write still leaves -/// a skipped slot, since tmux replies in order and the next reply must not -/// answer the wrong caller. -/// -public sealed class PendingCommandOrderingTests -{ - /// The rule the session applies when matching a reply to a waiter. - /// - /// A copy rather than the real queue: the session's own is private, and what - /// is worth pinning is the rule, which is small enough to state exactly. - /// - private static string? NextAnswered(Queue<(string Command, bool Abandoned)> pending) - { - while (pending.Count > 0) - { - (string command, bool abandoned) = pending.Dequeue(); - if (!abandoned) - { - return command; - } - } - - return null; - } - - [Fact] - public void A_reply_goes_to_the_command_that_was_actually_sent() - { - // The middle command's write failed, so tmux only ever heard the first - // and third. Two replies arrive. - Queue<(string, bool)> pending = new(); - pending.Enqueue(("first", false)); - pending.Enqueue(("cancelled", true)); - pending.Enqueue(("third", false)); - - Assert.Equal("first", NextAnswered(pending)); - Assert.Equal("third", NextAnswered(pending)); - Assert.Null(NextAnswered(pending)); - } - - [Fact] - public void Consecutive_abandoned_commands_are_all_skipped() - { - Queue<(string, bool)> pending = new(); - pending.Enqueue(("cancelled", true)); - pending.Enqueue(("also cancelled", true)); - pending.Enqueue(("sent", false)); - - Assert.Equal("sent", NextAnswered(pending)); - } - - [Fact] - public void A_queue_of_only_abandoned_commands_answers_nobody() - { - // Nothing reached tmux, so nothing is coming back. Handing this reply to - // a caller who was never sent is the bug being prevented. - Queue<(string, bool)> pending = new(); - pending.Enqueue(("cancelled", true)); - - Assert.Null(NextAnswered(pending)); - } -} diff --git a/tests/LibTmux.UnitTests/Entities/CompositeMutationDispatchTests.cs b/tests/LibTmux.UnitTests/Entities/CompositeMutationDispatchTests.cs new file mode 100644 index 0000000..90744a3 --- /dev/null +++ b/tests/LibTmux.UnitTests/Entities/CompositeMutationDispatchTests.cs @@ -0,0 +1,584 @@ +using System.Collections.Concurrent; +using System.Runtime.Versioning; +using System.Text; +using LibTmux.Internal; + +namespace LibTmux.UnitTests.Entities; + +[UnsupportedOSPlatform("windows")] +public sealed class CompositeMutationDispatchTests +{ + private static readonly ServerGeneration Generation = new(92, 902); + + [Fact] + 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)) + { + throw NotDispatched(arguments, "refresh was not dispatched"); + } + + return Task.FromResult(Success(request)); + }); + + LibTmuxException failure = await Assert.ThrowsAsync(() => + window.SelectLayoutAsync( + new SelectLayoutRequest("tiled"), + TestContext.Current.CancellationToken)); + + AssertPartialFailure(failure, typeof(TmuxTransportException)); + } + + [Fact] + public async Task Layout_cancellation_is_unknown_after_the_layout_changed() + { + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource( + TestContext.Current.CancellationToken); + Window window = CreateWindow((request, cancellationToken) => + { + cancellationToken.ThrowIfCancellationRequested(); + string[] arguments = [.. request.LogicalArguments]; + if (arguments.Contains("select-layout", StringComparer.Ordinal)) + { + cancellation.Cancel(); + } + + return Task.FromResult(Success(request)); + }); + + LibTmuxException failure = await Assert.ThrowsAsync(() => + window.SelectLayoutAsync(new SelectLayoutRequest("tiled"), cancellation.Token)); + + AssertPartialFailure(failure, typeof(OperationCanceledException)); + } + + [Fact] + public async Task Layout_first_failure_keeps_not_dispatched() + { + Window window = CreateWindow((request, _) => + { + string[] arguments = [.. request.LogicalArguments]; + throw NotDispatched(arguments, "layout was not dispatched"); + }); + + TmuxTransportException failure = await Assert.ThrowsAsync(() => + window.SelectLayoutAsync( + new SelectLayoutRequest("tiled"), + TestContext.Current.CancellationToken)); + + Assert.Equal(TmuxDispatchState.NotDispatched, failure.Dispatch); + Assert.Equal("layout was not dispatched", failure.Message); + } + + [Fact] + public async Task Reset_second_mutation_failure_is_unknown() + { + int mutations = 0; + Pane pane = CreatePane((request, _) => + { + string[] arguments = [.. request.LogicalArguments]; + if (arguments.Contains("send-keys", StringComparer.Ordinal) + || arguments.Contains("clear-history", StringComparer.Ordinal)) + { + if (Interlocked.Increment(ref mutations) == 2) + { + throw NotDispatched(arguments, "clear was not dispatched"); + } + } + + return Task.FromResult(Success(request)); + }); + + LibTmuxException failure = await Assert.ThrowsAsync(() => + pane.ResetAsync(TestContext.Current.CancellationToken)); + + AssertPartialFailure(failure, typeof(TmuxTransportException)); + Assert.Equal(2, Volatile.Read(ref mutations)); + } + + [Fact] + public async Task Appended_option_readback_failure_is_unknown() + { + Server server = CreateServer((request, _) => + { + string[] arguments = [.. request.LogicalArguments]; + if (arguments.Contains("show-options", StringComparer.Ordinal)) + { + throw NotDispatched(arguments, "option readback was not dispatched"); + } + + return Task.FromResult(Success(request)); + }); + + LibTmuxException failure = await Assert.ThrowsAsync(() => + server.Options.SetAsync( + new SetOptionRequest("status-left", "next", append: true), + TestContext.Current.CancellationToken)); + + AssertPartialFailure(failure, typeof(TmuxTransportException)); + } + + [Fact] + public async Task Multi_hook_second_mutation_failure_is_unknown() + { + int mutations = 0; + Server server = CreateServer((request, _) => + { + string[] arguments = [.. request.LogicalArguments]; + if (arguments.Contains("set-hook", StringComparer.Ordinal) + && Interlocked.Increment(ref mutations) == 2) + { + throw NotDispatched(arguments, "second hook was not dispatched"); + } + + return Task.FromResult(Success(request)); + }); + + LibTmuxException failure = await Assert.ThrowsAsync(() => + server.Hooks.SetAsync( + new SetHooksRequest( + "after-new-session", + new Dictionary + { + [0] = "display-message first", + [1] = "display-message second", + }), + TestContext.Current.CancellationToken)); + + AssertPartialFailure(failure, typeof(TmuxTransportException)); + Assert.Equal(2, Volatile.Read(ref mutations)); + } + + [Fact] + public async Task Replaced_session_listing_failure_is_unknown_after_creation() + { + var commands = new ConcurrentQueue(); + Server server = CreateServer((request, _) => + { + string[] arguments = [.. request.LogicalArguments]; + string command = ActualCommand(arguments); + commands.Enqueue(command); + return command switch + { + "has-session" => Task.FromResult(Success(request)), + "kill-session" => Task.FromResult(Success(request)), + "new-session" => Task.FromResult(Success(request, "$2\n")), + "display-message" => Task.FromResult(Success( + request, + $"{Generation.ProcessId}:{Generation.StartTime}\n")), + "-V" => Task.FromResult(Success(request, "tmux 3.7\n")), + "list-sessions" => throw NotDispatched( + arguments, + "session listing was not dispatched"), + _ => Task.FromResult(Success(request)), + }; + }); + + LibTmuxException failure = await Assert.ThrowsAsync(() => + server.CreateSessionAsync( + new NewSessionRequest("replace-me", replaceExisting: true), + TestContext.Current.CancellationToken)); + + AssertPartialFailure(failure, typeof(TmuxTransportException)); + Assert.Equal( + [ + "has-session", + "kill-session", + "new-session", + "display-message", + "-V", + "list-sessions", + ], + commands.ToArray()); + } + + [Fact] + public async Task Malformed_created_identifier_is_unknown_after_creation() + { + Server server = CreateServer((request, _) => + Task.FromResult(Success(request, "not-a-session-id\n"))); + + LibTmuxException failure = await Assert.ThrowsAsync(() => + server.CreateSessionAsync( + new NewSessionRequest("created"), + TestContext.Current.CancellationToken)); + + AssertPartialFailure(failure, typeof(InvalidDataException)); + } + + [Fact] + public async Task Select_existing_returns_the_expanded_name_match_when_detached() + { + var requests = new ConcurrentQueue(); + TmuxVersion floor = TmuxVersion.Parse("3.2a"); + Session session = CreateSession((request, _) => + { + string[] arguments = [.. request.LogicalArguments]; + requests.Enqueue(arguments); + string command = ActualCommand(arguments); + return command switch + { + "display-message" => Task.FromResult(Success(request, "-team-x\n")), + "new-window" => Task.FromResult(Success(request)), + "list-windows" => Task.FromResult(Success( + request, + WindowListing( + floor, + Generation, + ("@1", "active", true), + ("@2", "-team-x", false), + ("@3", "-#{session_name}-x", false)))), + _ => throw new InvalidOperationException($"Unexpected command '{command}'."), + }; + }, "tmux 3.2a"); + + Window selected = await session.CreateWindowAsync( + new NewWindowRequest("-#{session_name}-x", selectExisting: true), + TestContext.Current.CancellationToken); + + Assert.Equal(WindowId.Parse("@2"), selected.Id); + Assert.Equal("-team-x", selected.Name); + string[] expansion = requests.Single(arguments => + ActualCommand(arguments) == "display-message"); + Assert.Equal( + ["display-message", "-p", "-t", "$1", "--", "-#{session_name}-x"], + expansion[^6..]); + string[] create = requests.Single(arguments => + ActualCommand(arguments) == "new-window"); + Assert.Contains("-d", create); + Assert.Contains("-S", create); + Assert.Contains("$1:", create); + } + + [Fact] + public async Task Window_scoped_create_does_not_treat_empty_output_as_selected_active() + { + Window window = CreateWindow((request, _) => Task.FromResult(Success(request))); + + LibTmuxException failure = await Assert.ThrowsAsync(() => + window.CreateWindowAsync( + new NewWindowRequest("wanted", selectExisting: true), + TestContext.Current.CancellationToken)); + + AssertPartialFailure(failure, typeof(InvalidDataException)); + } + + [Fact] + public async Task Environment_readback_command_failure_is_unknown_after_set() + { + Server server = CreateServer((request, _) => + { + string command = ActualCommand([.. request.LogicalArguments]); + return command == "show-environment" + ? Task.FromResult(Failure(request, 2, "permission denied\n")) + : Task.FromResult(Success(request)); + }); + + LibTmuxException failure = await Assert.ThrowsAsync(() => + server.Environment.SetAsync( + "VISIBLE", + "value", + cancellationToken: TestContext.Current.CancellationToken)); + + AssertPartialFailure(failure, typeof(TmuxCommandException)); + } + + [Fact] + public async Task Environment_readback_stderr_is_unknown_even_with_zero_exit() + { + Server server = CreateServer((request, _) => + { + string command = ActualCommand([.. request.LogicalArguments]); + return command == "show-environment" + ? Task.FromResult(Failure(request, 0, "readback warning\n")) + : Task.FromResult(Success(request)); + }); + + LibTmuxException failure = await Assert.ThrowsAsync(() => + server.Environment.SetAsync( + "HIDDEN", + "value", + hidden: true, + cancellationToken: TestContext.Current.CancellationToken)); + + AssertPartialFailure(failure, typeof(TmuxCommandException)); + } + + [Fact] + public async Task Visible_environment_missing_after_set_is_unknown() + { + Server server = CreateServer((request, _) => + { + string command = ActualCommand([.. request.LogicalArguments]); + return command == "show-environment" + ? Task.FromResult(Failure(request, 1, "unknown variable: VISIBLE\n")) + : Task.FromResult(Success(request)); + }); + + LibTmuxException failure = await Assert.ThrowsAsync(() => + server.Environment.SetAsync( + "VISIBLE", + "value", + cancellationToken: TestContext.Current.CancellationToken)); + + AssertPartialFailure(failure, typeof(InvalidDataException)); + } + + [Fact] + public async Task Exact_missing_environment_result_remains_an_absence_answer() + { + Server server = CreateServer((request, _) => Task.FromResult( + Failure(request, 1, "unknown variable: MISSING\n"))); + + TmuxEnvironmentEntry? entry = await server.Environment.GetAsync( + "MISSING", + TestContext.Current.CancellationToken); + + Assert.Null(entry); + } + + [Fact] + public async Task Session_create_reuses_unchanged_generation_without_reinitializing() + { + int initializations = 0; + Server server = CreateServer( + SessionCreationExecutor(Generation), + (_, _) => + { + Interlocked.Increment(ref initializations); + return ValueTask.CompletedTask; + }); + + Session created = await server.CreateSessionAsync( + new NewSessionRequest("created"), + TestContext.Current.CancellationToken); + + Assert.Equal(Generation, created.Generation); + Assert.Equal(0, Volatile.Read(ref initializations)); + } + + [Fact] + public async Task Session_create_rediscovers_changed_generation_and_reinitializes() + { + var changed = new ServerGeneration(93, 903); + int initializations = 0; + Server server = CreateServer( + SessionCreationExecutor(changed), + (_, _) => + { + Interlocked.Increment(ref initializations); + return ValueTask.CompletedTask; + }); + + Session created = await server.CreateSessionAsync( + new NewSessionRequest("created"), + TestContext.Current.CancellationToken); + + Assert.Equal(changed, created.Generation); + Assert.Equal(1, Volatile.Read(ref initializations)); + } + + private static void AssertPartialFailure(LibTmuxException failure, Type innerType) + { + Assert.Equal(TmuxDispatchState.Unknown, failure.Dispatch); + Assert.Equal(TmuxMutationSequence.PartialFailureMessage, failure.Message); + Assert.IsType(innerType, failure.InnerException); + if (failure.InnerException is TmuxTransportException inner) + { + Assert.Equal(TmuxDispatchState.NotDispatched, inner.Dispatch); + } + } + + private static Pane CreatePane( + Func> execute) + { + var connection = CreateConnection(execute); + return new Pane(connection, Generation, new PaneId(1)); + } + + private static Window CreateWindow( + Func> execute) + { + TmuxConnection connection = CreateConnection(execute); + var server = new Server(connection, Generation, "tmux 3.7"); + return new Window( + server, + connection, + Generation, + new WindowId(1), + new Dictionary + { + ["session_id"] = "$1", + ["window_id"] = "@1", + }); + } + + private static Session CreateSession( + Func> execute, + string rawVersion = "tmux 3.7") + { + TmuxConnection connection = CreateConnection(execute); + var server = new Server(connection, Generation, rawVersion); + return new Session( + server, + connection, + Generation, + new SessionId(1), + new Dictionary + { + ["session_id"] = "$1", + ["session_name"] = "team", + }); + } + + private static Server CreateServer( + Func> execute, + Func? initializeAsync = null) + { + TmuxConnection connection = CreateConnection(execute, initializeAsync); + return new Server(connection, Generation, "tmux 3.7"); + } + + private static TmuxConnection CreateConnection( + Func> execute, + Func? initializeAsync = null) => + new( + new ServerConnectionOptions( + socketName: "composite-mutation-test", + initializeAsync: initializeAsync), + execute, + implementation: TmuxImplementation.Tmux); + + private static TmuxTransportException NotDispatched( + IReadOnlyList arguments, + string message) => + new(message, arguments, TmuxDispatchState.NotDispatched); + + private static string ActualCommand(string[] arguments) => + 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]; + + private static TmuxCommandResult Success( + TmuxCommandRequest request, + string payload = "", + ServerGeneration? generation = null) + { + string[] arguments = [.. request.LogicalArguments]; + bool guarded = arguments.Contains("if-shell", StringComparer.Ordinal); + ServerGeneration effectiveGeneration = generation ?? Generation; + string output = guarded + ? $"{effectiveGeneration.ProcessId}:{effectiveGeneration.StartTime}\n{payload}" + : payload; + byte[] bytes = Encoding.UTF8.GetBytes(output); + return new TmuxCommandResult( + arguments, + 0, + bytes, + ReadOnlyMemory.Empty, + Utf8BackslashDecoder.ProjectOutputLines(bytes), + []); + } + + private static TmuxCommandResult Failure( + TmuxCommandRequest request, + int exitCode, + string standardError) + { + string[] arguments = [.. request.LogicalArguments]; + byte[] error = Encoding.UTF8.GetBytes(standardError); + return new TmuxCommandResult( + arguments, + exitCode, + ReadOnlyMemory.Empty, + error, + [], + Utf8BackslashDecoder.ProjectErrorLines(error)); + } + + private static Func> + SessionCreationExecutor(ServerGeneration discovered) => + (request, _) => + { + string[] arguments = [.. request.LogicalArguments]; + string command = ActualCommand(arguments); + return command switch + { + "new-session" => Task.FromResult(Success(request, "$2\n")), + "display-message" => Task.FromResult(Success( + request, + $"{discovered.ProcessId}:{discovered.StartTime}\n")), + "-V" => Task.FromResult(Success(request, "tmux 3.7\n")), + "list-sessions" => Task.FromResult(Success( + request, + SessionListing(discovered, "$2", "created"), + discovered)), + _ => throw new InvalidOperationException($"Unexpected command '{command}'."), + }; + }; + + private static string SessionListing( + ServerGeneration generation, + string id, + string name) => + FramedListing( + "list-sessions", + TmuxVersion.Parse("3.7"), + generation, + new Dictionary(StringComparer.Ordinal) + { + ["session_id"] = id, + ["session_name"] = name, + }); + + private static string WindowListing( + TmuxVersion version, + ServerGeneration generation, + params (string Id, string Name, bool Active)[] windows) => + FramedListing( + "list-windows", + version, + generation, + [.. windows.Select((window, index) => + (IReadOnlyDictionary)new Dictionary( + StringComparer.Ordinal) + { + ["session_id"] = "$1", + ["window_id"] = window.Id, + ["window_name"] = window.Name, + ["window_index"] = index.ToString( + System.Globalization.CultureInfo.InvariantCulture), + ["window_active"] = window.Active ? "1" : "0", + })]); + + private static string FramedListing( + string command, + TmuxVersion version, + ServerGeneration generation, + params IReadOnlyDictionary[] rows) + { + FormatProjection projection = FormatProjection.Create(command, version); + return string.Concat(rows.Select(row => + string.Concat(projection.Fields.Select(field => + FieldValue(field.WireName, generation, row) + FormatProjection.RowSeparator)) + + "\n")); + } + + private static string FieldValue( + string field, + ServerGeneration generation, + IReadOnlyDictionary row) => + field switch + { + "pid" => generation.ProcessId.ToString( + System.Globalization.CultureInfo.InvariantCulture), + "start_time" => generation.StartTime.ToString( + System.Globalization.CultureInfo.InvariantCulture), + _ => row.TryGetValue(field, out string? value) ? value : string.Empty, + }; +} diff --git a/tests/LibTmux.UnitTests/Entities/PaneSendKeysDispatchTests.cs b/tests/LibTmux.UnitTests/Entities/PaneSendKeysDispatchTests.cs new file mode 100644 index 0000000..89acda6 --- /dev/null +++ b/tests/LibTmux.UnitTests/Entities/PaneSendKeysDispatchTests.cs @@ -0,0 +1,145 @@ +using System.Collections.Concurrent; +using System.Runtime.Versioning; +using System.Text; +using LibTmux.Internal; + +namespace LibTmux.UnitTests.Entities; + +[UnsupportedOSPlatform("windows")] +public sealed class PaneSendKeysDispatchTests +{ + private static readonly ServerGeneration Generation = new(91, 901); + + [Fact] + public async Task Enter_not_dispatched_after_text_is_reported_as_unknown() + { + var dispatched = new ConcurrentQueue(); + var enterFailure = new TmuxTransportException( + "Enter was not dispatched.", + ["send-keys", "-t", "%1", "Enter"], + TmuxDispatchState.NotDispatched); + Pane pane = CreatePane((request, _) => + { + string[] arguments = [.. request.LogicalArguments]; + if (arguments.Contains("send-keys", StringComparer.Ordinal)) + { + dispatched.Enqueue(arguments); + if (dispatched.Count == 2) + { + throw enterFailure; + } + } + + return Task.FromResult(Success(arguments)); + }); + + LibTmuxException failure = await Assert.ThrowsAsync(() => + pane.SendKeysAsync( + new SendKeysRequest(text: "payload", enter: true, literal: true), + TestContext.Current.CancellationToken)); + + Assert.Equal(TmuxDispatchState.Unknown, failure.Dispatch); + TmuxTransportException inner = Assert.IsType( + failure.InnerException); + Assert.Equal(TmuxDispatchState.NotDispatched, inner.Dispatch); + Assert.Contains("text was sent", failure.Message, StringComparison.Ordinal); + Assert.Contains("do not retry", failure.Message, StringComparison.Ordinal); + Assert.Equal(2, dispatched.Count); + Assert.Equal("Enter", dispatched.Last()[^1]); + } + + [Fact] + public async Task Cancellation_between_text_and_enter_is_reported_as_unknown() + { + using CancellationTokenSource cancellation = CancellationTokenSource.CreateLinkedTokenSource( + TestContext.Current.CancellationToken); + int sendStages = 0; + OperationCanceledException? enterFailure = null; + Pane pane = CreatePane(async (request, cancellationToken) => + { + string[] arguments = [.. request.LogicalArguments]; + if (arguments.Contains("send-keys", StringComparer.Ordinal)) + { + int stage = Interlocked.Increment(ref sendStages); + if (stage == 1) + { + await cancellation.CancelAsync(); + } + else + { + try + { + cancellationToken.ThrowIfCancellationRequested(); + } + catch (OperationCanceledException error) + { + enterFailure = error; + throw; + } + } + } + + return Success(arguments); + }); + + LibTmuxException failure = await Assert.ThrowsAsync(() => + pane.SendKeysAsync( + new SendKeysRequest(text: "payload", enter: true, literal: true), + cancellation.Token)); + + Assert.Equal(TmuxDispatchState.Unknown, failure.Dispatch); + Assert.Same(enterFailure, failure.InnerException); + Assert.Equal(2, Volatile.Read(ref sendStages)); + } + + [Fact] + public async Task Text_stage_failure_keeps_its_not_dispatched_state() + { + int sendStages = 0; + Pane pane = CreatePane((request, _) => + { + string[] arguments = [.. request.LogicalArguments]; + if (arguments.Contains("send-keys", StringComparer.Ordinal)) + { + Interlocked.Increment(ref sendStages); + throw new TmuxTransportException( + "Text was not dispatched.", + arguments, + TmuxDispatchState.NotDispatched); + } + + return Task.FromResult(Success(arguments)); + }); + + TmuxTransportException failure = await Assert.ThrowsAsync(() => + pane.SendKeysAsync( + new SendKeysRequest(text: "payload", enter: true, literal: true), + TestContext.Current.CancellationToken)); + + Assert.Equal(TmuxDispatchState.NotDispatched, failure.Dispatch); + Assert.Equal(1, Volatile.Read(ref sendStages)); + } + + private static Pane CreatePane( + Func> execute) + { + var connection = new TmuxConnection( + new ServerConnectionOptions(socketName: "send-keys-dispatch-test"), + execute, + implementation: TmuxImplementation.Tmux); + return new Pane(connection, Generation, new PaneId(1)); + } + + private static TmuxCommandResult Success(IReadOnlyList arguments) + { + byte[] output = Encoding.UTF8.GetBytes( + $"{Generation.ProcessId}:{Generation.StartTime}\n"); + return new TmuxCommandResult( + arguments, + 0, + output, + ReadOnlyMemory.Empty, + Utf8BackslashDecoder.ProjectOutputLines(output), + []); + } +} diff --git a/tests/LibTmux.UnitTests/Environment/TmuxEnvironmentTests.cs b/tests/LibTmux.UnitTests/Environment/TmuxEnvironmentTests.cs index 1cbd3e4..5417b57 100644 --- a/tests/LibTmux.UnitTests/Environment/TmuxEnvironmentTests.cs +++ b/tests/LibTmux.UnitTests/Environment/TmuxEnvironmentTests.cs @@ -72,4 +72,90 @@ public void Resolving_a_server_uses_only_the_socket_path() Assert.Equal("/tmp/tmux-1000/default", server.ConnectionOptions.SocketPath); Assert.False(server.IsMaterialized); } + + [Fact] + public void Resolving_psmux_fails_closed_before_selecting_a_path_executable() + { + const string RawTmux = "/tmp/psmux-4242/team,53123,0"; + TmuxObjectNotFoundException error = Assert.Throws( + () => Server.FromEnvironment( + Env(("TMUX", RawTmux), ("PSMUX_SESSION", "work")))); + + Assert.Equal("TMUX", error.Target); + Assert.Contains("explicit connection options", error.Message, StringComparison.Ordinal); + } + + [Fact] + public void Resolving_default_psmux_fails_closed_on_ambiguous_routing() + { + TmuxObjectNotFoundException error = Assert.Throws( + () => Server.FromEnvironment( + Env( + ("TMUX", "/tmp/psmux-4242/default,53123,0"), + ("PSMUX_SESSION", "work")))); + + Assert.Equal("TMUX", error.Target); + } + + [Fact] + public void A_psmux_marker_with_an_unverified_tmux_value_fails_closed() + { + TmuxObjectNotFoundException error = Assert.Throws( + () => Server.FromEnvironment( + Env( + ("TMUX", "/tmp/tmux-1000/default,4242,3"), + ("PSMUX_SESSION", "work")))); + + Assert.Equal("TMUX", error.Target); + } + + [Fact] + public void An_empty_psmux_marker_still_fails_closed() + { + TmuxObjectNotFoundException error = Assert.Throws( + () => Server.FromEnvironment( + Env( + ("TMUX", "/tmp/psmux-4242/team,53123,0"), + ("PSMUX_SESSION", string.Empty)))); + + Assert.Equal("TMUX", error.Target); + } + + [Fact] + public void A_psmux_shaped_server_without_its_marker_fails_closed() + { + TmuxObjectNotFoundException error = Assert.Throws( + () => Server.FromEnvironment( + Env(("TMUX", "/tmp/psmux-4242/team,53123,0")))); + + Assert.Equal("TMUX", error.Target); + } + + [Theory] + [InlineData("tmux", "psmux_session")] + [InlineData("TmUx", "PsMuX_SeSsIoN")] + public void Psmux_environment_detection_is_case_insensitive( + string tmuxVariable, + string psmuxVariable) + { + TmuxObjectNotFoundException error = Assert.Throws( + () => Server.FromEnvironment( + Env( + (tmuxVariable, "/tmp/psmux-4242/team,53123,0"), + (psmuxVariable, "work")))); + + Assert.Equal("TMUX", error.Target); + } + + [Fact] + public void Lowercase_psmux_marker_rejects_an_otherwise_regular_tmux_environment() + { + TmuxObjectNotFoundException error = Assert.Throws( + () => Server.FromEnvironment( + Env( + ("TMUX", "/tmp/tmux-1000/default,4242,3"), + ("psmux_session", "work")))); + + Assert.Equal("TMUX", error.Target); + } } diff --git a/tests/LibTmux.UnitTests/Mcp/BoundedMcpTaskStoreTests.cs b/tests/LibTmux.UnitTests/Mcp/BoundedMcpTaskStoreTests.cs new file mode 100644 index 0000000..4adc58b --- /dev/null +++ b/tests/LibTmux.UnitTests/Mcp/BoundedMcpTaskStoreTests.cs @@ -0,0 +1,219 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using LibTmux.Mcp; +using ModelContextProtocol; +using ModelContextProtocol.Extensions.Tasks; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +#pragma warning disable MCPEXP002 + +namespace LibTmux.UnitTests.Mcp; + +public sealed class BoundedMcpTaskStoreTests +{ + private static readonly JsonElement EmptyResult = JsonSerializer.SerializeToElement(new { }); + + [Fact] + public async Task Active_admission_is_atomic_under_a_flood() + { + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + var store = new BoundedMcpTaskStore( + maximumActiveTasks: 3, + maximumRetainedTasks: 100); + + Task[] attempts = Enumerable.Range(0, 100) + .Select(_ => Task.Run(async () => + { + try + { + return await store.CreateTaskAsync(cancellationToken: cancellationToken); + } + catch (McpProtocolException) + { + return null; + } + })) + .ToArray(); + + McpTaskInfo[] admitted = (await Task.WhenAll(attempts)) + .OfType() + .ToArray(); + + Assert.Equal(3, admitted.Length); + foreach (McpTaskInfo task in admitted) + { + await store.SetCompletedAsync(task.TaskId, EmptyResult, cancellationToken); + } + } + + [Fact] + public async Task Client_cancellation_retains_admission_until_background_finalizes() + { + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + var clock = new ManualTimeProvider(); + var store = new BoundedMcpTaskStore( + maximumActiveTasks: 1, + maximumRetainedTasks: 4, + timeToLive: TimeSpan.FromMinutes(1), + timeProvider: clock); + McpTaskInfo task = await store.CreateTaskAsync(cancellationToken: cancellationToken); + + using (store.EnterClientCancellation(task.TaskId)) + { + Assert.True(await store.SetCancelledAsync(task.TaskId, cancellationToken)); + } + + using (store.EnterClientCancellation(task.TaskId)) + { + Assert.False(await store.SetCancelledAsync(task.TaskId, cancellationToken)); + } + + Assert.Equal( + McpTaskStatus.Cancelled, + (await store.GetTaskAsync(task.TaskId, cancellationToken))?.Status); + McpProtocolException busy = await Assert.ThrowsAsync( + () => store.CreateTaskAsync(cancellationToken: cancellationToken)); + Assert.Contains("wait for it to stop", busy.Message, StringComparison.Ordinal); + + clock.Advance(TimeSpan.FromHours(1)); + Assert.NotNull(await store.GetTaskAsync(task.TaskId, cancellationToken)); + Assert.False(await store.SetCancelledAsync(task.TaskId, cancellationToken)); + + McpTaskInfo replacement = await store.CreateTaskAsync( + cancellationToken: cancellationToken); + Assert.NotNull(replacement); + Assert.Null(await store.GetTaskAsync(task.TaskId, cancellationToken)); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Background_completion_releases_a_cancelled_execution(bool completed) + { + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + var store = new BoundedMcpTaskStore( + maximumActiveTasks: 1, + maximumRetainedTasks: 4); + McpTaskInfo task = await store.CreateTaskAsync(cancellationToken: cancellationToken); + using (store.EnterClientCancellation(task.TaskId)) + { + _ = await store.SetCancelledAsync(task.TaskId, cancellationToken); + } + + if (completed) + { + await store.SetCompletedAsync(task.TaskId, EmptyResult, cancellationToken); + } + else + { + await store.SetFailedAsync(task.TaskId, EmptyResult, cancellationToken); + } + + Assert.Equal( + McpTaskStatus.Cancelled, + (await store.GetTaskAsync(task.TaskId, cancellationToken))?.Status); + Assert.NotNull(await store.CreateTaskAsync(cancellationToken: cancellationToken)); + } + + [Fact] + public async Task Retained_capacity_reclaims_only_after_the_advertised_ttl() + { + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + var clock = new ManualTimeProvider(); + var store = new BoundedMcpTaskStore( + maximumActiveTasks: 2, + maximumRetainedTasks: 2, + timeToLive: TimeSpan.FromMinutes(5), + timeProvider: clock); + McpTaskInfo first = await store.CreateTaskAsync(cancellationToken: cancellationToken); + McpTaskInfo second = await store.CreateTaskAsync(cancellationToken: cancellationToken); + await store.SetCompletedAsync(first.TaskId, EmptyResult, cancellationToken); + await store.SetCompletedAsync(second.TaskId, EmptyResult, cancellationToken); + + McpProtocolException full = await Assert.ThrowsAsync( + () => store.CreateTaskAsync(cancellationToken: cancellationToken)); + Assert.Contains("after one expires", full.Message, StringComparison.Ordinal); + Assert.DoesNotContain("collect", full.Message, StringComparison.OrdinalIgnoreCase); + + clock.Advance(TimeSpan.FromMinutes(5)); + Assert.NotNull(await store.CreateTaskAsync(cancellationToken: cancellationToken)); + Assert.Null(await store.GetTaskAsync(first.TaskId, cancellationToken)); + Assert.Null(await store.GetTaskAsync(second.TaskId, cancellationToken)); + } + + [Fact] + public void Cancellation_wrapper_fails_closed_without_exactly_one_sdk_handler() + { + var wrapper = new BoundedMcpTaskCancellationOptions(new BoundedMcpTaskStore()); + var missing = new McpServerOptions { RequestHandlers = [] }; + var duplicate = new McpServerOptions + { + RequestHandlers = + [ + Handler("tasks/cancel"), + Handler("tasks/cancel"), + ], + }; + + Assert.Throws(() => wrapper.Configure(missing)); + Assert.Throws(() => wrapper.Configure(duplicate)); + } + + [Fact] + public async Task Composed_cancellation_handler_cannot_release_execution_admission() + { + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + var store = new BoundedMcpTaskStore( + maximumActiveTasks: 1, + maximumRetainedTasks: 4); + McpTaskInfo task = await store.CreateTaskAsync(cancellationToken: cancellationToken); + var options = new McpServerOptions + { + RequestHandlers = + [ + new McpServerRequestHandler + { + Method = "tasks/cancel", + Handler = async (request, cancellationToken) => + { + string taskId = request.Params!["taskId"]!.GetValue(); + _ = await store.SetCancelledAsync(taskId, cancellationToken); + return null; + }, + }, + ], + }; + new BoundedMcpTaskCancellationOptions(store).Configure(options); + var request = new JsonRpcRequest + { + Method = "tasks/cancel", + Params = new JsonObject { ["taskId"] = task.TaskId }, + }; + + await options.RequestHandlers![0].Handler(request, cancellationToken); + await options.RequestHandlers[0].Handler(request, cancellationToken); + + McpProtocolException busy = await Assert.ThrowsAsync( + () => store.CreateTaskAsync(cancellationToken: cancellationToken)); + Assert.Contains("active MCP tasks", busy.Message, StringComparison.Ordinal); + + Assert.False(await store.SetCancelledAsync(task.TaskId, cancellationToken)); + Assert.NotNull(await store.CreateTaskAsync(cancellationToken: cancellationToken)); + } + + private static McpServerRequestHandler Handler(string method) => new() + { + Method = method, + Handler = static (_, _) => ValueTask.FromResult(null), + }; + + private sealed class ManualTimeProvider : TimeProvider + { + private DateTimeOffset _now = new(2030, 1, 1, 0, 0, 0, TimeSpan.Zero); + + public override DateTimeOffset GetUtcNow() => _now; + + internal void Advance(TimeSpan duration) => _now += duration; + } +} diff --git a/tests/LibTmux.UnitTests/Mcp/HierarchyWatcherLifecycleTests.cs b/tests/LibTmux.UnitTests/Mcp/HierarchyWatcherLifecycleTests.cs new file mode 100644 index 0000000..186055d --- /dev/null +++ b/tests/LibTmux.UnitTests/Mcp/HierarchyWatcherLifecycleTests.cs @@ -0,0 +1,895 @@ +using System.Runtime.Versioning; +using System.Threading.Channels; +using LibTmux.Mcp; +using Microsoft.Extensions.Logging; + +namespace LibTmux.UnitTests.Mcp; + +[UnsupportedOSPlatform("windows")] +public sealed class HierarchyWatcherLifecycleTests +{ + // A leaked subscription would otherwise hang the run rather than name itself. + private static readonly TimeSpan UnsubscribeTimeout = TimeSpan.FromSeconds(30); + + [Fact] + public async Task Initial_recovery_invalidates_the_sole_subscriber_without_a_later_event() + { + CancellationToken token = TestContext.Current.CancellationToken; + var delay = new ControlledDelay(); + await using HierarchyWatcher watcher = new(null, delay.WaitAsync); + FakeControlModeSession recovered = new(); + TaskCompletionSource restarted = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource> told = new( + TaskCreationOptions.RunContinuationsAsynchronously); + int starts = 0; + + Task Start(CancellationToken _) + { + if (starts++ == 0) + { + return Task.FromException( + new LibTmuxException("expected start failure")); + } + + restarted.TrySetResult(); + return Task.FromResult(recovered); + } + + object subscriber = new(); + await watcher.SubscribeAsync( + "tmux://hierarchy", + subscriber, + changed => + { + told.TrySetResult(changed); + return Task.CompletedTask; + }, + Start, + token); + + Assert.Equal(TimeSpan.FromMilliseconds(100), await delay.Entered.Task.WaitAsync(token)); + Assert.Equal(1, starts); + delay.Release(); + await restarted.Task.WaitAsync(token); + Assert.Equal(["tmux://hierarchy"], await told.Task.WaitAsync(token)); + Assert.Equal(2, starts); + + await watcher.UnsubscribeAsync("tmux://hierarchy", subscriber); + await recovered.Disposed.Task.WaitAsync(token); + Assert.Equal(1, recovered.DisposeCalls); + } + + [Fact] + public async Task Stream_death_recovery_invalidates_every_resource_without_a_later_event() + { + CancellationToken token = TestContext.Current.CancellationToken; + var delay = new ControlledDelay(); + await using HierarchyWatcher watcher = new(null, delay.WaitAsync); + FakeControlModeSession first = new(); + FakeControlModeSession recovered = new(); + TaskCompletionSource restarted = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource> told = new( + TaskCreationOptions.RunContinuationsAsynchronously); + int starts = 0; + int deliveries = 0; + + Task Start(CancellationToken _) + { + IControlModeSession session = starts++ == 0 ? first : recovered; + if (starts == 2) + { + restarted.TrySetResult(); + } + + return Task.FromResult(session); + } + + object subscriber = new(); + await watcher.SubscribeAsync( + "tmux://sessions", + subscriber, + changed => + { + Interlocked.Increment(ref deliveries); + told.TrySetResult(changed); + return Task.CompletedTask; + }, + Start, + token); + await watcher.SubscribeAsync( + "tmux://hierarchy", + subscriber, + _ => Task.CompletedTask, + Start, + token); + + first.EndUnexpectedly(); + await first.Disposed.Task.WaitAsync(token); + Assert.Equal(TimeSpan.FromMilliseconds(100), await delay.Entered.Task.WaitAsync(token)); + Assert.Equal(1, starts); + delay.Release(); + await restarted.Task.WaitAsync(token); + + IReadOnlyList changed = await told.Task.WaitAsync(token); + Assert.Equal(2, changed.Count); + Assert.Equal(1, changed.Count(uri => uri == "tmux://sessions")); + Assert.Equal(1, changed.Count(uri => uri == "tmux://hierarchy")); + Assert.Equal(1, Volatile.Read(ref deliveries)); + Assert.Equal(2, starts); + + await watcher.UnsubscribeAsync("tmux://sessions", subscriber); + await watcher.UnsubscribeAsync("tmux://hierarchy", subscriber); + await recovered.Disposed.Task.WaitAsync(token); + Assert.Equal(1, first.DisposeCalls); + Assert.Equal(1, recovered.DisposeCalls); + } + + [Fact] + public async Task Stale_failed_recovery_cannot_rearm_after_a_new_live_run() + { + CancellationToken token = TestContext.Current.CancellationToken; + var delay = new ControlledDelay(); + var outcome = new ControlledBarrier(); + TaskCompletionSource outcomeObserved = new( + TaskCreationOptions.RunContinuationsAsynchronously); + await using HierarchyWatcher watcher = new( + null, + delay.WaitAsync, + outcome.WaitAsync, + pending => outcomeObserved.TrySetResult(pending)); + FakeControlModeSession first = new(); + FakeControlModeSession recovered = new(); + TaskCompletionSource> told = new( + TaskCreationOptions.RunContinuationsAsynchronously); + int starts = 0; + int deliveries = 0; + + Task Start(CancellationToken _) + { + int attempt = Interlocked.Increment(ref starts); + return attempt switch + { + 1 => Task.FromResult(first), + 2 => Task.FromException( + new LibTmuxException("expected recovery failure")), + 3 => Task.FromResult(recovered), + _ => Task.FromException( + new InvalidOperationException("The watcher started too many clients.")), + }; + } + + object subscriber = new(); + Func, Task> announce = changed => + { + Interlocked.Increment(ref deliveries); + told.TrySetResult(changed); + return Task.CompletedTask; + }; + await watcher.SubscribeAsync( + "tmux://hierarchy", + subscriber, + announce, + Start, + token); + + first.EndUnexpectedly(); + await first.Disposed.Task.WaitAsync(token); + await delay.Entered.Task.WaitAsync(token); + delay.Release(); + await outcome.Entered.Task.WaitAsync(token); + + await watcher.SubscribeAsync( + "tmux://sessions", + subscriber, + announce, + Start, + token); + IReadOnlyList recoveredResources = await told.Task.WaitAsync(token); + Assert.Equal(2, recoveredResources.Count); + Assert.Equal(1, recoveredResources.Count(uri => uri == "tmux://hierarchy")); + Assert.Equal(1, recoveredResources.Count(uri => uri == "tmux://sessions")); + Assert.Equal(1, Volatile.Read(ref deliveries)); + + outcome.Release(); + Assert.False(await outcomeObserved.Task.WaitAsync(token)); + await watcher.SubscribeAsync( + "tmux://servers", + subscriber, + announce, + Start, + token); + + Assert.Equal(1, Volatile.Read(ref deliveries)); + Assert.Equal(3, Volatile.Read(ref starts)); + + await watcher.UnsubscribeAsync("tmux://hierarchy", subscriber); + await watcher.UnsubscribeAsync("tmux://sessions", subscriber); + await watcher.UnsubscribeAsync("tmux://servers", subscriber); + await recovered.Disposed.Task.WaitAsync(token); + Assert.Equal(1, first.DisposeCalls); + Assert.Equal(1, recovered.DisposeCalls); + } + + [Fact] + public async Task Disposal_cancels_a_pending_recovery() + { + CancellationToken token = TestContext.Current.CancellationToken; + var delay = new ControlledDelay(); + HierarchyWatcher watcher = new(null, delay.WaitAsync); + int starts = 0; + + await watcher.SubscribeAsync( + "tmux://hierarchy", + new object(), + _ => Task.CompletedTask, + _ => + { + starts++; + return Task.FromException( + new LibTmuxException("expected start failure")); + }, + token); + + await delay.Entered.Task.WaitAsync(token); + await watcher.DisposeAsync().AsTask().WaitAsync(token); + + await delay.Cancelled.Task.WaitAsync(token); + Assert.Equal(1, starts); + } + + [Fact] + public async Task Concurrent_disposal_joins_endpoint_cleanup() + { + CancellationToken token = TestContext.Current.CancellationToken; + HierarchyWatcher watcher = new(); + FakeControlModeSession session = new(pauseDisposal: true); + + await watcher.SubscribeAsync( + "tmux://hierarchy", + new object(), + _ => Task.CompletedTask, + _ => Task.FromResult(session), + token); + + Task first = watcher.DisposeAsync().AsTask(); + await session.DisposeStarted.Task.WaitAsync(token); + Task second = watcher.DisposeAsync().AsTask(); + + Assert.False(first.IsCompleted); + Assert.False(second.IsCompleted); + session.AllowDisposal(); + await Task.WhenAll(first, second).WaitAsync(token); + Assert.Equal(1, session.DisposeCalls); + } + + [Fact] + public async Task Cancelled_duplicate_cannot_remove_a_concurrent_subscription() + { + CancellationToken token = TestContext.Current.CancellationToken; + using CancellationTokenSource firstCancellation = + CancellationTokenSource.CreateLinkedTokenSource(token); + await using HierarchyWatcher watcher = new(); + FakeControlModeSession replacement = new(); + TaskCompletionSource firstStartEntered = new( + TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource> told = new( + TaskCreationOptions.RunContinuationsAsynchronously); + int starts = 0; + + async Task Start(CancellationToken cancellationToken) + { + if (Interlocked.Increment(ref starts) == 1) + { + firstStartEntered.TrySetResult(); + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken) + .ConfigureAwait(false); + } + + return replacement; + } + + object subscriber = new(); + Func, Task> announce = changed => + { + told.TrySetResult(changed); + return Task.CompletedTask; + }; + Task first = watcher.SubscribeAsync( + "tmux://hierarchy", + subscriber, + announce, + Start, + firstCancellation.Token); + await firstStartEntered.Task.WaitAsync(token); + Task second = watcher.SubscribeAsync( + "tmux://hierarchy", + subscriber, + announce, + Start, + token); + + Assert.False(second.IsCompleted); + firstCancellation.Cancel(); + await Assert.ThrowsAnyAsync(() => first); + await second.WaitAsync(token); + replacement.Publish(new TmuxNotificationEvent("window-add", ["@1"])); + + Assert.Equal(["tmux://hierarchy"], await told.Task.WaitAsync(token)); + Assert.Equal(2, starts); + + await watcher.UnsubscribeAsync("tmux://hierarchy", subscriber); + await replacement.Disposed.Task.WaitAsync(token); + Assert.Equal(1, replacement.DisposeCalls); + } + + [Fact] + public async Task Duplicate_subscriptions_are_one_reference_and_one_delivery() + { + CancellationToken token = TestContext.Current.CancellationToken; + await using HierarchyWatcher watcher = new(); + FakeControlModeSession session = new(); + object subscriber = new(); + TaskCompletionSource> told = new( + TaskCreationOptions.RunContinuationsAsynchronously); + int starts = 0; + int duplicateDeliveries = 0; + + await watcher.SubscribeAsync( + "tmux://hierarchy", + subscriber, + changed => + { + told.TrySetResult(changed); + return Task.CompletedTask; + }, + _ => + { + starts++; + return Task.FromResult(session); + }, + token); + await watcher.SubscribeAsync( + "tmux://hierarchy", + subscriber, + _ => + { + Interlocked.Increment(ref duplicateDeliveries); + return Task.CompletedTask; + }, + _ => + { + starts++; + return Task.FromResult(session); + }, + token); + + Assert.False(told.Task.IsCompleted); + session.Publish(new TmuxNotificationEvent("window-add", ["@1"])); + + Assert.Equal(["tmux://hierarchy"], await told.Task.WaitAsync(token)); + Assert.Equal(1, starts); + Assert.Equal(0, Volatile.Read(ref duplicateDeliveries)); + + await watcher.UnsubscribeAsync("tmux://hierarchy", subscriber); + await session.Disposed.Task.WaitAsync(token); + Assert.Equal(1, session.DisposeCalls); + } + + [Fact] + public async Task One_logical_unsubscribe_retires_every_generation_it_crossed() + { + CancellationToken token = TestContext.Current.CancellationToken; + await using HierarchyWatcher watcher = new(); + FakeControlModeSession first = new(); + FakeControlModeSession second = new(); + object subscriber = new(); + + await watcher.SubscribeAsync( + "tmux://hierarchy", + subscriber, + _ => Task.CompletedTask, + "endpoint", + new ServerGeneration(1, 101), + _ => Task.FromResult(first), + token); + await watcher.SubscribeAsync( + "tmux://hierarchy", + subscriber, + _ => Task.CompletedTask, + "endpoint", + new ServerGeneration(2, 202), + _ => Task.FromResult(second), + token); + + await watcher.UnsubscribeAsync("tmux://hierarchy", subscriber); + + await Task.WhenAll(first.Disposed.Task, second.Disposed.Task).WaitAsync(token); + Assert.Equal(1, first.DisposeCalls); + Assert.Equal(1, second.DisposeCalls); + } + + [Fact] + public async Task A_later_subscription_restarts_after_the_control_stream_ends() + { + CancellationToken token = TestContext.Current.CancellationToken; + await using HierarchyWatcher watcher = new(); + FakeControlModeSession first = new(); + FakeControlModeSession second = new(); + int starts = 0; + + Task Start(CancellationToken _) + { + IControlModeSession session = starts++ switch + { + 0 => first, + 1 => second, + _ => throw new InvalidOperationException("The watcher started too many clients."), + }; + return Task.FromResult(session); + } + + object firstSubscriber = new(); + object secondSubscriber = new(); + await watcher.SubscribeAsync( + "tmux://hierarchy", + firstSubscriber, + _ => Task.CompletedTask, + Start, + token); + + first.EndUnexpectedly(); + await first.Disposed.Task.WaitAsync(token); + + await watcher.SubscribeAsync( + "tmux://sessions", + secondSubscriber, + _ => Task.CompletedTask, + Start, + token); + + Assert.Equal(2, starts); + Assert.Equal(["refresh-client -f ignore-size,no-output"], first.Commands); + Assert.Equal(["refresh-client -f ignore-size,no-output"], second.Commands); + + await watcher.UnsubscribeAsync("tmux://hierarchy", firstSubscriber); + await watcher.UnsubscribeAsync("tmux://sessions", secondSubscriber); + await second.Disposed.Task.WaitAsync(token); + Assert.Equal(1, first.DisposeCalls); + Assert.Equal(1, second.DisposeCalls); + } + + [Fact] + public async Task Restart_waits_for_the_dead_client_cleanup_without_losing_the_subscriber() + { + CancellationToken token = TestContext.Current.CancellationToken; + HierarchyWatcher watcher = new(); + FakeControlModeSession first = new(pauseDisposal: true); + FakeControlModeSession second = new(); + int starts = 0; + + Task Start(CancellationToken _) + { + IControlModeSession session = starts++ == 0 ? first : second; + return Task.FromResult(session); + } + + object firstSubscriber = new(); + object secondSubscriber = new(); + try + { + await watcher.SubscribeAsync( + "tmux://hierarchy", + firstSubscriber, + _ => Task.CompletedTask, + Start, + token); + + first.EndUnexpectedly(); + await first.DisposeStarted.Task.WaitAsync(token); + + Task restart = watcher.SubscribeAsync( + "tmux://sessions", + secondSubscriber, + _ => Task.CompletedTask, + Start, + token); + Assert.Equal(1, starts); + Assert.False(restart.IsCompleted); + + first.AllowDisposal(); + await restart.WaitAsync(token); + Assert.Equal(2, starts); + + await watcher.UnsubscribeAsync("tmux://hierarchy", firstSubscriber); + await watcher.UnsubscribeAsync("tmux://sessions", secondSubscriber); + await second.Disposed.Task.WaitAsync(token); + Assert.Equal(1, first.DisposeCalls); + Assert.Equal(1, second.DisposeCalls); + } + finally + { + first.AllowDisposal(); + await watcher.DisposeAsync(); + } + } + + [Fact] + public async Task Different_endpoint_generations_own_separate_runs_and_invalidations() + { + CancellationToken token = TestContext.Current.CancellationToken; + await using HierarchyWatcher watcher = new(); + FakeControlModeSession first = new(); + FakeControlModeSession second = new(); + object firstSubscriber = new(); + object secondSubscriber = new(); + TaskCompletionSource> firstTold = new( + TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource> secondTold = new( + TaskCreationOptions.RunContinuationsAsynchronously); + int firstStarts = 0; + int secondStarts = 0; + + await watcher.SubscribeAsync( + "tmux://hierarchy", + firstSubscriber, + changed => + { + firstTold.TrySetResult(changed); + return Task.CompletedTask; + }, + "endpoint-a", + new ServerGeneration(101, 1001), + _ => + { + firstStarts++; + return Task.FromResult(first); + }, + token); + await watcher.SubscribeAsync( + "tmux://sessions", + secondSubscriber, + changed => + { + secondTold.TrySetResult(changed); + return Task.CompletedTask; + }, + "endpoint-b", + new ServerGeneration(202, 2002), + _ => + { + secondStarts++; + return Task.FromResult(second); + }, + token); + + Assert.Equal(1, firstStarts); + Assert.Equal(1, secondStarts); + Assert.Equal(["refresh-client -f ignore-size,no-output"], first.Commands); + Assert.Equal(["refresh-client -f ignore-size,no-output"], second.Commands); + + first.Publish(new TmuxNotificationEvent("window-add", ["@1"])); + Assert.Equal( + ["tmux://hierarchy"], + await firstTold.Task.WaitAsync(token)); + Assert.False(secondTold.Task.IsCompleted); + + second.Publish(new TmuxNotificationEvent("window-add", ["@1"])); + Assert.Equal( + ["tmux://sessions"], + await secondTold.Task.WaitAsync(token)); + + await watcher.UnsubscribeAsync("tmux://hierarchy", firstSubscriber); + await first.Disposed.Task.WaitAsync(token); + Assert.Equal(1, first.DisposeCalls); + Assert.Equal(0, second.DisposeCalls); + + await watcher.UnsubscribeAsync("tmux://sessions", secondSubscriber); + await second.Disposed.Task.WaitAsync(token); + Assert.Equal(1, second.DisposeCalls); + } + + [Fact] + public async Task A_stuck_subscriber_does_not_starve_peers_or_disposal() + { + CancellationToken token = TestContext.Current.CancellationToken; + HierarchyWatcher watcher = new(); + FakeControlModeSession session = new(); + TaskCompletionSource firstStarted = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource releaseFirst = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource firstFinished = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource> secondTold = new( + TaskCreationOptions.RunContinuationsAsynchronously); + + try + { + await watcher.SubscribeAsync( + "tmux://hierarchy", + new object(), + async _ => + { + firstStarted.TrySetResult(); + await releaseFirst.Task.ConfigureAwait(false); + firstFinished.TrySetResult(); + }, + _ => Task.FromResult(session), + token); + await watcher.SubscribeAsync( + "tmux://sessions", + new object(), + changed => + { + secondTold.TrySetResult(changed); + return Task.CompletedTask; + }, + _ => Task.FromResult(session), + token); + + session.Publish(new TmuxNotificationEvent("window-add", ["@1"])); + + await firstStarted.Task.WaitAsync(token); + Assert.Equal(["tmux://sessions"], await secondTold.Task.WaitAsync(token)); + await watcher.DisposeAsync().AsTask().WaitAsync(token); + Assert.Equal(1, session.DisposeCalls); + Assert.False(firstFinished.Task.IsCompleted); + } + finally + { + releaseFirst.TrySetResult(); + if (firstStarted.Task.IsCompleted) + { + await firstFinished.Task.WaitAsync(token); + } + + await watcher.DisposeAsync(); + } + } + + [Fact] + public async Task A_faulted_subscriber_is_observed_without_stopping_peers() + { + CancellationToken token = TestContext.Current.CancellationToken; + var logger = new RecordingLogger(); + await using HierarchyWatcher watcher = new(logger); + FakeControlModeSession session = new(); + TaskCompletionSource> peerTold = new( + TaskCreationOptions.RunContinuationsAsynchronously); + + await watcher.SubscribeAsync( + "tmux://hierarchy", + new object(), + _ => Task.FromException(new InvalidOperationException("subscriber failed")), + _ => Task.FromResult(session), + token); + await watcher.SubscribeAsync( + "tmux://sessions", + new object(), + changed => + { + peerTold.TrySetResult(changed); + return Task.CompletedTask; + }, + _ => Task.FromResult(session), + token); + + session.Publish(new TmuxNotificationEvent("window-add", ["@1"])); + + Assert.Equal(["tmux://sessions"], await peerTold.Task.WaitAsync(token)); + (EventId EventId, Exception Error) failure = await logger.Failure.Task.WaitAsync(token); + Assert.Equal(10, failure.EventId.Id); + Assert.IsType(failure.Error); + } + + [Fact] + public async Task Keyless_unsubscribe_releases_the_resource_on_every_endpoint() + { + CancellationToken token = TestContext.Current.CancellationToken; + await using HierarchyWatcher watcher = new(); + FakeControlModeSession first = new(); + FakeControlModeSession second = new(); + int starts = 0; + + Task Start(CancellationToken _) + { + IControlModeSession session = starts++ switch + { + 0 => first, + 1 => second, + _ => throw new InvalidOperationException("The watcher started too many clients."), + }; + return Task.FromResult(session); + } + + await watcher.SubscribeAsync( + "tmux://hierarchy", + new object(), + _ => Task.CompletedTask, + "endpoint-a", + new ServerGeneration(11, 111), + Start, + token); + await watcher.SubscribeAsync( + "tmux://hierarchy", + new object(), + _ => Task.CompletedTask, + "endpoint-b", + new ServerGeneration(12, 121), + Start, + token); + + Assert.Equal(2, starts); + + await watcher.UnsubscribeAsync("tmux://hierarchy"); + + await first.Disposed.Task.WaitAsync(UnsubscribeTimeout, token); + await second.Disposed.Task.WaitAsync(UnsubscribeTimeout, token); + Assert.Equal(1, first.DisposeCalls); + Assert.Equal(1, second.DisposeCalls); + } + + [Fact] + public async Task Keyless_unsubscribe_releases_every_holder_of_one_resource() + { + CancellationToken token = TestContext.Current.CancellationToken; + await using HierarchyWatcher watcher = new(); + FakeControlModeSession session = new(); + ServerGeneration generation = new(13, 131); + + await watcher.SubscribeAsync( + "tmux://hierarchy", + new object(), + _ => Task.CompletedTask, + "endpoint-a", + generation, + _ => Task.FromResult(session), + token); + await watcher.SubscribeAsync( + "tmux://hierarchy", + new object(), + _ => Task.CompletedTask, + "endpoint-a", + generation, + _ => Task.FromResult(session), + token); + + await watcher.UnsubscribeAsync("tmux://hierarchy"); + + await session.Disposed.Task.WaitAsync(UnsubscribeTimeout, token); + Assert.Equal(1, session.DisposeCalls); + } + + private sealed class FakeControlModeSession : IControlModeSession + { + private readonly Channel _events = Channel.CreateUnbounded(); + private readonly TaskCompletionSource? _allowDisposal; + private int _disposeCalls; + private int _disposed; + private int _running = 1; + + internal FakeControlModeSession(bool pauseDisposal = false) + { + if (pauseDisposal) + { + _allowDisposal = new(TaskCreationOptions.RunContinuationsAsynchronously); + } + } + + internal List Commands { get; } = []; + + internal TaskCompletionSource Disposed { get; } = new( + TaskCreationOptions.RunContinuationsAsynchronously); + + internal TaskCompletionSource DisposeStarted { get; } = new( + TaskCreationOptions.RunContinuationsAsynchronously); + + internal int DisposeCalls => Volatile.Read(ref _disposeCalls); + + public IAsyncEnumerable Events => _events.Reader.ReadAllAsync(); + + public bool IsRunning => Volatile.Read(ref _running) != 0; + + public Task> SendAsync( + string command, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + Commands.Add(command); + return Task.FromResult>([]); + } + + public async ValueTask DisposeAsync() + { + Interlocked.Increment(ref _disposeCalls); + if (Interlocked.Exchange(ref _disposed, 1) == 0) + { + Volatile.Write(ref _running, 0); + _events.Writer.TryComplete(); + DisposeStarted.TrySetResult(); + if (_allowDisposal is not null) + { + await _allowDisposal.Task.ConfigureAwait(false); + } + + Disposed.TrySetResult(); + } + } + + internal void AllowDisposal() => _allowDisposal?.TrySetResult(); + + internal void EndUnexpectedly() + { + Volatile.Write(ref _running, 0); + _events.Writer.TryComplete(); + } + + internal void Publish(TmuxEvent observed) => _events.Writer.TryWrite(observed); + } + + private sealed class ControlledDelay + { + private readonly TaskCompletionSource _release = new( + TaskCreationOptions.RunContinuationsAsynchronously); + + internal TaskCompletionSource Entered { get; } = new( + TaskCreationOptions.RunContinuationsAsynchronously); + + internal TaskCompletionSource Cancelled { get; } = new( + TaskCreationOptions.RunContinuationsAsynchronously); + + internal void Release() => _release.TrySetResult(); + + internal async Task WaitAsync(TimeSpan delay, CancellationToken cancellationToken) + { + Entered.TrySetResult(delay); + try + { + await _release.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + Cancelled.TrySetResult(); + throw; + } + } + } + + private sealed class ControlledBarrier + { + private readonly TaskCompletionSource _release = new( + TaskCreationOptions.RunContinuationsAsynchronously); + + internal TaskCompletionSource Entered { get; } = new( + TaskCreationOptions.RunContinuationsAsynchronously); + + internal void Release() => _release.TrySetResult(); + + internal async Task WaitAsync(CancellationToken cancellationToken) + { + Entered.TrySetResult(); + await _release.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + } + } + + private sealed class RecordingLogger : ILogger + { + internal TaskCompletionSource<(EventId EventId, Exception Error)> Failure { get; } = new( + TaskCreationOptions.RunContinuationsAsynchronously); + + public IDisposable? BeginScope(TState state) + where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + if (exception is not null) + { + Failure.TrySetResult((eventId, exception)); + } + } + } +} diff --git a/tests/LibTmux.UnitTests/Mcp/JobStoreTests.cs b/tests/LibTmux.UnitTests/Mcp/JobStoreTests.cs new file mode 100644 index 0000000..12779bd --- /dev/null +++ b/tests/LibTmux.UnitTests/Mcp/JobStoreTests.cs @@ -0,0 +1,1204 @@ +using System.Collections.Concurrent; +using System.Globalization; +using System.Runtime.Versioning; +using System.Text; +using System.Text.Json; +using LibTmux.Internal; +using LibTmux.Mcp; +using Microsoft.Extensions.Logging; +using ModelContextProtocol; + +namespace LibTmux.UnitTests.Mcp; + +[UnsupportedOSPlatform("windows")] +public sealed class JobStoreTests +{ + [Fact] + public async Task Start_rejects_a_pane_from_another_endpoint_or_generation() + { + CancellationToken token = TestContext.Current.CancellationToken; + ServerGeneration generation = new(51, 501); + FakeEndpoint origin = new("owner-a", generation); + FakeEndpoint otherEndpoint = new("owner-b", generation); + FakeEndpoint restarted = new("owner-a", new ServerGeneration(52, 502)); + await using JobStore jobs = new(); + + Exception? endpointError = Record.Exception(() => + { + _ = jobs.StartAsync( + origin.Server, + otherEndpoint.Pane, + "echo wrong-endpoint", + suppressHistory: true, + token); + }); + Exception? generationError = Record.Exception(() => + { + _ = jobs.StartAsync( + restarted.Server, + origin.Pane, + "echo stale-generation", + suppressHistory: true, + token); + }); + McpException endpointMismatch = Assert.IsType(endpointError); + McpException generationMismatch = Assert.IsType(generationError); + + Assert.Contains("different tmux endpoint", endpointMismatch.Message, StringComparison.Ordinal); + Assert.Contains("server generation", generationMismatch.Message, StringComparison.Ordinal); + Assert.Empty(origin.Commands); + Assert.Empty(otherEndpoint.Commands); + Assert.Empty(restarted.Commands); + Assert.Equal(0, jobs.List().TotalJobs); + } + + [Fact] + public async Task Start_rejects_an_unreturnable_handle_before_dispatch() + { + CancellationToken token = TestContext.Current.CancellationToken; + FakeEndpoint endpoint = new( + new string('s', 6_000), + new ServerGeneration(61, 601)); + await using JobStore jobs = new(); + + Exception? failure = Record.Exception(() => + { + _ = jobs.StartAsync( + endpoint.Server, + endpoint.Pane, + "echo never-dispatched", + suppressHistory: true, + maxCommandBytes: 4_000, + cancellationToken: token); + }); + McpException tooLarge = Assert.IsType(failure); + + Assert.Contains("job handle response", tooLarge.Message, StringComparison.Ordinal); + Assert.Contains(ServerPolicy.MaxBytesVariable, tooLarge.Message, StringComparison.Ordinal); + Assert.Empty(endpoint.Commands); + Assert.Equal(0, jobs.List().TotalJobs); + } + + [Fact] + public async Task Same_pane_id_on_two_servers_stays_bound_to_the_starting_endpoint() + { + CancellationToken token = TestContext.Current.CancellationToken; + FakeEndpoint origin = new("jobs-a", new ServerGeneration(101, 1001)); + FakeEndpoint other = new("jobs-b", new ServerGeneration(202, 2002)); + await using JobStore jobs = new(); + await using PaneActivityHub activity = new(); + using TmuxConnectionAccessor connections = new(other.Server); + var write = new WriteTools(connections, new ServerPolicy(), activity, jobs); + const string Secret = "TOKEN=do-not-return-this echo work"; + + Assert.Equal(origin.Pane.Id, other.Pane.Id); + JobInfo started = await jobs.StartAsync( + origin.Server, + origin.Pane, + Secret, + suppressHistory: true, + token); + + Assert.Equal("jobs-a", started.SocketName); + Assert.Null(started.SocketPath); + Assert.NotEqual( + other.Pane.Server.Connection?.GetEndpointFingerprint(), + started.EndpointFingerprint); + Assert.Equal( + origin.Pane.Server.Connection?.GetEndpointFingerprint(), + started.EndpointFingerprint); + Assert.Equal(origin.Generation, started.ServerGeneration); + Assert.Equal(Encoding.UTF8.GetByteCount(Secret), started.CommandBytes); + Assert.True( + Utf8JsonBudget.GetStructuredToolResultByteCount(started, ToolJson.Options) <= 4_000); + Assert.DoesNotContain( + Secret, + JsonSerializer.Serialize(started, ToolJson.Options), + StringComparison.Ordinal); + + McpException readMismatch = await Assert.ThrowsAsync( + () => write.JobAsync(started.JobId, socketName: "jobs-b", cancellationToken: token)); + McpException cancelMismatch = await Assert.ThrowsAsync( + () => write.CancelJobAsync(started.JobId, "jobs-b", token)); + + Assert.Contains("jobs-a", readMismatch.Message, StringComparison.Ordinal); + Assert.Contains("jobs-b", readMismatch.Message, StringComparison.Ordinal); + Assert.Contains("jobs-a", cancelMismatch.Message, StringComparison.Ordinal); + Assert.Empty(other.Commands); + + JobInfo cancelled = await write.CancelJobAsync( + started.JobId, + cancellationToken: token); + Assert.Equal(JobState.Cancelled, cancelled.State); + Assert.True( + Utf8JsonBudget.GetStructuredToolResultByteCount(cancelled, ToolJson.Options) <= 4_000); + Assert.Contains( + origin.Commands, + arguments => arguments.Contains("C-c", StringComparer.Ordinal)); + Assert.Empty(other.Commands); + } + + [Fact] + public async Task Concurrent_terminal_transitions_publish_one_complete_snapshot() + { + CancellationToken token = TestContext.Current.CancellationToken; + FakeEndpoint endpoint = new("atomic", new ServerGeneration(303, 3003)); + JobStore.StoredJob job = endpoint.Job("atomic-job"); + using var start = new ManualResetEventSlim(false); + + Task exited = Task.Run( + () => + { + start.Wait(token); + return job.TryFinish(JobState.Exited, 7); + }, + token); + Task cancelled = Task.Run( + () => + { + start.Wait(token); + return job.TryFinish(JobState.Cancelled, null); + }, + token); + + start.Set(); + bool[] transitions = await Task.WhenAll(exited, cancelled); + JobInfo snapshot = job.Describe(); + + Assert.Single(transitions, won => won); + Assert.NotNull(snapshot.EndedAt); + if (snapshot.State == JobState.Exited) + { + Assert.Equal(7, snapshot.ExitStatus); + } + else + { + Assert.Equal(JobState.Cancelled, snapshot.State); + Assert.Null(snapshot.ExitStatus); + } + } + + [Fact] + public async Task Concurrent_output_collections_serialize_cursor_read_and_advance() + { + CancellationToken token = TestContext.Current.CancellationToken; + FakeEndpoint endpoint = new("cursor", new ServerGeneration(404, 4004)); + JobStore.StoredJob job = endpoint.Job("cursor-job"); + JobStore.StoredJob.OutputLease first = await job.AcquireOutputAsync(token); + Task secondTask = job + .AcquireOutputAsync(token) + .AsTask(); + + Assert.False(secondTask.IsCompleted); + first.Advance("cursor-one"); + first.Dispose(); + + using JobStore.StoredJob.OutputLease second = await secondTask; + Assert.Equal("cursor-one", second.Cursor); + second.Advance("cursor-two"); + } + + [Fact] + public async Task Not_dispatched_start_failure_removes_the_unissued_handle() + { + CancellationToken token = TestContext.Current.CancellationToken; + FakeEndpoint endpoint = new("failed", new ServerGeneration(505, 5005)); + endpoint.Handler = (arguments, _) => + arguments.Contains("send-keys", StringComparer.Ordinal) + ? Task.FromException(new TmuxTransportException( + "The tmux client was not started.", + arguments, + TmuxDispatchState.NotDispatched)) + : Task.FromResult(endpoint.Success(arguments)); + await using JobStore jobs = new(); + + TmuxTransportException failure = await Assert.ThrowsAsync( + () => jobs.StartAsync( + endpoint.Server, + endpoint.Pane, + "echo never-started", + suppressHistory: true, + token)); + + Assert.Equal(TmuxDispatchState.NotDispatched, failure.Dispatch); + Assert.False(failure.Data.Contains(JobStore.RecoveryJobIdDataKey)); + JobList remembered = jobs.List(); + Assert.Equal(0, remembered.TotalJobs); + Assert.Empty(remembered.Jobs); + } + + [Fact] + public async Task Not_dispatched_enter_after_payload_retains_a_recovery_handle() + { + CancellationToken token = TestContext.Current.CancellationToken; + FakeEndpoint endpoint = new("enter-failed", new ServerGeneration(525, 5205)); + int sendStage = 0; + string? jobId = null; + endpoint.Handler = async (arguments, cancellationToken) => + { + if (arguments.Contains("send-keys", StringComparer.Ordinal)) + { + int stage = Interlocked.Increment(ref sendStage); + if (stage == 1) + { + jobId = ExtractRunId(arguments); + return endpoint.Success(arguments); + } + + throw new TmuxTransportException( + "Enter was not dispatched.", + arguments, + TmuxDispatchState.NotDispatched); + } + + if (arguments.Count > 0 && arguments[0] == "wait-for") + { + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + } + + return endpoint.Success(arguments); + }; + await using JobStore jobs = new(); + + LibTmuxException failure = await Assert.ThrowsAsync(() => + jobs.StartAsync( + endpoint.Server, + endpoint.Pane, + "echo maybe-started", + suppressHistory: true, + token)); + string retainedId = Assert.IsType( + failure.Data[JobStore.RecoveryJobIdDataKey]); + + Assert.Equal(TmuxDispatchState.Unknown, failure.Dispatch); + TmuxTransportException enterFailure = Assert.IsType( + failure.InnerException); + Assert.Equal(TmuxDispatchState.NotDispatched, enterFailure.Dispatch); + Assert.Equal(2, Volatile.Read(ref sendStage)); + Assert.Equal(jobId, retainedId); + JobInfo retained = Assert.Single(jobs.List().Jobs); + Assert.Equal(retainedId, retained.JobId); + Assert.Equal(JobState.Running, retained.State); + Assert.NotNull(jobs.Resolve(retainedId, null).Watcher); + } + + [Fact] + public async Task Unknown_baseline_failure_does_not_publish_an_unstarted_job() + { + CancellationToken token = TestContext.Current.CancellationToken; + FakeEndpoint endpoint = new("baseline-failed", new ServerGeneration(555, 5505)); + endpoint.Handler = (arguments, _) => + arguments.Contains("capture-pane", StringComparer.Ordinal) + ? Task.FromException(new TmuxTransportException( + "The baseline capture pipe failed.", + arguments, + TmuxDispatchState.Unknown)) + : Task.FromResult(endpoint.Success(arguments)); + await using JobStore jobs = new(); + + TmuxTransportException failure = await Assert.ThrowsAsync( + () => jobs.StartAsync( + endpoint.Server, + endpoint.Pane, + "echo never-dispatched", + suppressHistory: true, + token)); + + Assert.Equal(TmuxDispatchState.Unknown, failure.Dispatch); + Assert.False(failure.Data.Contains(JobStore.RecoveryJobIdDataKey)); + Assert.Equal(0, jobs.List().TotalJobs); + Assert.DoesNotContain( + endpoint.Commands, + arguments => arguments.Contains("send-keys", StringComparer.Ordinal)); + } + + [Fact] + public async Task Cancelled_start_removes_the_unissued_handle() + { + CancellationToken token = TestContext.Current.CancellationToken; + FakeEndpoint endpoint = new("cancelled", new ServerGeneration(606, 6006)); + var sendStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + endpoint.Handler = async (arguments, cancellationToken) => + { + if (arguments.Contains("send-keys", StringComparer.Ordinal)) + { + sendStarted.TrySetResult(); + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + } + + return endpoint.Success(arguments); + }; + await using JobStore jobs = new(); + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(token); + + Task starting = jobs.StartAsync( + endpoint.Server, + endpoint.Pane, + "echo cancelled", + suppressHistory: true, + cancellation.Token); + await sendStarted.Task.WaitAsync(token); + await cancellation.CancelAsync(); + + await Assert.ThrowsAnyAsync(() => starting); + Assert.Equal(0, jobs.List().TotalJobs); + } + + [Fact] + public async Task Starting_job_is_not_visible_until_dispatch_commits() + { + CancellationToken token = TestContext.Current.CancellationToken; + FakeEndpoint endpoint = new("starting", new ServerGeneration(656, 6506)); + var sendStarted = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var releaseSend = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + endpoint.Handler = async (arguments, cancellationToken) => + { + if (arguments.Contains("send-keys", StringComparer.Ordinal) + && arguments.Any(argument => argument.Contains("lt_r_", StringComparison.Ordinal))) + { + sendStarted.TrySetResult(ExtractRunId(arguments)); + await releaseSend.Task.WaitAsync(cancellationToken); + } + + return endpoint.Success(arguments); + }; + await using JobStore jobs = new(); + + Task starting = jobs.StartAsync( + endpoint.Server, + endpoint.Pane, + "echo withheld", + suppressHistory: true, + token); + string jobId = await sendStarted.Task.WaitAsync(token); + + Assert.Equal(0, jobs.List().TotalJobs); + _ = Assert.Throws(() => jobs.Get(jobId)); + _ = await Assert.ThrowsAsync( + () => jobs.CancelAsync(jobId, cancellationToken: token)); + Assert.DoesNotContain( + endpoint.Commands, + arguments => arguments.Contains("C-c", StringComparer.Ordinal)); + + releaseSend.TrySetResult(); + JobInfo issued = await starting.WaitAsync(token); + + Assert.Equal(jobId, issued.JobId); + Assert.Equal(1, jobs.List().TotalJobs); + Assert.Equal(jobId, jobs.Resolve(jobId, null).JobId); + } + + [Fact] + public async Task Ambiguous_dispatch_cancellation_retains_a_collectable_job() + { + CancellationToken token = TestContext.Current.CancellationToken; + FakeEndpoint endpoint = new("ambiguous", new ServerGeneration(676, 6706)) + { + CaptureLines = ["possibly-started output"], + }; + string? jobId = null; + int ambiguousSend = 0; + endpoint.Handler = async (arguments, cancellationToken) => + { + if (arguments.Contains("send-keys", StringComparer.Ordinal) + && arguments.Any(argument => argument.Contains("lt_r_", StringComparison.Ordinal)) + && Interlocked.CompareExchange(ref ambiguousSend, 1, 0) == 0) + { + jobId = ExtractRunId(arguments); + endpoint.MarkJobDispatched(); + throw new TmuxOperationCanceledException( + "The tmux client was cancelled after launch.", + new CancellationToken(canceled: true), + commandMayHaveExecuted: true, + clientProcessId: 1234); + } + + if (arguments.Count > 0 && arguments[0] == "wait-for") + { + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + } + + return endpoint.Success(arguments); + }; + await using JobStore jobs = new(); + await using PaneActivityHub activity = new(); + using TmuxConnectionAccessor connections = new(endpoint.Server); + var write = new WriteTools(connections, new ServerPolicy(), activity, jobs); + + TmuxOperationCanceledException cancelled = await Assert.ThrowsAsync< + TmuxOperationCanceledException>(() => jobs.StartAsync( + endpoint.Server, + endpoint.Pane, + "echo maybe-started", + suppressHistory: true, + token)); + string retainedId = Assert.IsType( + cancelled.Data[JobStore.RecoveryJobIdDataKey]); + + Assert.Equal(jobId, retainedId); + JobInfo retained = Assert.Single(jobs.List().Jobs); + Assert.Equal(retainedId, retained.JobId); + Assert.Equal(JobState.Running, retained.State); + + JobReport report = await write.JobAsync( + retainedId, + cancellationToken: token); + Assert.Contains("possibly-started output", report.Output.Lines); + + JobInfo stopped = await jobs.CancelAsync( + retainedId, + cancellationToken: token); + Assert.Equal(JobState.Cancelled, stopped.State); + } + + [Theory] + [InlineData("cleanup")] + [InlineData("unknown")] + [InlineData("dispatched")] + public async Task Non_definitive_dispatch_failures_retain_a_recovery_handle( + string failureKind) + { + CancellationToken token = TestContext.Current.CancellationToken; + FakeEndpoint endpoint = new("dispatch-failed", new ServerGeneration(686, 6806)); + string? jobId = null; + endpoint.Handler = async (arguments, cancellationToken) => + { + if (arguments.Contains("send-keys", StringComparer.Ordinal) + && arguments.Any(argument => argument.Contains("lt_r_", StringComparison.Ordinal))) + { + jobId = ExtractRunId(arguments); + endpoint.MarkJobDispatched(); + if (failureKind == "cleanup") + { + throw new TmuxCleanupException( + "The cancelled client could not be cleaned up.", + new OperationCanceledException( + "cancelled after launch", + new CancellationToken(canceled: true)), + clientProcessId: 2345, + new IOException("cleanup failed")); + } + + if (failureKind == "unknown") + { + throw new TmuxTransportException( + "The client pipe failed after launch.", + arguments, + TmuxDispatchState.Unknown, + new IOException("pipe failed")); + } + + return FakeEndpoint.Result( + arguments, + exitCode: 1, + standardError: "tmux reported a dispatched failure\n"); + } + + if (arguments.Count > 0 && arguments[0] == "wait-for") + { + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + } + + return endpoint.Success(arguments); + }; + await using JobStore jobs = new(); + + LibTmuxException failure = await Assert.ThrowsAnyAsync( + () => jobs.StartAsync( + endpoint.Server, + endpoint.Pane, + $"echo {failureKind}", + suppressHistory: true, + token)); + string retainedId = Assert.IsType( + failure.Data[JobStore.RecoveryJobIdDataKey]); + + Assert.NotEqual(TmuxDispatchState.NotDispatched, failure.Dispatch); + Assert.Equal(jobId, retainedId); + JobInfo retained = Assert.Single(jobs.List().Jobs); + Assert.Equal(retainedId, retained.JobId); + Assert.Equal(JobState.Running, retained.State); + Assert.NotNull(jobs.Resolve(retainedId, null).Watcher); + } + + [Fact] + public async Task Running_jobs_apply_backpressure_at_the_store_capacity() + { + CancellationToken token = TestContext.Current.CancellationToken; + FakeEndpoint endpoint = new("capacity", new ServerGeneration(707, 7007)); + await using JobStore jobs = new(); + + for (int index = 0; index < JobStore.Capacity; index++) + { + _ = await jobs.StartAsync( + endpoint.Server, + endpoint.Pane, + $"echo {index}", + suppressHistory: true, + token); + } + + McpException full = await Assert.ThrowsAsync(() => jobs.StartAsync( + endpoint.Server, + endpoint.Pane, + "echo one-too-many", + suppressHistory: true, + token)); + + Assert.Contains( + JobStore.Capacity.ToString(CultureInfo.InvariantCulture), + full.Message, + StringComparison.Ordinal); + Assert.Equal(JobStore.Capacity, jobs.List().TotalJobs); + } + + [Fact] + 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; + + for (int index = 0; index < JobStore.Capacity; index++) + { + JobInfo started = await jobs.StartAsync( + endpoint.Server, + endpoint.Pane, + $"ignore-sigint {index}", + suppressHistory: true, + token); + JobInfo cancelled = await jobs.CancelAsync( + started.JobId, + cancellationToken: token); + Assert.Equal(JobState.Cancelled, cancelled.State); + Task watcher = Assert.IsAssignableFrom( + jobs.Resolve(started.JobId, null).Watcher); + firstWatcher ??= watcher; + Assert.False(watcher.IsCompleted); + } + + McpException full = await Assert.ThrowsAsync(() => jobs.StartAsync( + endpoint.Server, + endpoint.Pane, + "ignore-sigint overflow", + suppressHistory: true, + token)); + + Assert.Contains( + JobStore.Capacity.ToString(CultureInfo.InvariantCulture), + full.Message, + StringComparison.Ordinal); + Assert.Equal(JobStore.Capacity, jobs.List().TotalJobs); + Assert.All(jobs.List().Jobs, job => Assert.Equal(JobState.Cancelled, job.State)); + + releaseFirstWatcher.TrySetResult(); + await Assert.IsAssignableFrom(firstWatcher).WaitAsync(token); + _ = await jobs.StartAsync( + endpoint.Server, + endpoint.Pane, + "replacement-after-watcher", + suppressHistory: true, + token); + + Assert.Equal(JobStore.Capacity, jobs.List().TotalJobs); + } + + [Fact] + public async Task Job_output_fits_the_complete_escaped_protocol_envelope() + { + CancellationToken token = TestContext.Current.CancellationToken; + FakeEndpoint endpoint = new("escaped-output", new ServerGeneration(767, 7607)) + { + CaptureLines = [string.Concat(Enumerable.Repeat("\"\\", 4_000))], + }; + await using JobStore jobs = new(); + await using PaneActivityHub activity = new(); + using TmuxConnectionAccessor connections = new(endpoint.Server); + var write = new WriteTools( + connections, + new ServerPolicy { MaxBytes = 4_000 }, + activity, + jobs); + JobInfo started = await jobs.StartAsync( + endpoint.Server, + endpoint.Pane, + "escape-heavy-output", + suppressHistory: true, + token); + + JobReport report = await write.JobAsync( + started.JobId, + cancellationToken: token); + + Assert.True(report.Output.Truncated); + Assert.NotEmpty(report.Output.Lines); + Assert.True( + Utf8JsonBudget.GetStructuredToolResultByteCount(report, ToolJson.Options) <= 4_000); + } + + [Fact] + public async Task First_collection_starts_at_the_pre_dispatch_pane_baseline() + { + CancellationToken token = TestContext.Current.CancellationToken; + FakeEndpoint endpoint = new("output-baseline", new ServerGeneration(772, 7702)) + { + PreJobCaptureLines = ["old prompt"], + CaptureLines = ["old prompt", "job line one", "job line two"], + }; + await using JobStore jobs = new(); + await using PaneActivityHub activity = new(); + using TmuxConnectionAccessor connections = new(endpoint.Server); + var write = new WriteTools(connections, new ServerPolicy(), activity, jobs); + + JobInfo started = await jobs.StartAsync( + endpoint.Server, + endpoint.Pane, + "print-job-lines", + suppressHistory: true, + token); + JobReport report = await write.JobAsync( + started.JobId, + cancellationToken: token); + + Assert.DoesNotContain("old prompt", report.Output.Lines); + Assert.Equal(["job line one", "job line two"], report.Output.Lines); + Assert.False(report.LinesMissed); + } + + [Fact] + public async Task Rejected_job_response_does_not_advance_output_cursor() + { + CancellationToken token = TestContext.Current.CancellationToken; + FakeEndpoint endpoint = new( + new string('s', 6_000), + new ServerGeneration(777, 7707)) + { + CaptureLines = ["retry-output"], + }; + await using JobStore jobs = new(); + await using PaneActivityHub activity = new(); + using TmuxConnectionAccessor connections = new(endpoint.Server); + var small = new WriteTools( + connections, + new ServerPolicy { MaxBytes = 4_000 }, + activity, + jobs); + var large = new WriteTools( + connections, + new ServerPolicy { MaxBytes = 128_000 }, + activity, + jobs); + JobInfo started = await jobs.StartAsync( + endpoint.Server, + endpoint.Pane, + "print-once", + suppressHistory: true, + token); + + string baseline; + using (JobStore.StoredJob.OutputLease output = await jobs + .Resolve(started.JobId, null) + .AcquireOutputAsync(token)) + { + baseline = Assert.IsType(output.Cursor); + } + + McpException tooLarge = await Assert.ThrowsAsync( + () => small.JobAsync(started.JobId, cancellationToken: token)); + using (JobStore.StoredJob.OutputLease output = await jobs + .Resolve(started.JobId, null) + .AcquireOutputAsync(token)) + { + Assert.Equal(baseline, output.Cursor); + } + + JobReport retried = await large.JobAsync( + started.JobId, + cancellationToken: token); + + Assert.Contains("cannot fit", tooLarge.Message, StringComparison.Ordinal); + Assert.Contains("retry-output", retried.Output.Lines); + } + + [Fact] + public async Task Terminal_signal_interrupts_a_silent_activity_wait() + { + CancellationToken token = TestContext.Current.CancellationToken; + FakeEndpoint endpoint = new("silent-finish", new ServerGeneration(787, 7807)); + JobStore.StoredJob job = endpoint.Job("silent-job"); + var activityStarted = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var activityCancelled = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + + async Task WaitForSilentActivity(CancellationToken cancellationToken) + { + activityStarted.TrySetResult(); + try + { + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + return true; + } + catch (OperationCanceledException) + { + activityCancelled.TrySetResult(); + throw; + } + } + + Task waiting = WriteTools.WaitForTerminalOrActivityAsync( + job, + WaitForSilentActivity, + token); + await activityStarted.Task.WaitAsync(token); + Assert.False(waiting.IsCompleted); + + Assert.True(job.TryFinish(JobState.Exited, 0)); + + Assert.True(await waiting.WaitAsync(TimeSpan.FromSeconds(1), token)); + await activityCancelled.Task.WaitAsync(TimeSpan.FromSeconds(1), token); + } + + [Fact] + public async Task Unexpected_watcher_failure_is_observed_and_marks_the_job_lost() + { + CancellationToken token = TestContext.Current.CancellationToken; + FakeEndpoint endpoint = new("watch-fault", new ServerGeneration(808, 8008)); + endpoint.Handler = (arguments, _) => + arguments.Count > 0 && arguments[0] == "wait-for" + ? Task.FromException(new InvalidOperationException("watch exploded")) + : 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 InvalidOperationException); + } + + [Fact] + public async Task Disposal_cancels_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 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(); + } + + return endpoint.Success(arguments); + }; + JobStore jobs = new(); + try + { + _ = await jobs.StartAsync( + endpoint.Server, + endpoint.Pane, + "echo watched", + suppressHistory: true, + token); + + Task disposing = jobs.DisposeAsync().AsTask(); + await cancellationSeen.Task.WaitAsync(token); + Assert.False(disposing.IsCompleted); + + release.TrySetResult(); + await disposing.WaitAsync(token); + } + finally + { + release.TrySetResult(); + await jobs.DisposeAsync(); + } + } + + [Fact] + public async Task Disposal_preserves_a_retired_watcher_failure_and_drains_the_rest() + { + CancellationToken token = TestContext.Current.CancellationToken; + FakeEndpoint faulting = new("dispose-fault", new ServerGeneration(959, 9509)); + faulting.Handler = (arguments, _) => + arguments.Count > 0 && arguments[0] == "wait-for" + ? Task.FromException( + 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( + 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(); + } + + return held.Success(arguments); + }; + JobStore jobs = new(new ThrowingLogger()); + try + { + JobInfo first = await jobs.StartAsync( + faulting.Server, + faulting.Pane, + "echo faulting", + suppressHistory: true, + token); + Task faultedWatcher = Assert.IsAssignableFrom( + jobs.Resolve(first.JobId, null).Watcher); + Exception watcherFailure = await Assert.ThrowsAnyAsync( + () => faultedWatcher.WaitAsync(token)); + Assert.Contains("logger failed", watcherFailure.ToString(), StringComparison.Ordinal); + + _ = await jobs.StartAsync( + held.Server, + held.Pane, + "echo held", + suppressHistory: true, + token); + + Task disposing = jobs.DisposeAsync().AsTask(); + await cancellationSeen.Task.WaitAsync(token); + Assert.False(disposing.IsCompleted); + + release.TrySetResult(); + Exception failure = await Assert.ThrowsAnyAsync( + () => disposing.WaitAsync(token)); + Assert.Contains("logger failed", failure.ToString(), StringComparison.Ordinal); + } + finally + { + release.TrySetResult(); + try + { + await jobs.DisposeAsync(); + } + catch (Exception error) when (error.ToString().Contains( + "logger failed", + StringComparison.Ordinal)) + { + } + } + } + + [Fact] + public async Task Command_and_list_budgets_bound_stored_responses() + { + CancellationToken token = TestContext.Current.CancellationToken; + string longSocket = new('s', 900); + FakeEndpoint endpoint = new(longSocket, new ServerGeneration(1001, 10001)); + await using JobStore jobs = new(); + string secret = "PASSWORD=secret-value"; + + JobInfo kept = await jobs.StartAsync( + endpoint.Server, + endpoint.Pane, + secret, + suppressHistory: true, + token); + for (int index = 0; index < 3; index++) + { + _ = await jobs.StartAsync( + endpoint.Server, + endpoint.Pane, + $"echo {index}", + suppressHistory: true, + token); + } + + JobList bounded = jobs.List(4_000); + int responseBytes = Utf8JsonBudget.GetStructuredToolResultByteCount( + bounded, + ToolJson.Options); + + Assert.Equal(4, bounded.TotalJobs); + Assert.True(bounded.Truncated); + Assert.NotEmpty(bounded.Jobs); + Assert.True(responseBytes <= 4_000, $"response used {responseBytes} bytes"); + Assert.DoesNotContain( + secret, + JsonSerializer.Serialize(kept, ToolJson.Options), + StringComparison.Ordinal); + + string overBudget = new('x', 65); + McpException tooLarge = await Assert.ThrowsAsync(() => jobs.StartAsync( + endpoint.Server, + endpoint.Pane, + overBudget, + suppressHistory: true, + maxCommandBytes: 64, + cancellationToken: token)); + Assert.Contains("65", tooLarge.Message, StringComparison.Ordinal); + Assert.Equal(4, jobs.List().TotalJobs); + + McpException tooSmall = Assert.Throws(() => jobs.List(1)); + Assert.Contains("needs at least", tooSmall.Message, StringComparison.Ordinal); + } + + [Fact] + public void A_job_records_the_socket_the_connection_resolved() + { + FakeEndpoint fromFactory = new( + new ServerConnectionOptions(socketNameFactory: () => "jobs-factory"), + new ServerGeneration(71, 701)); + FakeEndpoint fromDefault = new( + new ServerConnectionOptions(), + new ServerGeneration(72, 702)); + + JobInfo factoryJob = fromFactory.Job("job-factory").Describe(); + JobInfo defaultJob = fromDefault.Job("job-default").Describe(); + + Assert.Equal("jobs-factory", factoryJob.SocketName); + Assert.Null(factoryJob.SocketPath); + Assert.Equal("default", defaultJob.SocketName); + + fromFactory.Job("job-assert").RequireSocket("jobs-factory"); + McpException mismatch = Assert.Throws( + () => fromFactory.Job("job-assert").RequireSocket("jobs-elsewhere")); + Assert.Contains("jobs-factory", mismatch.Message, StringComparison.Ordinal); + } + + [Fact] + public void A_job_records_the_socket_path_the_connection_resolved() + { + FakeEndpoint endpoint = new( + new ServerConnectionOptions(socketPath: "relative-socket"), + new ServerGeneration(73, 703)); + + JobInfo described = endpoint.Job("job-path").Describe(); + + Assert.Null(described.SocketName); + Assert.Equal(Path.GetFullPath("relative-socket"), described.SocketPath); + } + + private delegate Task CommandHandler( + IReadOnlyList arguments, + CancellationToken cancellationToken); + + private sealed class FakeEndpoint + { + private readonly TmuxConnection _connection; + private int _jobDispatched; + + internal FakeEndpoint(string socketName, ServerGeneration generation) + : this(new ServerConnectionOptions(socketName: socketName), generation) + { + } + + internal FakeEndpoint(ServerConnectionOptions options, ServerGeneration generation) + { + Generation = generation; + _connection = new TmuxConnection( + options, + ExecuteAsync, + implementation: TmuxImplementation.Tmux); + Server = new Server(_connection, generation, "tmux 3.7"); + Pane = new Pane( + Server, + _connection, + generation, + new PaneId(1), + new Dictionary(StringComparer.Ordinal) + { + ["pane_id"] = "%1", + ["pane_width"] = "80", + ["pane_height"] = "24", + }); + } + + internal ConcurrentQueue Commands { get; } = new(); + + internal ServerGeneration Generation { get; } + + internal Server Server { get; } + + internal Pane Pane { get; } + + internal IReadOnlyList CaptureLines { get; init; } = []; + + internal IReadOnlyList PreJobCaptureLines { get; init; } = []; + + internal CommandHandler? Handler { get; set; } + + internal JobStore.StoredJob Job(string jobId) => new( + jobId, + Server, + Pane, + commandBytes: 4, + token: new WriteTools.RunToken("run-token")); + + internal TmuxCommandResult Success(IReadOnlyList arguments) => + Result( + arguments, + exitCode: 0, + standardOutput: Output(arguments)); + + internal void MarkJobDispatched() => Volatile.Write(ref _jobDispatched, 1); + + internal static TmuxCommandResult Result( + IReadOnlyList arguments, + int exitCode, + string standardOutput = "", + string standardError = "") + { + byte[] stdout = Encoding.UTF8.GetBytes(standardOutput); + byte[] stderr = Encoding.UTF8.GetBytes(standardError); + return new TmuxCommandResult( + arguments, + exitCode, + stdout, + stderr, + Utf8BackslashDecoder.ProjectOutputLines(stdout), + Utf8BackslashDecoder.ProjectErrorLines(stderr)); + } + + private async Task ExecuteAsync( + TmuxCommandRequest request, + CancellationToken cancellationToken) + { + string[] arguments = [.. request.LogicalArguments]; + Commands.Enqueue(arguments); + TmuxCommandResult result; + if (Handler is not null) + { + result = await Handler(arguments, cancellationToken).ConfigureAwait(false); + } + else + { + if (arguments.Length > 0 && arguments[0] == "wait-for") + { + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken) + .ConfigureAwait(false); + } + + result = Success(arguments); + } + + if (result.ExitCode == 0 && arguments.Contains("send-keys", StringComparer.Ordinal)) + { + Volatile.Write(ref _jobDispatched, 1); + } + + return result; + } + + private static bool IsGuarded(IReadOnlyList arguments) => + arguments.Count > 2 + && arguments[0] == "display-message" + && arguments[2] == "#{pid}:#{start_time}"; + + private string Output(IReadOnlyList arguments) + { + string commandOutput; + if (arguments.Count > 0 + && arguments.Any(argument => argument.Contains( + "#{history_size}", + StringComparison.Ordinal))) + { + commandOutput = "4242\t0\t50000\t24\t0\t0\t0\n"; + } + else if (arguments.Contains("capture-pane", StringComparer.Ordinal)) + { + IReadOnlyList lines = Volatile.Read(ref _jobDispatched) == 0 + ? PreJobCaptureLines + : CaptureLines; + commandOutput = lines.Count == 0 + ? string.Empty + : string.Join('\n', lines) + "\n"; + } + else + { + commandOutput = string.Empty; + } + + return IsGuarded(arguments) + ? $"{Generation.ProcessId}:{Generation.StartTime}\n{commandOutput}" + : commandOutput; + } + } + + private static string ExtractRunId(IReadOnlyList arguments) + { + string payload = Assert.Single( + arguments, + argument => argument.Contains("lt_r_", StringComparison.Ordinal)); + int start = payload.IndexOf("lt_r_", StringComparison.Ordinal) + "lt_r_".Length; + return payload.Substring(start, 10); + } + + private sealed class RecordingLogger : ILogger + { + internal ConcurrentQueue<(EventId EventId, Exception? Error)> Entries { get; } = new(); + + public IDisposable? BeginScope(TState state) + where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) => + Entries.Enqueue((eventId, exception)); + } + + private sealed class ThrowingLogger : ILogger + { + public IDisposable? BeginScope(TState state) + where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) => + throw new InvalidOperationException("logger failed"); + } +} diff --git a/tests/LibTmux.UnitTests/Mcp/PaneActivityHubLifecycleTests.cs b/tests/LibTmux.UnitTests/Mcp/PaneActivityHubLifecycleTests.cs new file mode 100644 index 0000000..29b4d57 --- /dev/null +++ b/tests/LibTmux.UnitTests/Mcp/PaneActivityHubLifecycleTests.cs @@ -0,0 +1,358 @@ +using System.Runtime.Versioning; +using System.Threading.Channels; +using LibTmux.Mcp; + +namespace LibTmux.UnitTests.Mcp; + +[UnsupportedOSPlatform("windows")] +public sealed class PaneActivityHubLifecycleTests +{ + [Fact] + public async Task A_later_watch_restarts_after_the_control_stream_ends() + { + CancellationToken token = TestContext.Current.CancellationToken; + await using PaneActivityHub hub = new(); + FakeControlModeSession first = new(); + FakeControlModeSession second = new(); + int starts = 0; + + Task Start(CancellationToken _) + { + IControlModeSession session = starts++ switch + { + 0 => first, + 1 => second, + _ => throw new InvalidOperationException("The hub started too many clients."), + }; + return Task.FromResult(session); + } + + IAsyncDisposable firstLease = await hub.WatchAsync("$1", Start, token); + Assert.True(hub.IsStreaming); + object signal = Assert.IsAssignableFrom(hub.CaptureSignal("%1")); + first.Emit(new TmuxOutputEvent("%1", "changed")); + Assert.True(await hub.WaitForActivityAsync( + "%1", + signal, + TimeSpan.FromSeconds(1), + token)); + + first.EndUnexpectedly(); + await first.Disposed.Task.WaitAsync(token); + Assert.False(hub.IsStreaming); + Assert.Null(hub.CaptureSignal("%1")); + + IAsyncDisposable secondLease = await hub.WatchAsync("$1", Start, token); + Assert.True(hub.IsStreaming); + Assert.Equal(2, starts); + Assert.Equal(["refresh-client -f ignore-size"], first.Commands); + Assert.Equal(["refresh-client -f ignore-size"], second.Commands); + + await firstLease.DisposeAsync(); + await secondLease.DisposeAsync(); + await second.Disposed.Task.WaitAsync(token); + Assert.Equal(1, first.DisposeCalls); + Assert.Equal(1, second.DisposeCalls); + } + + [Fact] + public async Task Restart_waits_for_dead_client_cleanup_without_losing_the_watch() + { + CancellationToken token = TestContext.Current.CancellationToken; + PaneActivityHub hub = new(); + FakeControlModeSession first = new(pauseDisposal: true); + FakeControlModeSession second = new(); + List leases = []; + int starts = 0; + + Task Start(CancellationToken _) + { + IControlModeSession session = starts++ == 0 ? first : second; + return Task.FromResult(session); + } + + try + { + leases.Add(await hub.WatchAsync("$1", Start, token)); + first.EndUnexpectedly(); + await first.DisposeStarted.Task.WaitAsync(token); + Assert.False(hub.IsStreaming); + + Task restart = hub.WatchAsync("$1", Start, token); + Assert.Equal(1, starts); + Assert.False(restart.IsCompleted); + + first.AllowDisposal(); + leases.Add(await restart.WaitAsync(token)); + Assert.Equal(2, starts); + Assert.True(hub.IsStreaming); + + foreach (IAsyncDisposable lease in leases) + { + await lease.DisposeAsync(); + } + + await second.Disposed.Task.WaitAsync(token); + Assert.Equal(1, first.DisposeCalls); + Assert.Equal(1, second.DisposeCalls); + } + finally + { + first.AllowDisposal(); + foreach (IAsyncDisposable lease in leases) + { + await lease.DisposeAsync(); + } + + await hub.DisposeAsync(); + } + } + + [Fact] + public async Task Last_release_cannot_retire_a_watch_during_a_new_acquisition() + { + CancellationToken token = TestContext.Current.CancellationToken; + PaneActivityHub hub = new(); + FakeControlModeSession first = new(); + FakeControlModeSession second = new(); + TaskCompletionSource secondStartEntered = new( + TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource allowSecondStart = new( + TaskCreationOptions.RunContinuationsAsynchronously); + IAsyncDisposable? firstLease = null; + IAsyncDisposable? secondLease = null; + int starts = 0; + + async Task Start(CancellationToken _) + { + if (starts++ == 0) + { + return first; + } + + secondStartEntered.TrySetResult(); + return await allowSecondStart.Task.ConfigureAwait(false); + } + + try + { + firstLease = await hub.WatchAsync("$1", Start, token); + first.EndUnexpectedly(); + await first.Disposed.Task.WaitAsync(token); + + Task acquiring = hub.WatchAsync("$1", Start, token); + await secondStartEntered.Task.WaitAsync(token); + Task releasing = firstLease.DisposeAsync().AsTask(); + Assert.False(releasing.IsCompleted); + + allowSecondStart.TrySetResult(second); + secondLease = await acquiring.WaitAsync(token); + await releasing.WaitAsync(token); + + Assert.True(hub.IsStreaming); + Assert.Equal(2, starts); + Assert.Equal(0, second.DisposeCalls); + + await secondLease.DisposeAsync(); + await second.Disposed.Task.WaitAsync(token); + Assert.Equal(1, second.DisposeCalls); + } + finally + { + allowSecondStart.TrySetResult(second); + if (firstLease is not null) + { + await firstLease.DisposeAsync(); + } + + if (secondLease is not null) + { + await secondLease.DisposeAsync(); + } + + await hub.DisposeAsync(); + } + } + + [Fact] + public async Task Failed_start_cannot_remove_a_concurrent_retry_watch() + { + CancellationToken token = TestContext.Current.CancellationToken; + await using PaneActivityHub hub = new(); + FakeControlModeSession replacement = new(); + TaskCompletionSource failingStartEntered = new( + TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource finishFailingStart = new( + TaskCreationOptions.RunContinuationsAsynchronously); + int replacementStarts = 0; + + async Task Fail(CancellationToken _) + { + failingStartEntered.TrySetResult(); + return await finishFailingStart.Task.ConfigureAwait(false); + } + + Task Retry(CancellationToken _) + { + replacementStarts++; + return Task.FromResult(replacement); + } + + Task failed = hub.WatchAsync("$1", Fail, token); + await failingStartEntered.Task.WaitAsync(token); + Task retry = hub.WatchAsync("$1", Retry, token); + + finishFailingStart.TrySetException(new LibTmuxException("expected start failure")); + await using IAsyncDisposable unavailable = await failed.WaitAsync(token); + await using IAsyncDisposable acquired = await retry.WaitAsync(token); + + Assert.Equal(1, replacementStarts); + Assert.True(hub.IsStreaming); + + await acquired.DisposeAsync(); + await replacement.Disposed.Task.WaitAsync(token); + Assert.Equal(1, replacement.DisposeCalls); + } + + [Fact] + public async Task Unavailable_session_polls_while_another_session_streams() + { + CancellationToken token = TestContext.Current.CancellationToken; + await using PaneActivityHub hub = new(); + FakeControlModeSession streaming = new(); + + await using IAsyncDisposable streamingLease = await hub.WatchAsync( + "endpoint-a", + "$1", + _ => Task.FromResult(streaming), + token); + await using IAsyncDisposable unavailableLease = await hub.WatchAsync( + "endpoint-b", + "$1", + _ => Task.FromException( + new LibTmuxException("expected start failure")), + token); + + Assert.NotNull(hub.CaptureSignal("endpoint-a", "$1", "%1")); + object? unavailableSignal = hub.CaptureSignal("endpoint-b", "$1", "%1"); + Assert.Null(unavailableSignal); + + bool activity = await hub.WaitForActivityAsync( + "%1", + unavailableSignal, + TimeSpan.FromSeconds(5), + token) + .WaitAsync(TimeSpan.FromSeconds(1), token); + + Assert.False(activity); + } + + [Fact] + public async Task Equal_ids_on_different_endpoints_do_not_share_signals() + { + CancellationToken token = TestContext.Current.CancellationToken; + await using PaneActivityHub hub = new(); + FakeControlModeSession first = new(); + FakeControlModeSession second = new(); + + await using IAsyncDisposable firstLease = await hub.WatchAsync( + "endpoint-a", + "$1", + _ => Task.FromResult(first), + token); + await using IAsyncDisposable secondLease = await hub.WatchAsync( + "endpoint-b", + "$1", + _ => Task.FromResult(second), + token); + + object firstSignal = Assert.IsAssignableFrom( + hub.CaptureSignal("endpoint-a", "$1", "%1")); + object secondSignal = Assert.IsAssignableFrom( + hub.CaptureSignal("endpoint-b", "$1", "%1")); + Task firstWait = hub.WaitForActivityAsync( + "%1", + firstSignal, + TimeSpan.FromSeconds(1), + token); + Task secondWait = hub.WaitForActivityAsync( + "%1", + secondSignal, + TimeSpan.FromSeconds(1), + token); + + first.Emit(new TmuxOutputEvent("%1", "first")); + Assert.True(await firstWait.WaitAsync(token)); + Assert.False(secondWait.IsCompleted); + + second.Emit(new TmuxOutputEvent("%1", "second")); + Assert.True(await secondWait.WaitAsync(token)); + } + + private sealed class FakeControlModeSession : IControlModeSession + { + private readonly Channel _events = Channel.CreateUnbounded(); + private readonly TaskCompletionSource? _allowDisposal; + private int _disposeCalls; + private int _disposed; + private int _running = 1; + + internal FakeControlModeSession(bool pauseDisposal = false) + { + if (pauseDisposal) + { + _allowDisposal = new(TaskCreationOptions.RunContinuationsAsynchronously); + } + } + + internal List Commands { get; } = []; + + internal TaskCompletionSource Disposed { get; } = new( + TaskCreationOptions.RunContinuationsAsynchronously); + + internal TaskCompletionSource DisposeStarted { get; } = new( + TaskCreationOptions.RunContinuationsAsynchronously); + + internal int DisposeCalls => Volatile.Read(ref _disposeCalls); + + public IAsyncEnumerable Events => _events.Reader.ReadAllAsync(); + + public bool IsRunning => Volatile.Read(ref _running) != 0; + + public Task> SendAsync( + string command, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + Commands.Add(command); + return Task.FromResult>([]); + } + + public async ValueTask DisposeAsync() + { + Interlocked.Increment(ref _disposeCalls); + if (Interlocked.Exchange(ref _disposed, 1) == 0) + { + Volatile.Write(ref _running, 0); + _events.Writer.TryComplete(); + DisposeStarted.TrySetResult(); + if (_allowDisposal is not null) + { + await _allowDisposal.Task.ConfigureAwait(false); + } + + Disposed.TrySetResult(); + } + } + + internal void AllowDisposal() => _allowDisposal?.TrySetResult(); + + internal void Emit(TmuxEvent item) => _events.Writer.TryWrite(item); + + internal void EndUnexpectedly() + { + Volatile.Write(ref _running, 0); + _events.Writer.TryComplete(); + } + } +} diff --git a/tests/LibTmux.UnitTests/Mcp/PaneReaderTests.cs b/tests/LibTmux.UnitTests/Mcp/PaneReaderTests.cs new file mode 100644 index 0000000..2453a96 --- /dev/null +++ b/tests/LibTmux.UnitTests/Mcp/PaneReaderTests.cs @@ -0,0 +1,108 @@ +using System.Runtime.Versioning; +using LibTmux.Internal; +using LibTmux.Mcp; + +namespace LibTmux.UnitTests.Mcp; + +[UnsupportedOSPlatform("windows")] +public sealed class PaneReaderTests +{ + [Fact] + public void A_redrawn_row_below_the_cursor_does_not_replay_the_rows_beside_it() + { + TailCursor cursor = CursorOver(["prompt$ ", "one", "two", "three"]); + + List reported = PaneReader.DropAlreadySeen( + ["prompt$ ", "one", "two", "redrawn"], + cursor); + + Assert.Equal(["redrawn"], reported); + } + + [Fact] + public void A_rewritten_row_between_unchanged_rows_is_the_only_one_reported() + { + TailCursor cursor = CursorOver(["prompt$ ", "one", "two", "three"]); + + List reported = PaneReader.DropAlreadySeen( + ["prompt$ ", "one", "rewritten", "three"], + cursor); + + Assert.Equal(["rewritten"], reported); + } + + [Fact] + public void An_unchanged_screen_reports_nothing() + { + TailCursor cursor = CursorOver(["prompt$ ", "one", "two", "three"]); + + List reported = PaneReader.DropAlreadySeen( + ["prompt$ ", "one", "two", "three"], + cursor); + + Assert.Empty(reported); + } + + [Fact] + public void Rows_written_past_the_previous_screen_are_reported_in_order() + { + TailCursor cursor = CursorOver(["prompt$ ", "one", "two"]); + + List reported = PaneReader.DropAlreadySeen( + ["prompt$ ", "one", "two", "three", "four"], + cursor); + + Assert.Equal(["three", "four"], reported); + } + + [Fact] + public void A_rewritten_anchor_row_is_reported_with_the_rows_that_changed() + { + TailCursor cursor = CursorOver(["prompt$ ", "one", "two"]); + + List reported = PaneReader.DropAlreadySeen( + ["prompt$ typed", "one", "changed"], + cursor); + + Assert.Equal(["prompt$ typed", "changed"], reported); + } + + [Fact] + public void Rows_past_the_tracked_window_are_reported_rather_than_guessed() + { + string[] before = ["prompt$ ", .. Enumerable.Range(0, 40).Select(index => $"row {index}")]; + string[] after = [.. before]; + after[^1] = "row 39 redrawn"; + TailCursor cursor = CursorOver(before); + + List reported = PaneReader.DropAlreadySeen(after, cursor); + + Assert.Equal(before[33..^1].Append("row 39 redrawn"), reported); + } + + private static TailCursor CursorOver(IReadOnlyList cursorRows) => TailCursor.Build( + Pane(), + new PaneGridState("313", 2, 1_000, 64, 1, false, false), + cursorRows); + + private static Pane Pane() + { + var connection = new TmuxConnection( + new ServerConnectionOptions(socketName: "pane-reader"), + 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, + connection, + new ServerGeneration(17, 9001), + new PaneId(1), + new Dictionary(StringComparer.Ordinal)); + } +} diff --git a/tests/LibTmux.UnitTests/Mcp/PasteTextCleanupTests.cs b/tests/LibTmux.UnitTests/Mcp/PasteTextCleanupTests.cs new file mode 100644 index 0000000..ae87fb1 --- /dev/null +++ b/tests/LibTmux.UnitTests/Mcp/PasteTextCleanupTests.cs @@ -0,0 +1,303 @@ +using System.Collections.Concurrent; +using System.Runtime.Versioning; +using System.Text; +using LibTmux.Internal; +using LibTmux.Mcp; + +namespace LibTmux.UnitTests.Mcp; + +[UnsupportedOSPlatform("windows")] +public sealed class PasteTextCleanupTests +{ + [Fact] + public async Task Cancellation_during_paste_uses_an_independent_cleanup_token() + { + await using var fixture = new PasteFixture(failPaste: true); + using CancellationTokenSource cancellation = CancellationTokenSource.CreateLinkedTokenSource( + TestContext.Current.CancellationToken); + + Task pasting = fixture.Tools.PasteTextAsync( + "secret text", + paneId: "%1", + cancellationToken: cancellation.Token); + await fixture.PrimaryStarted.Task.WaitAsync(TestContext.Current.CancellationToken); + await cancellation.CancelAsync(); + fixture.ReleasePrimary(); + + Exception? failure = await Record.ExceptionAsync(() => pasting); + + Assert.Same(fixture.PrimaryFailure, failure); + Assert.False(fixture.DeleteTokenWasCancelled); + Assert.Equal(fixture.CreatedBuffer, fixture.DeletedBuffer); + Assert.Empty(fixture.Buffers); + } + + [Fact] + public async Task Ambiguous_set_buffer_failure_still_cleans_the_possible_buffer() + { + await using var fixture = new PasteFixture(failSetBuffer: true); + using CancellationTokenSource cancellation = CancellationTokenSource.CreateLinkedTokenSource( + TestContext.Current.CancellationToken); + + Task pasting = fixture.Tools.PasteTextAsync( + "secret text", + paneId: "%1", + cancellationToken: cancellation.Token); + await fixture.PrimaryStarted.Task.WaitAsync(TestContext.Current.CancellationToken); + await cancellation.CancelAsync(); + fixture.ReleasePrimary(); + + Exception? failure = await Record.ExceptionAsync(() => pasting); + + Assert.Same(fixture.PrimaryFailure, failure); + Assert.False(fixture.DeleteTokenWasCancelled); + Assert.Equal(fixture.CreatedBuffer, fixture.DeletedBuffer); + Assert.Empty(fixture.Buffers); + } + + [Fact] + public async Task Not_dispatched_set_buffer_failure_does_not_delete_an_unowned_buffer() + { + await using var fixture = new PasteFixture(setBufferNotDispatched: true); + + Exception? failure = await Record.ExceptionAsync(() => + fixture.Tools.PasteTextAsync( + "secret text", + paneId: "%1", + cancellationToken: TestContext.Current.CancellationToken)); + + Assert.Same(fixture.PrimaryFailure, failure); + Assert.Null(fixture.CreatedBuffer); + Assert.Null(fixture.DeletedBuffer); + } + + [Fact] + public async Task Cleanup_failure_is_attached_without_replacing_the_primary_failure() + { + await using var fixture = new PasteFixture(failPaste: true, failDelete: true); + + Task pasting = fixture.Tools.PasteTextAsync( + "secret text", + paneId: "%1", + cancellationToken: TestContext.Current.CancellationToken); + await fixture.PrimaryStarted.Task.WaitAsync(TestContext.Current.CancellationToken); + fixture.ReleasePrimary(); + + InvalidOperationException failure = await Assert.ThrowsAsync( + () => pasting); + + Assert.Same(fixture.PrimaryFailure, failure); + Assert.Same( + fixture.CleanupFailure, + failure.Data[WriteTools.PasteBufferCleanupFailureDataKey]); + Assert.Equal( + fixture.CreatedBuffer, + failure.Data[WriteTools.PasteBufferCleanupBufferDataKey]); + } + + [Fact] + public async Task Cleanup_failure_after_a_successful_paste_returns_do_not_retry_warning() + { + await using var fixture = new PasteFixture(failDelete: true); + + ActionResult result = await fixture.Tools.PasteTextAsync( + "secret text", + paneId: "%1", + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Contains("cleanup failed", result.Changed, StringComparison.Ordinal); + Assert.Contains(fixture.CreatedBuffer!, result.Changed, StringComparison.Ordinal); + Assert.Contains("Do not retry", result.Changed, StringComparison.Ordinal); + Assert.Contains("tmux_list_buffers", result.Changed, StringComparison.Ordinal); + Assert.Contains( + $"tmux delete-buffer -b {fixture.CreatedBuffer}", + result.Changed, + StringComparison.Ordinal); + Assert.Equal("%1", result.PaneId); + Assert.False(fixture.DeleteTokenWasCancelled); + Assert.Equal(fixture.CreatedBuffer, fixture.DeletedBuffer); + Assert.Single(fixture.Buffers); + } + + private sealed class PasteFixture : IAsyncDisposable + { + private static readonly ServerGeneration Generation = new(81, 801); + + private readonly bool _failSetBuffer; + private readonly bool _setBufferNotDispatched; + private readonly bool _failPaste; + private readonly bool _failDelete; + private readonly TaskCompletionSource _releasePrimary = new( + TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TmuxConnectionAccessor _accessor; + private readonly PaneActivityHub _activity = new(); + private readonly JobStore _jobs = new(); + + internal PasteFixture( + bool failSetBuffer = false, + bool failPaste = false, + bool failDelete = false, + bool setBufferNotDispatched = false) + { + _failSetBuffer = failSetBuffer; + _failPaste = failPaste; + _failDelete = failDelete; + _setBufferNotDispatched = setBufferNotDispatched; + PrimaryFailure = failSetBuffer + ? new TmuxOperationCanceledException( + "set-buffer may have executed", + CancellationToken.None, + commandMayHaveExecuted: true, + clientProcessId: 801) + : setBufferNotDispatched + ? new TmuxTransportException( + "set-buffer was not dispatched", + ["set-buffer"], + TmuxDispatchState.NotDispatched) + : new InvalidOperationException("paste failed"); + var connection = new TmuxConnection( + new ServerConnectionOptions(socketName: "paste-cleanup-test"), + ExecuteAsync, + implementation: TmuxImplementation.Tmux); + var server = new Server(connection, Generation, "tmux 3.7"); + _accessor = new TmuxConnectionAccessor(server); + Tools = new WriteTools(_accessor, new ServerPolicy(), _activity, _jobs); + } + + internal ConcurrentDictionary Buffers { get; } = new( + StringComparer.Ordinal); + + internal string? CreatedBuffer { get; private set; } + + internal IOException CleanupFailure { get; } = new("cleanup failed"); + + internal string? DeletedBuffer { get; private set; } + + internal bool DeleteTokenWasCancelled { get; private set; } + + internal Exception PrimaryFailure { get; } + + internal TaskCompletionSource PrimaryStarted { get; } = new( + TaskCreationOptions.RunContinuationsAsynchronously); + + internal WriteTools Tools { get; } + + public async ValueTask DisposeAsync() + { + _releasePrimary.TrySetResult(); + await _jobs.DisposeAsync().ConfigureAwait(false); + await _activity.DisposeAsync().ConfigureAwait(false); + _accessor.Dispose(); + } + + internal void ReleasePrimary() => _releasePrimary.TrySetResult(); + + private async Task ExecuteAsync( + TmuxCommandRequest request, + CancellationToken cancellationToken) + { + string[] arguments = [.. request.LogicalArguments]; + if (arguments.Contains("list-panes", StringComparer.Ordinal)) + { + return Success(arguments, PaneListing()); + } + + if (arguments.Contains("set-buffer", StringComparer.Ordinal)) + { + if (_setBufferNotDispatched) + { + throw PrimaryFailure; + } + + string buffer = ValueAfter(arguments, "-b"); + CreatedBuffer = buffer; + Buffers[buffer] = arguments[^1]; + if (_failSetBuffer) + { + await FailPrimaryAsync().ConfigureAwait(false); + } + + return Success(arguments); + } + + if (arguments.Contains("paste-buffer", StringComparer.Ordinal)) + { + if (_failPaste) + { + await FailPrimaryAsync().ConfigureAwait(false); + } + + return Success(arguments); + } + + if (arguments.Contains("delete-buffer", StringComparer.Ordinal)) + { + DeleteTokenWasCancelled = cancellationToken.IsCancellationRequested; + string deletedBuffer = ValueAfter(arguments, "-b"); + DeletedBuffer = deletedBuffer; + if (_failDelete) + { + throw CleanupFailure; + } + + Buffers.TryRemove(deletedBuffer, out _); + return Success(arguments); + } + + return Success(arguments); + } + + private async Task FailPrimaryAsync() + { + PrimaryStarted.TrySetResult(); + await _releasePrimary.Task.ConfigureAwait(false); + throw PrimaryFailure; + } + + private static string PaneListing() + { + FormatProjection projection = FormatProjection.Create( + "list-panes", + TmuxVersion.Parse("3.7")); + return string.Concat(projection.Fields.Select( + static field => FieldValue(field.WireName) + FormatProjection.RowSeparator)) + "\n"; + } + + private static string FieldValue(string field) => field switch + { + "pid" => Generation.ProcessId.ToString(System.Globalization.CultureInfo.InvariantCulture), + "start_time" => Generation.StartTime.ToString( + System.Globalization.CultureInfo.InvariantCulture), + "session_id" => "$1", + "window_id" => "@1", + "pane_id" => "%1", + "pane_width" => "80", + "pane_height" => "24", + "pane_active" => "1", + _ => string.Empty, + }; + + private static string ValueAfter(string[] arguments, string option) + { + int index = Array.IndexOf(arguments, option); + return index >= 0 && index + 1 < arguments.Length + ? arguments[index + 1] + : throw new InvalidOperationException($"Missing {option} in tmux command."); + } + + private static TmuxCommandResult Success( + IReadOnlyList arguments, + string payload = "") + { + string standardOutput = $"{Generation.ProcessId}:{Generation.StartTime}\n{payload}"; + byte[] output = Encoding.UTF8.GetBytes(standardOutput); + return new TmuxCommandResult( + arguments, + 0, + output, + ReadOnlyMemory.Empty, + Utf8BackslashDecoder.ProjectOutputLines(output), + []); + } + } +} diff --git a/tests/LibTmux.UnitTests/Mcp/PolicyAndBudgetTests.cs b/tests/LibTmux.UnitTests/Mcp/PolicyAndBudgetTests.cs index 81ddc01..3f2b6de 100644 --- a/tests/LibTmux.UnitTests/Mcp/PolicyAndBudgetTests.cs +++ b/tests/LibTmux.UnitTests/Mcp/PolicyAndBudgetTests.cs @@ -97,18 +97,47 @@ public void A_byte_budget_applies_after_the_line_budget() // byte budget. BoundedText fitted = BoundedText.Fit([new string('x', 100), "short"], 10, 20); - Assert.Equal(["short"], fitted.Lines); + Assert.Equal([new string('x', 14), "short"], fitted.Lines); Assert.True(fitted.Truncated); - Assert.Equal(1, fitted.DroppedLines); + Assert.Equal(0, fitted.DroppedLines); + Assert.Equal(86, fitted.DroppedBytes); } [Fact] - public void The_newest_line_survives_even_when_it_alone_overruns() + public void An_oversized_multibyte_line_is_clipped_on_a_character_boundary() { - BoundedText fitted = BoundedText.Fit(["old", new string('x', 500)], 10, 20); + string oversized = string.Concat(Enumerable.Repeat("\U0001f642", 10)); + + BoundedText fitted = BoundedText.Fit(["old", oversized], 10, 11); Assert.Single(fitted.Lines); - Assert.Equal(500, fitted.Lines[0].Length); + Assert.Equal("\U0001f642\U0001f642", fitted.Lines[0]); + Assert.Equal(8, Encoding.UTF8.GetByteCount(string.Join('\n', fitted.Lines))); + Assert.Equal(1, fitted.DroppedLines); + Assert.Equal(36, fitted.DroppedBytes); + Assert.DoesNotContain('\ufffd', fitted.Lines[0]); + } + + [Fact] + public void Every_result_obeys_the_utf8_byte_ceiling() + { + BoundedText fitted = BoundedText.Fit(["earlier", "\U0001f642abcdef", "new"], 10, 6); + + Assert.Equal(["ef", "new"], fitted.Lines); + Assert.True(Encoding.UTF8.GetByteCount(string.Join('\n', fitted.Lines)) <= 6); + Assert.Equal(1, fitted.DroppedLines); + Assert.Equal(16, fitted.DroppedBytes); + } + + [Fact] + public void A_character_that_cannot_fit_is_reported_as_fully_dropped() + { + BoundedText fitted = BoundedText.Fit(["\U0001f642"], 10, 1); + + Assert.Empty(fitted.Lines); + Assert.True(fitted.Truncated); + Assert.Equal(1, fitted.DroppedLines); + Assert.Equal(4, fitted.DroppedBytes); } [Fact] @@ -136,7 +165,7 @@ public void A_truncated_result_says_so_before_the_text() // never printed them. string rendered = BoundedText.Fit(["a", "b", "c"], 1, 1000).ToDisplayString(); - Assert.StartsWith("[2 earlier lines", rendered, StringComparison.Ordinal); + Assert.StartsWith("[2 complete earlier lines", rendered, StringComparison.Ordinal); Assert.EndsWith("c", rendered, StringComparison.Ordinal); } } diff --git a/tests/LibTmux.UnitTests/Mcp/ReadToolsHistoryTests.cs b/tests/LibTmux.UnitTests/Mcp/ReadToolsHistoryTests.cs new file mode 100644 index 0000000..167482a --- /dev/null +++ b/tests/LibTmux.UnitTests/Mcp/ReadToolsHistoryTests.cs @@ -0,0 +1,243 @@ +using System.Collections.Concurrent; +using System.Runtime.Versioning; +using System.Text; +using LibTmux.Internal; +using LibTmux.Mcp; + +namespace LibTmux.UnitTests; + +[UnsupportedOSPlatform("windows")] +public sealed class ReadToolsHistoryTests +{ + private const string HistoryOnlyLine = "archived failure from scrollback"; + + [Fact] + public async Task Capture_history_asks_tmux_for_the_beginning_and_returns_oldest_content() + { + await using var fixture = new HistoryFixture(); + + CaptureResult result = await fixture.Tools.CapturePaneAsync( + paneId: "%1", + includeHistory: true, + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Contains(HistoryOnlyLine, result.Content.Lines); + AssertBeginningOfHistoryCapture(fixture.Commands); + } + + [Fact] + public async Task Search_history_asks_tmux_for_the_beginning_and_finds_oldest_content() + { + await using var fixture = new HistoryFixture(); + + SearchResult result = await fixture.Tools.SearchPanesAsync( + pattern: "archived failure", + includeHistory: true, + ignoreCase: false, + cancellationToken: TestContext.Current.CancellationToken); + + PaneMatch pane = Assert.Single(result.Panes); + MatchedLine match = Assert.Single(pane.Matches); + Assert.Equal(HistoryOnlyLine, match.Text); + Assert.Equal(-1, match.Row); + AssertBeginningOfHistoryCapture(fixture.Commands); + } + + [Fact] + public async Task Streaming_full_history_sentinel_reaches_the_beginning() + { + await using var fixture = new HistoryFixture(); + CancellationToken token = TestContext.Current.CancellationToken; + Pane pane = Assert.Single(await fixture.Server.GetPanesAsync(token)); + + IReadOnlyList lines = await PaneReader.CaptureAsync( + pane, + int.MinValue, + token); + + Assert.Contains(HistoryOnlyLine, lines); + AssertBeginningOfHistoryCapture(fixture.Commands); + } + + [Fact] + public void Streaming_anchor_scan_observes_cancellation_between_candidates() + { + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource( + TestContext.Current.CancellationToken); + var rows = new CancellingRows(cancellation, cancelAt: 8, count: 1_000); + var cursor = new TailCursor( + Version: 3, + EndpointFingerprint: "endpoint", + ServerProcessId: 1, + ServerStartTime: 2, + PaneId: "%1", + PanePid: "3", + HistorySize: 1_000, + PaneHeight: 24, + AnchorAbsolute: 500, + AnchorHash: TailCursor.HashLine("absent anchor"), + BelowCount: 0, + BelowHash: null, + SuffixCount: 0, + SuffixHash: null, + RowHashes: null); + + Assert.Throws(() => + PaneReader.FindUniqueAnchor(rows, cursor, cancellation.Token)); + Assert.InRange(rows.Reads, 8, 9); + } + + private static void AssertBeginningOfHistoryCapture( + IEnumerable commands) + { + string[] command = Assert.Single( + commands, + static arguments => arguments.Contains( + "capture-pane", + StringComparer.Ordinal)); + int captureIndex = Array.IndexOf(command, "capture-pane"); + + Assert.True(captureIndex >= 0); + Assert.True( + HasBeginningOfHistory(command, captureIndex), + $"Expected capture-pane -S -, got: {string.Join(' ', command[captureIndex..])}"); + } + + private static bool HasBeginningOfHistory(string[] arguments, int start) => + Enumerable.Range(start, arguments.Length - start - 1) + .Any(index => arguments[index] == "-S" && arguments[index + 1] == "-"); + + private sealed class CancellingRows( + CancellationTokenSource cancellation, + int cancelAt, + int count) : IReadOnlyList + { + public int Count { get; } = count; + + internal int Reads { get; private set; } + + public string this[int index] + { + get + { + Reads++; + if (index == cancelAt) + { + cancellation.Cancel(); + } + + return $"row {index}"; + } + } + + public IEnumerator GetEnumerator() => + Enumerable.Range(0, Count).Select(index => this[index]).GetEnumerator(); + + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => + GetEnumerator(); + } + + private sealed class HistoryFixture : IAsyncDisposable + { + private static readonly ServerGeneration Generation = new(71, 701); + private static readonly IReadOnlyList HistoryLines = + [ + HistoryOnlyLine, + .. Enumerable.Range(0, 24).Select(static index => $"visible line {index:D2}"), + ]; + + private readonly TmuxConnectionAccessor _accessor; + private readonly PaneActivityHub _activity = new(); + + internal HistoryFixture() + { + var connection = new TmuxConnection( + new ServerConnectionOptions(socketName: "history-test"), + ExecuteAsync, + implementation: TmuxImplementation.Tmux); + var server = new Server(connection, Generation, "tmux 3.7"); + Server = server; + _accessor = new TmuxConnectionAccessor(server); + Tools = new ReadTools( + _accessor, + new ServerPolicy { MaxBytes = 128_000 }, + _activity); + } + + internal ConcurrentQueue Commands { get; } = new(); + + internal ReadTools Tools { get; } + + internal Server Server { get; } + + public async ValueTask DisposeAsync() + { + await _activity.DisposeAsync().ConfigureAwait(false); + _accessor.Dispose(); + } + + private Task ExecuteAsync( + TmuxCommandRequest request, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + string[] arguments = [.. request.LogicalArguments]; + Commands.Enqueue(arguments); + + string payload = arguments.Contains("list-panes", StringComparer.Ordinal) + ? PaneListing() + : arguments.Contains("capture-pane", StringComparer.Ordinal) + ? PaneCapture(arguments) + : string.Empty; + string output = $"{Generation.ProcessId}:{Generation.StartTime}\n{payload}"; + return Task.FromResult(Result(arguments, output)); + } + + private static string PaneListing() + { + FormatProjection projection = FormatProjection.Create( + "list-panes", + TmuxVersion.Parse("3.7")); + return string.Concat(projection.Fields.Select( + static field => FieldValue(field.WireName) + FormatProjection.RowSeparator)) + "\n"; + } + + private static string PaneCapture(string[] arguments) + { + int commandIndex = Array.IndexOf(arguments, "capture-pane"); + bool fromBeginning = HasBeginningOfHistory(arguments, commandIndex); + IReadOnlyList lines = fromBeginning + ? HistoryLines + : HistoryLines.Skip(1).ToArray(); + return string.Join('\n', lines) + "\n"; + } + + private static string FieldValue(string field) => field switch + { + "pid" => Generation.ProcessId.ToString(System.Globalization.CultureInfo.InvariantCulture), + "start_time" => Generation.StartTime.ToString( + System.Globalization.CultureInfo.InvariantCulture), + "session_id" => "$1", + "window_id" => "@1", + "pane_id" => "%1", + "pane_width" => "80", + "pane_height" => "24", + "pane_active" => "1", + _ => string.Empty, + }; + + private static TmuxCommandResult Result( + IReadOnlyList arguments, + string standardOutput) + { + byte[] output = Encoding.UTF8.GetBytes(standardOutput); + return new TmuxCommandResult( + arguments, + 0, + output, + ReadOnlyMemory.Empty, + Utf8BackslashDecoder.ProjectOutputLines(output), + []); + } + } +} diff --git a/tests/LibTmux.UnitTests/Mcp/RecipePromptTests.cs b/tests/LibTmux.UnitTests/Mcp/RecipePromptTests.cs new file mode 100644 index 0000000..cc1fce5 --- /dev/null +++ b/tests/LibTmux.UnitTests/Mcp/RecipePromptTests.cs @@ -0,0 +1,85 @@ +using System.ComponentModel; +using System.Reflection; +using System.Runtime.Versioning; +using LibTmux.Mcp; + +namespace LibTmux.UnitTests.Mcp; + +[UnsupportedOSPlatform("windows")] +public sealed class RecipePromptTests +{ + [Fact] + public void Recipes_use_the_live_camel_case_schema_names() + { + string prompts = string.Join( + '\n', + RecipePrompts.RunAndReport("true", "%1"), + RecipePrompts.DiagnosePane("%1"), + RecipePrompts.BuildWorkspace("work"), + RecipePrompts.InterruptPane("%1")); + + foreach (string stale in new[] + { + "exit_status", + "timed_out", + "pane_id", + "current_command", + "alternate_screen", + "max_lines", + "timeout_seconds", + }) + { + Assert.DoesNotContain(stale, prompts, StringComparison.Ordinal); + } + + Assert.Contains("exitStatus", prompts, StringComparison.Ordinal); + Assert.Contains("timedOut", prompts, StringComparison.Ordinal); + Assert.Contains("pane.currentCommand", prompts, StringComparison.Ordinal); + Assert.Contains("alternateScreen", prompts, StringComparison.Ordinal); + Assert.Contains("paneId=", prompts, StringComparison.Ordinal); + Assert.Contains("maxLines", prompts, StringComparison.Ordinal); + } + + [Fact] + public void Tool_descriptions_use_the_live_camel_case_result_names() + { + string descriptions = string.Join( + '\n', + Describe(typeof(WriteTools), nameof(WriteTools.RunAsync)), + Describe(typeof(ReadTools), nameof(ReadTools.WaitForTextAsync)), + Describe(typeof(ReadTools), nameof(ReadTools.HierarchyAsync)), + Describe(typeof(ReadTools), nameof(ReadTools.ListPanesAsync)), + Describe(typeof(WriteTools), nameof(WriteTools.CancelJobAsync))); + + foreach (string stale in new[] + { + "lines_missed", + "anchor_lost", + "effective_timeout_seconds", + "is_caller", + "current_command", + }) + { + Assert.DoesNotContain(stale, descriptions, StringComparison.Ordinal); + } + + Assert.Contains("linesMissed", descriptions, StringComparison.Ordinal); + Assert.Contains("anchorLost", descriptions, StringComparison.Ordinal); + Assert.Contains("effectiveTimeoutSeconds", descriptions, StringComparison.Ordinal); + Assert.Contains("isCaller", descriptions, StringComparison.Ordinal); + Assert.Contains("currentCommand", descriptions, StringComparison.Ordinal); + } + + private static string Describe(Type type, string methodName) + { + MethodInfo method = type.GetMethod(methodName) + ?? throw new InvalidOperationException($"Method {type.Name}.{methodName} is absent."); + IEnumerable descriptions = method + .GetCustomAttributes() + .Select(static attribute => attribute.Description) + .Concat(method.GetParameters().SelectMany(static parameter => parameter + .GetCustomAttributes() + .Select(static attribute => attribute.Description))); + return string.Join('\n', descriptions); + } +} diff --git a/tests/LibTmux.UnitTests/Mcp/ResourceResponseBudgetFilterTests.cs b/tests/LibTmux.UnitTests/Mcp/ResourceResponseBudgetFilterTests.cs new file mode 100644 index 0000000..76d0fa2 --- /dev/null +++ b/tests/LibTmux.UnitTests/Mcp/ResourceResponseBudgetFilterTests.cs @@ -0,0 +1,264 @@ +using System.IO.Pipelines; +using System.Text; +using System.Text.Json; +using LibTmux.Mcp; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using ModelContextProtocol; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace LibTmux.UnitTests; + +public sealed class ResourceResponseBudgetFilterTests +{ + [Fact] + public async Task Oversized_resource_content_is_refused_with_budget_guidance() + { + var policy = new ServerPolicy { MaxBytes = 4_000 }; + var original = new ReadResourceResult + { + Contents = + [ + new TextResourceContents + { + Uri = "tmux://hierarchy", + MimeType = "application/json", + Text = new string('x', 8_000), + }, + ], + }; + McpRequestHandler handler = + ResourceResponseBudgetFilter.Create(policy)( + (_, _) => ValueTask.FromResult(original)); + + McpException error = await Assert.ThrowsAsync( + () => handler(null!, TestContext.Current.CancellationToken).AsTask()); + + Assert.Contains(ServerPolicy.MaxBytesVariable, error.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task A_resource_that_fits_is_not_rewritten() + { + var policy = new ServerPolicy { MaxBytes = 4_000 }; + var original = new ReadResourceResult + { + Contents = + [ + new TextResourceContents + { + Uri = "tmux://self", + MimeType = "application/json", + Text = "null", + }, + ], + }; + McpRequestHandler handler = + ResourceResponseBudgetFilter.Create(policy)( + (_, _) => ValueTask.FromResult(original)); + + ReadResourceResult actual = await handler( + null!, + TestContext.Current.CancellationToken); + + Assert.Same(original, actual); + } + + [Fact] + public async Task Resource_protocol_results_stay_within_the_complete_byte_ceiling() + { + CancellationToken token = TestContext.Current.CancellationToken; + await using ResourceProtocolHarness harness = await ResourceProtocolHarness.StartAsync(token); + int offset = harness.WireLength; + + ReadResourceResult result = await harness.Client.ReadResourceAsync( + "budget://boundary", + cancellationToken: token); + + Assert.NotEmpty(result.Contents); + Assert.InRange(harness.ResourceResultBytesSince(offset), 1, 4_000); + } + + [McpServerResourceType] + private sealed class BudgetProbeResources + { + [McpServerResource( + UriTemplate = "budget://boundary", + Name = "budget_resource_boundary", + MimeType = "text/plain")] + public static string Boundary() => new('x', 3_000); + } + + private sealed class ResourceProtocolHarness : IAsyncDisposable + { + private readonly McpServer _server; + private readonly RecordingWriteStream _wire; + private readonly ServiceProvider _services; + + private ResourceProtocolHarness( + McpServer server, + McpClient client, + RecordingWriteStream wire, + ServiceProvider services) + { + _server = server; + Client = client; + _wire = wire; + _services = services; + } + + internal McpClient Client { get; } + + internal int WireLength => _wire.RecordedLength; + + internal int ResourceResultBytesSince(int offset) + { + foreach (string frame in Encoding.UTF8.GetString(_wire.Snapshot(offset)) + .Split('\n', StringSplitOptions.RemoveEmptyEntries)) + { + using JsonDocument document = JsonDocument.Parse(frame); + if (document.RootElement.TryGetProperty("result", out JsonElement result) + && result.TryGetProperty("contents", out _)) + { + return Encoding.UTF8.GetByteCount(result.GetRawText()); + } + } + + throw new InvalidOperationException("No resource result was written."); + } + + internal static async Task StartAsync( + CancellationToken cancellationToken) + { + ServiceCollection services = new(); + services.AddLogging(); + services + .AddMcpServer() + .WithResources() + .WithRequestFilters(filters => filters.AddReadResourceFilter( + ResourceResponseBudgetFilter.Create( + new ServerPolicy { MaxBytes = 4_000 }))); + ServiceProvider provider = services.BuildServiceProvider(); + + Pipe clientToServer = new(); + Pipe serverToClient = new(); + var wire = new RecordingWriteStream(serverToClient.Writer.AsStream()); + McpServer server = McpServer.Create( + new StreamServerTransport( + clientToServer.Reader.AsStream(), + wire), + provider.GetRequiredService>().Value, + provider.GetRequiredService(), + provider); + _ = server.RunAsync(CancellationToken.None); + McpClient client = await McpClient.CreateAsync( + new StreamClientTransport( + clientToServer.Writer.AsStream(), + serverToClient.Reader.AsStream()), + cancellationToken: cancellationToken); + + return new ResourceProtocolHarness(server, client, wire, provider); + } + + public async ValueTask DisposeAsync() + { + await Client.DisposeAsync().ConfigureAwait(false); + await _server.DisposeAsync().ConfigureAwait(false); + await _services.DisposeAsync().ConfigureAwait(false); + } + } + + private sealed class RecordingWriteStream(Stream inner) : Stream + { + private readonly object _gate = new(); + private readonly MemoryStream _recording = new(); + + internal int RecordedLength + { + get + { + lock (_gate) + { + return checked((int)_recording.Length); + } + } + } + + internal byte[] Snapshot(int offset) + { + lock (_gate) + { + return _recording.ToArray()[offset..]; + } + } + + public override bool CanRead => false; + + public override bool CanSeek => false; + + public override bool CanWrite => true; + + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override void Flush() => inner.Flush(); + + public override Task FlushAsync(CancellationToken cancellationToken) => + inner.FlushAsync(cancellationToken); + + public override int Read(byte[] buffer, int offset, int count) => + throw new NotSupportedException(); + + public override long Seek(long offset, SeekOrigin origin) => + throw new NotSupportedException(); + + public override void SetLength(long value) => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) + { + Record(buffer.AsSpan(offset, count)); + inner.Write(buffer, offset, count); + } + + public override void Write(ReadOnlySpan buffer) + { + Record(buffer); + inner.Write(buffer); + } + + public override async ValueTask WriteAsync( + ReadOnlyMemory buffer, + CancellationToken cancellationToken = default) + { + Record(buffer.Span); + await inner.WriteAsync(buffer, cancellationToken).ConfigureAwait(false); + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _recording.Dispose(); + inner.Dispose(); + } + + base.Dispose(disposing); + } + + private void Record(ReadOnlySpan bytes) + { + lock (_gate) + { + _recording.Write(bytes); + } + } + } +} diff --git a/tests/LibTmux.UnitTests/Mcp/SearchResultBudgetTests.cs b/tests/LibTmux.UnitTests/Mcp/SearchResultBudgetTests.cs new file mode 100644 index 0000000..151f7c6 --- /dev/null +++ b/tests/LibTmux.UnitTests/Mcp/SearchResultBudgetTests.cs @@ -0,0 +1,350 @@ +using System.Runtime.Versioning; +using System.Text.RegularExpressions; +using LibTmux.Internal; +using LibTmux.Mcp; +using ModelContextProtocol; + +namespace LibTmux.UnitTests; + +[UnsupportedOSPlatform("windows")] +public sealed class SearchResultBudgetTests +{ + [Theory] + [InlineData(0)] + [InlineData(-1)] + [InlineData(501)] + public void A_per_pane_limit_must_fit_the_server_wide_line_budget(int requested) + { + McpException error = Assert.Throws(() => + ReadTools.ValidateSearchMatchLimit(requested, 500)); + + Assert.Contains("between 1 and 500", error.Message, StringComparison.Ordinal); + } + + [Fact] + public void Incremental_accounting_matches_the_complete_budgeted_size() + { + MatchedLine first = new(-3, "quote \" and \U0001f642"); + MatchedLine second = new(8, "line\nwith\tcontrols"); + var exact = new SearchResult( + "err(or)?", + 4, + [new PaneMatch("%1", "@2", "$3", [first, second])], + false); + int exactBytes = Utf8JsonBudget.GetStructuredToolResultByteCount(exact, ToolJson.Options); + var budget = new SearchResultBudget("err(or)?", 4, 10, exactBytes); + List matches = []; + + Assert.Equal( + SearchMatchBudgetOutcome.Added, + budget.TryAdd("%1", "@2", "$3", matches, first)); + Assert.Equal( + SearchMatchBudgetOutcome.Added, + budget.TryAdd("%1", "@2", "$3", matches, second)); + Assert.NotEqual( + SearchMatchBudgetOutcome.Added, + budget.TryAdd("%1", "@2", "$3", matches, new MatchedLine(9, "x"))); + budget.Commit("%1", "@2", "$3", matches); + + SearchResult result = budget.Build(4, false); + Assert.Equal( + exactBytes, + Utf8JsonBudget.GetStructuredToolResultByteCount(result, ToolJson.Options)); + } + + [Fact] + public void The_global_match_ceiling_applies_across_panes() + { + var budget = new SearchResultBudget("x", 10, 2, 4_000); + List firstPane = []; + List secondPane = []; + + Assert.Equal( + SearchMatchBudgetOutcome.Added, + budget.TryAdd("%1", "@1", "$1", firstPane, new MatchedLine(0, "x"))); + budget.Commit("%1", "@1", "$1", firstPane); + Assert.Equal( + SearchMatchBudgetOutcome.Added, + budget.TryAdd("%2", "@2", "$2", secondPane, new MatchedLine(0, "x"))); + Assert.Equal( + SearchMatchBudgetOutcome.GlobalLimit, + budget.TryAdd("%2", "@2", "$2", secondPane, new MatchedLine(1, "x"))); + } + + [Fact] + public void A_local_cap_on_one_pane_does_not_stop_the_next_pane() + { + var budget = new SearchResultBudget("x", 2, 10, 4_000); + Regex regex = ReadTools.CompilePattern("x", ignoreCase: false); + + SearchPaneBudgetOutcome first = ReadTools.AddSearchMatches( + budget, + "%1", + "@1", + "$1", + ["x-one", "x-two"], + 0, + regex, + maxMatchesPerPane: 1, + TestContext.Current.CancellationToken); + SearchPaneBudgetOutcome second = ReadTools.AddSearchMatches( + budget, + "%2", + "@2", + "$2", + ["x-three"], + 0, + regex, + maxMatchesPerPane: 1, + TestContext.Current.CancellationToken); + SearchResult result = budget.Build(2, truncated: true); + + Assert.Equal(SearchPaneBudgetOutcome.PerPaneLimit, first); + Assert.Equal(SearchPaneBudgetOutcome.Complete, second); + Assert.Equal(2, result.Panes.Count); + Assert.Equal("x-three", result.Panes[1].Matches[0].Text); + } + + [Fact] + public void An_oversized_match_does_not_hide_a_later_small_match() + { + var budget = new SearchResultBudget("x", 1, 10, 4_000); + Regex regex = ReadTools.CompilePattern("x", ignoreCase: false); + + SearchPaneBudgetOutcome outcome = ReadTools.AddSearchMatches( + budget, + "%1", + "@1", + "$1", + ["x" + new string('z', 1_000_000), "x-small"], + 0, + regex, + maxMatchesPerPane: 10, + TestContext.Current.CancellationToken); + SearchResult result = budget.Build(1, truncated: outcome != SearchPaneBudgetOutcome.Complete); + + Assert.Equal(SearchPaneBudgetOutcome.OversizedMatchSkipped, outcome); + PaneMatch pane = Assert.Single(result.Panes); + MatchedLine match = Assert.Single(pane.Matches); + Assert.Equal("x-small", match.Text); + Assert.True(result.Truncated); + } + + [Fact] + public void Byte_exhaustion_stops_scanning_the_current_and_later_panes() + { + var budget = new SearchResultBudget("x", 2, 500, 4_000); + Regex regex = ReadTools.CompilePattern("x", ignoreCase: false); + var lines = new RepeatedLines(1_000_000, "x"); + + SearchPaneBudgetOutcome outcome = ReadTools.AddSearchMatches( + budget, + "%1", + "@1", + "$1", + lines, + 0, + regex, + maxMatchesPerPane: 500, + TestContext.Current.CancellationToken); + + Assert.Equal(SearchPaneBudgetOutcome.GlobalLimit, outcome); + Assert.InRange(lines.Reads, 1, 200); + } + + [Fact] + public void A_cancelled_search_stops_before_scanning_more_rows() + { + var budget = new SearchResultBudget("x", 1, 500, 4_000); + Regex regex = ReadTools.CompilePattern("x", ignoreCase: false); + var lines = new RepeatedLines(1_000_000, "x"); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + + Assert.Throws(() => ReadTools.AddSearchMatches( + budget, + "%1", + "@1", + "$1", + lines, + 0, + regex, + maxMatchesPerPane: 500, + cancellation.Token)); + + Assert.Equal(0, lines.Reads); + } + + [Fact] + public void A_regex_timeout_is_reported_as_an_actionable_mcp_error() + { + var budget = new SearchResultBudget("(a+)+$", 1, 10, 4_000); + var regex = new Regex("(a+)+$", RegexOptions.None, TimeSpan.FromTicks(1)); + + McpException error = Assert.Throws(() => ReadTools.AddSearchMatches( + budget, + "%1", + "@1", + "$1", + [new string('a', 10_000) + "!"], + 0, + regex, + maxMatchesPerPane: 10, + TestContext.Current.CancellationToken)); + + Assert.Contains("Simplify", error.Message, StringComparison.Ordinal); + } + + [Fact] + public void A_large_history_row_does_not_hide_a_later_smaller_row() + { + var budget = new SearchResultBudget("x", 1, 500, 4_000); + List matches = []; + Assert.Equal( + SearchMatchBudgetOutcome.Added, + budget.TryAdd( + "%1", + "@1", + "$1", + matches, + new MatchedLine(-20_000, new string('x', 1_430)))); + + Assert.Equal( + SearchMatchBudgetOutcome.ItemTooLarge, + budget.TryAdd("%1", "@1", "$1", matches, new MatchedLine(-10_000, string.Empty))); + Assert.Equal( + SearchMatchBudgetOutcome.Added, + budget.TryAdd("%1", "@1", "$1", matches, new MatchedLine(-999, string.Empty))); + } + + [Fact] + public void Multibyte_matches_never_push_the_complete_result_over_its_byte_ceiling() + { + const int maxBytes = 4_000; + var budget = new SearchResultBudget("\U0001f642+", 20, 500, maxBytes); + List matches = []; + int row = 0; + while (budget.TryAdd( + "%1", + "@1", + "$1", + matches, + new MatchedLine(row++, string.Concat(Enumerable.Repeat("\U0001f642", 12)))) + == SearchMatchBudgetOutcome.Added) + { + } + + budget.Commit("%1", "@1", "$1", matches); + SearchResult result = budget.Build(1, truncated: true); + int bytes = Utf8JsonBudget.GetStructuredToolResultByteCount(result, ToolJson.Options); + + Assert.NotEmpty(result.Panes); + Assert.True(bytes <= maxBytes, $"search result used {bytes} bytes"); + Assert.True(result.Truncated); + } + + [Fact] + public void A_pattern_that_consumes_the_whole_response_is_rejected_before_searching() + { + McpException error = Assert.Throws(() => + new SearchResultBudget(new string('x', 500), 1, 10, 100)); + + Assert.Contains("pattern alone", error.Message, StringComparison.Ordinal); + } + + [Fact] + public void An_oversized_logical_line_is_rejected_without_copying_it() + { + var budget = new SearchResultBudget("x", 1, 10, 4_000); + var match = new MatchedLine(0, new string('x', 1_000_000)); + List matches = []; + _ = System.Text.Encoding.UTF8.GetByteCount("warm"); + long before = GC.GetAllocatedBytesForCurrentThread(); + + SearchMatchBudgetOutcome added = budget.TryAdd("%1", "@1", "$1", matches, match); + long allocated = GC.GetAllocatedBytesForCurrentThread() - before; + + Assert.NotEqual(SearchMatchBudgetOutcome.Added, added); + Assert.Empty(matches); + Assert.True(allocated < 4_096, $"rejection allocated {allocated} bytes"); + } + + [Fact] + public void Escape_heavy_matches_are_rejected_without_materializing_the_fragment() + { + const int maxBytes = 4_000_000; + var budget = new SearchResultBudget("x", 1, 500, maxBytes); + var match = new MatchedLine(0, new string('\u0001', 1_900_000)); + List matches = []; + _ = Utf8JsonBudget.GetStructuredJsonFragmentByteCount( + new MatchedLine(0, "warm"), + ToolJson.Options); + long before = GC.GetAllocatedBytesForCurrentThread(); + + SearchMatchBudgetOutcome added = budget.TryAdd("%1", "@1", "$1", matches, match); + long allocated = GC.GetAllocatedBytesForCurrentThread() - before; + + Assert.NotEqual(SearchMatchBudgetOutcome.Added, added); + Assert.Empty(matches); + Assert.True(allocated < 4_000_000, $"rejection allocated {allocated:N0} bytes"); + } + + [Fact] + 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, _) => + { + Interlocked.Increment(ref dispatches); + return Task.FromResult(new TmuxCommandResult( + request.LogicalArguments, + 0, + ReadOnlyMemory.Empty, + 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); + await using var activity = new PaneActivityHub(); + var tools = new ReadTools( + accessor, + new ServerPolicy { MaxBytes = 128_000 }, + activity); + + McpException error = await Assert.ThrowsAsync(() => + tools.SearchPanesAsync( + new string('x', 4_097), + cancellationToken: TestContext.Current.CancellationToken)); + + Assert.Contains("4096", error.Message, StringComparison.Ordinal); + Assert.Equal(0, dispatches); + } + + private sealed class RepeatedLines(int count, string value) : IReadOnlyList + { + internal int Reads { get; private set; } + + public int Count { get; } = count; + + public string this[int index] + { + get + { + ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(index, Count); + Reads++; + return value; + } + } + + public IEnumerator GetEnumerator() => + Enumerable.Repeat(value, Count).GetEnumerator(); + + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => + GetEnumerator(); + } +} diff --git a/tests/LibTmux.UnitTests/Mcp/StructuredTextResultBudgetTests.cs b/tests/LibTmux.UnitTests/Mcp/StructuredTextResultBudgetTests.cs new file mode 100644 index 0000000..9d05857 --- /dev/null +++ b/tests/LibTmux.UnitTests/Mcp/StructuredTextResultBudgetTests.cs @@ -0,0 +1,345 @@ +using System.Globalization; +using System.Runtime.Versioning; +using System.Text; +using LibTmux.Internal; +using LibTmux.Mcp; +using ModelContextProtocol; + +namespace LibTmux.UnitTests; + +[UnsupportedOSPlatform("windows")] +public sealed class StructuredTextResultBudgetTests +{ + private const int MaxBytes = 4_000; + + [Fact] + public void Every_content_bearing_result_fits_its_complete_tool_budget() + { + string[] lines = Enumerable.Range(0, 200) + .Select(index => $"line {index}: \\\"quoted\\\" \\\\ path \U0001f642") + .ToArray(); + + AssertFits(lines, content => new CaptureResult("%1", content)); + AssertFits(lines, content => new TailResult("%1", content, WidestCursor(), false, false)); + AssertFits(lines, content => new WaitResult( + "%1", + WaitOutcome.Matched, + "ready.*", + content, + 1.25, + 30)); + AssertFits(lines, content => new RunResult("%1", 0, false, content, 1.25, 30)); + AssertFits(lines, content => new PaneSnapshot( + Pane(), + content, + 10, + 2, + false)); + } + + [Fact] + public void Truncation_keeps_the_newest_text_and_reports_exact_loss() + { + string[] lines = Enumerable.Range(0, 100) + .Select(index => $"{index:D3}: {new string('x', 40)}") + .ToArray(); + + CaptureResult result = StructuredTextResultBudget.Fit( + lines, + maxLines: 40, + MaxBytes, + content => new CaptureResult("%1", content), + "test-capture"); + + Assert.True(result.Content.Truncated); + Assert.EndsWith("099: " + new string('x', 40), result.Content.Lines[^1], StringComparison.Ordinal); + Assert.Equal( + JoinedUtf8ByteCount(lines) - JoinedUtf8ByteCount(result.Content.Lines), + result.Content.DroppedBytes); + Assert.True(result.Content.DroppedLines >= 60); + AssertFits(result); + } + + [Fact] + public void Fixed_metadata_that_cannot_fit_fails_with_operator_guidance() + { + McpException error = Assert.Throws(() => + StructuredTextResultBudget.Fit( + ["small"], + maxLines: 10, + MaxBytes, + content => new CaptureResult(new string('x', MaxBytes), content), + "test-capture")); + + Assert.Contains("test-capture metadata", error.Message, StringComparison.Ordinal); + Assert.Contains(ServerPolicy.MaxBytesVariable, error.Message, StringComparison.Ordinal); + } + + [Fact] + public void A_huge_logical_line_is_only_refined_after_it_is_bounded() + { + string huge = new('x', 1_000_000); + _ = StructuredTextResultBudget.Fit( + ["warmup"], + maxLines: 10, + MaxBytes, + content => new CaptureResult("%1", content), + "test-capture"); + long before = GC.GetAllocatedBytesForCurrentThread(); + + CaptureResult result = StructuredTextResultBudget.Fit( + [huge], + maxLines: 10, + MaxBytes, + content => new CaptureResult("%1", content), + "test-capture"); + + long allocated = GC.GetAllocatedBytesForCurrentThread() - before; + Assert.EndsWith("xxxx", result.Content.Lines[^1], StringComparison.Ordinal); + Assert.True(allocated < 2_000_000, $"allocated {allocated:N0} bytes"); + AssertFits(result); + } + + [Fact] + public void Maximum_policy_refinement_allocates_a_small_multiple_of_the_result() + { + const int LargeBudget = 4_000_000; + string huge = new('x', 5_000_000); + _ = StructuredTextResultBudget.Fit( + ["warmup"], + maxLines: 10, + LargeBudget, + content => new CaptureResult("%1", content), + "test-capture"); + long before = GC.GetAllocatedBytesForCurrentThread(); + + CaptureResult result = StructuredTextResultBudget.Fit( + [huge], + maxLines: 10, + LargeBudget, + content => new CaptureResult("%1", content), + "test-capture"); + + long allocated = GC.GetAllocatedBytesForCurrentThread() - before; + Assert.True(result.Content.Lines[0].Length > 1_500_000); + Assert.True(allocated < 64_000_000, $"allocated {allocated:N0} bytes"); + Assert.True( + Utf8JsonBudget.GetStructuredToolResultByteCount(result, ToolJson.Options) + <= LargeBudget); + } + + [Fact] + public void Refinement_uses_headroom_when_older_text_escapes_more_than_the_tail() + { + const int LargeBudget = 4_000_000; + string controls = new('\u0001', 500_000); + string newest = new('x', 2_000_000); + + CaptureResult result = StructuredTextResultBudget.Fit( + [controls, newest], + maxLines: 10, + LargeBudget, + content => new CaptureResult("%1", content), + "test-capture"); + + Assert.Single(result.Content.Lines); + Assert.True( + result.Content.Lines[0].Length > 1_900_000, + $"retained {result.Content.Lines[0].Length:N0} characters"); + Assert.EndsWith("xxxx", result.Content.Lines[0], StringComparison.Ordinal); + Assert.True( + Utf8JsonBudget.GetStructuredToolResultByteCount(result, ToolJson.Options) + <= LargeBudget); + } + + [Fact] + public void Refinement_falls_back_below_an_escape_cost_discontinuity() + { + string newest = new('"', 274); + + CaptureResult result = StructuredTextResultBudget.Fit( + [new string('x', 2_046), newest], + maxLines: 10, + MaxBytes, + content => new CaptureResult("%1", content), + "test-capture"); + + string retained = Assert.Single(result.Content.Lines); + Assert.EndsWith(new string('"', 4), retained, StringComparison.Ordinal); + Assert.InRange(retained.Length, 200, newest.Length); + AssertFits(result); + } + + [Fact] + public void Refinement_crosses_a_whole_line_plateau_to_use_available_headroom() + { + string[] lines = + [ + new string('\\', 686), + new string('\u0001', 1_088), + new string('x', 422), + new string('x', 187), + ]; + + CaptureResult result = StructuredTextResultBudget.Fit( + lines, + maxLines: 10, + MaxBytes, + content => new CaptureResult("%1", content), + "test-capture"); + + Assert.True(result.Content.Lines.Count >= 2); + Assert.Equal(422, result.Content.Lines[^2].Length); + Assert.Equal(187, result.Content.Lines[^1].Length); + AssertFits(result); + } + + [Fact] + public void Refinement_keeps_a_long_boundary_suffix_before_a_short_newest_line() + { + CaptureResult result = StructuredTextResultBudget.Fit( + [new string('x', 10_000), new string('y', 100)], + maxLines: null, + MaxBytes, + content => new CaptureResult("%1", content), + "test-capture"); + + Assert.Equal(2, result.Content.Lines.Count); + Assert.True(result.Content.Lines[0].Length > 1_000); + Assert.Equal(new string('y', 100), result.Content.Lines[1]); + AssertFits(result); + } + + [Fact] + public void One_byte_candidate_at_the_metadata_boundary_falls_back_without_throwing() + { + int low = 1; + int high = MaxBytes; + int best = 0; + BoundedText empty = BoundedText.Fit(["xx"], 0, 1); + while (low <= high) + { + int length = low + ((high - low) / 2); + var probe = new PaneSnapshot(PaneWithPath(length), empty, 0, 0, false); + if (Utf8JsonBudget.GetStructuredToolResultByteCount(probe, ToolJson.Options) + <= MaxBytes) + { + best = length; + low = length + 1; + } + else + { + high = length - 1; + } + } + + PaneSnapshot result = StructuredTextResultBudget.Fit( + ["xx"], + maxLines: 10, + MaxBytes, + content => new PaneSnapshot(PaneWithPath(best), content, 0, 0, false), + "test-snapshot"); + + Assert.True(result.Content.Truncated); + AssertFits(result); + } + + private static void AssertFits(IReadOnlyList lines, Func create) + { + T result = StructuredTextResultBudget.Fit( + lines, + maxLines: 100, + MaxBytes, + create, + "test-result"); + + System.Reflection.PropertyInfo contentProperty = typeof(T).GetProperty("Content") + ?? typeof(T).GetProperty("Output") + ?? typeof(T).GetProperty("Tail") + ?? throw new InvalidOperationException("The result has no bounded text property."); + var content = Assert.IsType(contentProperty.GetValue(result)); + Assert.True(content.Truncated); + Assert.EndsWith("line 199: \\\"quoted\\\" \\\\ path \U0001f642", content.Lines[^1], StringComparison.Ordinal); + AssertFits(result); + } + + private static void AssertFits(T result) => + Assert.True( + Utf8JsonBudget.GetStructuredToolResultByteCount(result, ToolJson.Options) <= MaxBytes); + + // The cursor a tall pane issues is the widest field a tail result carries, + // so a placeholder would stop measuring the case that fails first. + private static string WidestCursor() + { + var connection = new TmuxConnection( + new ServerConnectionOptions(socketName: "budget-cursor"), + 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"), + connection, + generation, + new PaneId(99_999), + new Dictionary(StringComparer.Ordinal)); + return TailCursor.Build( + pane, + new PaneGridState( + int.MaxValue.ToString(CultureInfo.InvariantCulture), + 2, + 50_000, + 20_000, + 1, + false, + false), + [.. Enumerable.Repeat(new string('\u0416', 80), 200)]) + .Encode(); + } + + private static PaneInfo Pane() => new( + "%1", + "@1", + "$1", + 0, + 80, + 24, + "shell", + true, + false, + false, + false, + "bash", + "/tmp", + 123, + 10, + 2_000, + false); + + private static PaneInfo PaneWithPath(int pathLength) => new( + "%0", + "@0", + "$0", + 0, + 1, + 1, + null, + false, + false, + false, + false, + null, + new string('p', pathLength), + null, + null, + null, + false); + + private static int JoinedUtf8ByteCount(IReadOnlyList lines) => + Encoding.UTF8.GetByteCount(string.Join('\n', lines)); +} diff --git a/tests/LibTmux.UnitTests/Mcp/SubscriptionStreamTests.cs b/tests/LibTmux.UnitTests/Mcp/SubscriptionStreamTests.cs new file mode 100644 index 0000000..63ed1c0 --- /dev/null +++ b/tests/LibTmux.UnitTests/Mcp/SubscriptionStreamTests.cs @@ -0,0 +1,130 @@ +using System.Runtime.Versioning; +using LibTmux.Mcp; +using ModelContextProtocol; +using ModelContextProtocol.Protocol; + +namespace LibTmux.UnitTests.Mcp; + +[UnsupportedOSPlatform("windows")] +public sealed class SubscriptionStreamTests +{ + [Fact] + public void Requested_resources_are_distinct_supported_and_canonically_ordered() + { + IReadOnlyList watched = SubscriptionStream.Canonicalize( + [ + "tmux://servers", + "tmux://hierarchy", + "tmux://servers", + "tmux://unsupported", + "tmux://sessions", + "tmux://hierarchy", + ]); + + Assert.Equal(HierarchyWatcher.Watchable, watched); + Assert.Empty(SubscriptionStream.Canonicalize(null)); + } + + [Fact] + public void Canonicalization_stops_after_the_fixed_watchable_set_is_found() + { + int enumerated = 0; + + IEnumerable Requested() + { + foreach (string uri in HierarchyWatcher.Watchable.Reverse()) + { + enumerated++; + yield return uri; + } + + throw new InvalidOperationException("The bounded scan read beyond the full set."); + } + + Assert.Equal(HierarchyWatcher.Watchable, SubscriptionStream.Canonicalize(Requested())); + Assert.Equal(HierarchyWatcher.Watchable.Count, enumerated); + } + + [Fact] + public void Subscription_ids_are_bounded_after_json_encoding() + { + Assert.Equal(256, SubscriptionStream.SubscriptionIdMaxEncodedBytes); + string boundary = new('a', SubscriptionStream.SubscriptionIdMaxEncodedBytes); + + Assert.Equal( + new RequestId(boundary), + SubscriptionStream.ValidateSubscriptionId(new RequestId(boundary))); + McpException large = Assert.Throws(() => + SubscriptionStream.ValidateSubscriptionId(new RequestId(boundary + "b"))); + _ = Assert.Throws(() => + SubscriptionStream.ValidateSubscriptionId(new RequestId(new string('\n', 129)))); + _ = Assert.Throws(() => + SubscriptionStream.ValidateSubscriptionId(new RequestId(new string('x', 1_000_000)))); + + Assert.Contains("JSON-encoded bytes", large.Message, StringComparison.Ordinal); + } + + [Fact] + public void Numeric_subscription_ids_keep_their_wire_type() + { + RequestId numeric = new(long.MaxValue); + + RequestId validated = SubscriptionStream.ValidateSubscriptionId(numeric); + + Assert.Equal(numeric, validated); + Assert.IsType(validated.Id); + } + + [Fact] + public void Full_admission_rejects_before_subscriber_allocation() + { + Assert.Equal(8, SubscriptionAdmission.ConcurrentListenLimit); + SubscriptionAdmission admission = new(2); + using SubscriptionAdmission.Lease first = admission.Acquire(CancellationToken.None); + using SubscriptionAdmission.Lease second = admission.Acquire(CancellationToken.None); + int subscriberAllocations = 0; + + void AllocateSubscriber() + { + using SubscriptionAdmission.Lease lease = admission.Acquire(CancellationToken.None); + subscriberAllocations++; + } + + McpException full = Assert.Throws(AllocateSubscriber); + + Assert.Contains("At most 2", full.Message, StringComparison.Ordinal); + Assert.Contains("Cancel", full.Message, StringComparison.Ordinal); + Assert.Equal(0, subscriberAllocations); + Assert.Equal(2, admission.ActiveCount); + } + + [Fact] + public void Releasing_or_disposing_a_lease_allows_reacquisition() + { + SubscriptionAdmission admission = new(1); + SubscriptionAdmission.Lease first = admission.Acquire(CancellationToken.None); + + Assert.Equal(1, admission.ActiveCount); + first.Dispose(); + first.Dispose(); + Assert.Equal(0, admission.ActiveCount); + + using SubscriptionAdmission.Lease second = admission.Acquire(CancellationToken.None); + Assert.Equal(1, admission.ActiveCount); + } + + [Fact] + public void Precancelled_admission_does_not_consume_capacity() + { + SubscriptionAdmission admission = new(1); + using CancellationTokenSource cancellation = new(); + cancellation.Cancel(); + + _ = Assert.Throws(() => + admission.Acquire(cancellation.Token)); + + Assert.Equal(0, admission.ActiveCount); + using SubscriptionAdmission.Lease available = admission.Acquire(CancellationToken.None); + Assert.Equal(1, admission.ActiveCount); + } +} diff --git a/tests/LibTmux.UnitTests/Mcp/TailCursorTests.cs b/tests/LibTmux.UnitTests/Mcp/TailCursorTests.cs new file mode 100644 index 0000000..e262ead --- /dev/null +++ b/tests/LibTmux.UnitTests/Mcp/TailCursorTests.cs @@ -0,0 +1,170 @@ +using System.Globalization; +using System.Runtime.Versioning; +using LibTmux.Internal; +using LibTmux.Mcp; +using ModelContextProtocol; + +namespace LibTmux.UnitTests; + +[UnsupportedOSPlatform("windows")] +public sealed class TailCursorTests +{ + [Fact] + public void A_cursor_round_trips_for_its_exact_endpoint_generation_and_pane() + { + Pane pane = PaneFor("cursor-one", new ServerGeneration(17, 9001), 3); + TailCursor cursor = CursorFor(pane); + + TailCursor? decoded = TailCursor.Decode(cursor.Encode(), pane); + + Assert.Equal(cursor, decoded); + } + + [Fact] + public void A_cursor_stays_bounded_instead_of_copying_every_row_hash() + { + Pane pane = PaneFor("cursor-size", new ServerGeneration(int.MaxValue, long.MaxValue), 99_999); + var state = new PaneGridState( + int.MaxValue.ToString(CultureInfo.InvariantCulture), + 2, + 50_000, + 20_000, + 1, + false, + false); + string wideRow = string.Concat(Enumerable.Repeat("\U0001f642", 80)); + string[] rows = Enumerable.Repeat(wideRow, 10_000).ToArray(); + + string token = TailCursor.Build(pane, state, rows).Encode(); + TailCursor decoded = Assert.IsType(TailCursor.Decode(token, pane)); + + // Decode enforces these ceilings, so a cursor that cannot be read back + // would be issued by every tail of a tall pane. + Assert.InRange(token.Length, 1, 2_048); + Assert.Equal(32, decoded.BelowCount); + Assert.Equal(9_999, decoded.SuffixCount); + Assert.Equal(32 * 8, Convert.FromBase64String( + decoded.RowHashes!.Replace('-', '+').Replace('_', '/') + "==").Length); + } + + [Fact] + public void A_cursor_with_nothing_below_it_round_trips() + { + Pane pane = PaneFor("cursor-bottom", new ServerGeneration(17, 9001), 3); + TailCursor cursor = TailCursor.Build( + pane, + new PaneGridState("313", 23, 1_000, 24, 1, false, false), + ["only the cursor row"]); + + TailCursor decoded = Assert.IsType(TailCursor.Decode(cursor.Encode(), pane)); + + Assert.Null(decoded.RowHashes); + Assert.Equal(0, decoded.BelowCount); + Assert.Equal(cursor, decoded); + } + + [Fact] + public void A_modified_token_is_rejected() + { + Pane pane = PaneFor("cursor-tamper", new ServerGeneration(17, 9001), 3); + string token = CursorFor(pane).Encode(); + char replacement = token[^1] == 'a' ? 'b' : 'a'; + + McpException error = Assert.Throws(() => + TailCursor.Decode(token[..^1] + replacement, pane)); + + Assert.Contains("invalid", error.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void A_cursor_cannot_cross_endpoints() + { + var generation = new ServerGeneration(17, 9001); + Pane issuedFor = PaneFor("cursor-endpoint-a", generation, 3); + Pane presentedTo = PaneFor("cursor-endpoint-b", generation, 3); + + McpException error = Assert.Throws(() => + TailCursor.Decode(CursorFor(issuedFor).Encode(), presentedTo)); + + Assert.Contains("different pane or tmux server", error.Message, StringComparison.Ordinal); + } + + [Fact] + public void A_cursor_cannot_cross_server_generations() + { + Pane issuedFor = PaneFor("cursor-generation", new ServerGeneration(17, 9001), 3); + Pane presentedTo = PaneFor("cursor-generation", new ServerGeneration(17, 9002), 3); + + Assert.Throws(() => + TailCursor.Decode(CursorFor(issuedFor).Encode(), presentedTo)); + } + + [Fact] + public void A_cursor_cannot_cross_panes() + { + var generation = new ServerGeneration(17, 9001); + Pane issuedFor = PaneFor("cursor-pane", generation, 3); + Pane presentedTo = PaneFor("cursor-pane", generation, 4); + + Assert.Throws(() => + TailCursor.Decode(CursorFor(issuedFor).Encode(), presentedTo)); + } + + [Fact] + public void An_authenticated_cursor_with_a_null_required_field_is_rejected_cleanly() + { + Pane pane = PaneFor("cursor-null", new ServerGeneration(17, 9001), 3); + TailCursor malformed = CursorFor(pane) with { EndpointFingerprint = null! }; + + McpException error = Assert.Throws(() => + TailCursor.Decode(malformed.Encode(), pane)); + + Assert.Contains("invalid", error.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void An_authenticated_cursor_with_an_unknown_version_is_rejected() + { + Pane pane = PaneFor("cursor-version", new ServerGeneration(17, 9001), 3); + TailCursor malformed = CursorFor(pane) with { Version = int.MaxValue }; + + Assert.Throws(() => TailCursor.Decode(malformed.Encode(), pane)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("\ttmux-tail-v3:")] + public void An_explicit_blank_or_padded_cursor_is_rejected(string token) + { + Pane pane = PaneFor("cursor-whitespace", new ServerGeneration(17, 9001), 3); + + Assert.Throws(() => TailCursor.Decode(token, pane)); + } + + private static TailCursor CursorFor(Pane pane) => TailCursor.Build( + pane, + new PaneGridState("313", 2, 1_000, 24, 1, false, false), + ["anchor", "below"]); + + private static Pane PaneFor(string socketName, ServerGeneration generation, int paneId) + { + var connection = new TmuxConnection( + new ServerConnectionOptions(socketName: socketName), + 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, + connection, + generation, + new PaneId(paneId), + new Dictionary(StringComparer.Ordinal)); + } +} diff --git a/tests/LibTmux.UnitTests/Mcp/ToolResponseBudgetFilterTests.cs b/tests/LibTmux.UnitTests/Mcp/ToolResponseBudgetFilterTests.cs new file mode 100644 index 0000000..4621e8e --- /dev/null +++ b/tests/LibTmux.UnitTests/Mcp/ToolResponseBudgetFilterTests.cs @@ -0,0 +1,576 @@ +using System.IO.Pipelines; +using System.Runtime.Versioning; +using System.Text; +using System.Text.Json; +using LibTmux.Mcp; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using ModelContextProtocol.Client; +using ModelContextProtocol.Extensions.Tasks; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace LibTmux.UnitTests; + +[UnsupportedOSPlatform("windows")] +public sealed class ToolResponseBudgetFilterTests +{ + [Fact] + public async Task Oversized_text_and_structured_content_are_replaced_by_a_bounded_error() + { + var policy = new ServerPolicy { MaxBytes = 4_000 }; + string oversized = string.Concat(Enumerable.Repeat("\U0001f642", 3_000)); + var original = new CallToolResult + { + Content = [new TextContentBlock { Text = oversized }], + StructuredContent = JsonSerializer.SerializeToElement(new { value = oversized }), + }; + McpRequestHandler handler = + ToolResponseBudgetFilter.Create(policy)( + (_, _) => ValueTask.FromResult(original)); + + CallToolResult filtered = await handler(null!, TestContext.Current.CancellationToken); + + Assert.True(filtered.IsError); + Assert.Null(filtered.StructuredContent); + string message = Assert.IsType(Assert.Single(filtered.Content)).Text; + Assert.Contains(ServerPolicy.MaxBytesVariable, message, StringComparison.Ordinal); + Assert.True(Utf8JsonBudget.Fits(filtered, policy.MaxBytes, ToolJson.Options)); + } + + [Fact] + public async Task A_result_that_fits_is_not_rewritten() + { + var policy = new ServerPolicy { MaxBytes = 4_000 }; + var original = new CallToolResult + { + Content = [new TextContentBlock { Text = "small" }], + StructuredContent = JsonSerializer.SerializeToElement(new { value = "small" }), + }; + McpRequestHandler handler = + ToolResponseBudgetFilter.Create(policy)( + (_, _) => ValueTask.FromResult(original)); + + CallToolResult filtered = await handler(null!, TestContext.Current.CancellationToken); + + Assert.Same(original, filtered); + } + + [Fact] + public async Task Oversized_action_acknowledgement_stays_successful_after_one_dispatch() + { + var policy = new ServerPolicy { MaxBytes = 4_000 }; + var action = new ActionResult( + new string('x', 8_000), + PaneId: "%7", + WindowId: "@8", + SessionId: "$9"); + JsonElement structured = JsonSerializer.SerializeToElement(action, ToolJson.Options); + var original = new CallToolResult + { + Content = [new TextContentBlock { Text = structured.GetRawText() }], + StructuredContent = structured, + }; + int dispatches = 0; + McpRequestHandler handler = + ToolResponseBudgetFilter.Create(policy)( + (_, _) => + { + dispatches++; + return ValueTask.FromResult(original); + }); + + CallToolResult filtered = await handler(null!, TestContext.Current.CancellationToken); + + Assert.Equal(1, dispatches); + Assert.NotEqual(true, filtered.IsError); + Assert.True(Utf8JsonBudget.FitsToolResult(filtered, policy.MaxBytes, ToolJson.Options)); + JsonElement bounded = Assert.IsType(filtered.StructuredContent); + ActionResult acknowledgement = bounded.Deserialize(ToolJson.Options) + ?? throw new InvalidOperationException("The action acknowledgement was null."); + Assert.Contains("completed", acknowledgement.Changed, StringComparison.Ordinal); + Assert.Contains("Do not retry", acknowledgement.Changed, StringComparison.Ordinal); + Assert.DoesNotContain(action.Changed, acknowledgement.Changed, StringComparison.Ordinal); + Assert.Equal(action.PaneId, acknowledgement.PaneId); + Assert.Equal(action.WindowId, acknowledgement.WindowId); + Assert.Equal(action.SessionId, acknowledgement.SessionId); + } + + [Fact] + public async Task Oversized_mutating_error_keeps_conservative_retry_advice() + { + CancellationToken token = TestContext.Current.CancellationToken; + BudgetProbeTools.LargeWriteErrorCalls = 0; + await using BudgetProtocolHarness harness = await BudgetProtocolHarness.StartAsync(token); + + CallToolResult result = await harness.Client.CallToolAsync( + "budget_probe_large_write_error", + cancellationToken: token); + + Assert.True(result.IsError); + Assert.Equal(1, BudgetProbeTools.LargeWriteErrorCalls); + string message = Assert.IsType(Assert.Single(result.Content)).Text; + Assert.Contains("tmux may have acted", message, StringComparison.Ordinal); + Assert.Contains("Do not retry", message, StringComparison.Ordinal); + Assert.True(Utf8JsonBudget.FitsToolResult(result, 4_000, ToolJson.Options)); + } + + [Fact] + public async Task Oversized_read_error_does_not_claim_that_tmux_mutated_state() + { + CancellationToken token = TestContext.Current.CancellationToken; + await using BudgetProtocolHarness harness = await BudgetProtocolHarness.StartAsync(token); + + CallToolResult result = await harness.Client.CallToolAsync( + "budget_probe_large_read_error", + cancellationToken: token); + + Assert.True(result.IsError); + string message = Assert.IsType(Assert.Single(result.Content)).Text; + Assert.Contains("The read failed", message, StringComparison.Ordinal); + Assert.DoesNotContain("may have acted", message, StringComparison.Ordinal); + Assert.Contains(ServerPolicy.MaxBytesVariable, message, StringComparison.Ordinal); + Assert.True(Utf8JsonBudget.FitsToolResult(result, 4_000, ToolJson.Options)); + } + + [Fact] + public async Task Paste_primary_and_cleanup_failure_names_the_owned_buffer_on_the_wire() + { + CancellationToken token = TestContext.Current.CancellationToken; + await using BudgetProtocolHarness harness = await BudgetProtocolHarness.StartAsync(token); + + CallToolResult result = await harness.Client.CallToolAsync( + "budget_probe_paste_cleanup_failure", + cancellationToken: token); + + Assert.True(result.IsError); + string message = Assert.IsType(Assert.Single(result.Content)).Text; + Assert.Contains(BudgetProbeTools.PasteBuffer, message, StringComparison.Ordinal); + Assert.Contains("may still contain", message, StringComparison.Ordinal); + Assert.Contains("Do not retry", message, StringComparison.Ordinal); + Assert.Contains("tmux delete-buffer -b", message, StringComparison.Ordinal); + Assert.True(Utf8JsonBudget.FitsToolResult(result, 4_000, ToolJson.Options)); + } + + [Fact] + public void Dispatch_advice_is_conservative_only_for_mutating_tools() + { + var error = new TmuxTransportException( + "the client disappeared", + ["send-keys"], + TmuxDispatchState.Unknown); + + string read = ToolFailureFilter.ActionableAdvice( + "tmux_capture_pane", + error, + mayModify: false, + "The read failed."); + string write = ToolFailureFilter.ActionableAdvice( + "tmux_start_job", + error, + mayModify: true, + "The dispatch failed."); + + Assert.Equal("The read failed.", read); + Assert.Contains("Do not retry", write, StringComparison.Ordinal); + Assert.Contains("tmux_list_jobs", write, StringComparison.Ordinal); + } + + [Fact] + public async Task Oversized_protocol_metadata_is_filtered_with_the_rest_of_the_result() + { + var policy = new ServerPolicy { MaxBytes = 4_000 }; + var original = new CallToolResult + { + Content = [new TextContentBlock { Text = "small" }], + }; + System.Reflection.PropertyInfo metaProperty = typeof(CallToolResult).GetProperty("Meta") + ?? throw new InvalidOperationException("CallToolResult.Meta was not found."); + object? metadata = JsonSerializer.Deserialize( + JsonSerializer.Serialize(new { blob = new string('m', 8_000) }), + metaProperty.PropertyType, + ToolJson.Options); + Assert.NotNull(metadata); + metaProperty.SetValue(original, metadata); + McpRequestHandler handler = + ToolResponseBudgetFilter.Create(policy)( + (_, _) => ValueTask.FromResult(original)); + + CallToolResult filtered = await handler(null!, TestContext.Current.CancellationToken); + + Assert.True(filtered.IsError); + Assert.Null(filtered.Meta); + Assert.True(Utf8JsonBudget.Fits(filtered, policy.MaxBytes, ToolJson.Options)); + } + + [Fact] + public async Task The_wire_protocol_never_receives_an_oversized_tool_result() + { + CancellationToken token = TestContext.Current.CancellationToken; + await using BudgetProtocolHarness harness = await BudgetProtocolHarness.StartAsync(token); + + CallToolResult result = await harness.Client.CallToolAsync( + "budget_probe_large", + cancellationToken: token); + + Assert.True(result.IsError); + Assert.Null(result.StructuredContent); + string message = Assert.IsType(Assert.Single(result.Content)).Text; + Assert.Contains("Narrow", message, StringComparison.Ordinal); + Assert.True(Utf8JsonBudget.Fits(result, 4_000, ToolJson.Options)); + } + + [Fact] + public async Task Structured_result_accounting_matches_the_sdk_wire_shape() + { + CancellationToken token = TestContext.Current.CancellationToken; + await using BudgetProtocolHarness harness = await BudgetProtocolHarness.StartAsync(token); + + CallToolResult actual = await harness.Client.CallToolAsync( + "budget_probe_search", + cancellationToken: token); + SearchResult value = BudgetProbeTools.Search(); + + int actualBytes = Utf8JsonBudget.GetByteCount(actual, ToolJson.Options); + int budgetedBytes = Utf8JsonBudget.GetStructuredToolResultByteCount( + value, + ToolJson.Options); + + Assert.True(actualBytes <= budgetedBytes); + Assert.True(budgetedBytes - actualBytes < Utf8JsonBudget.ProtocolMetadataReserve); + Assert.InRange(budgetedBytes, 3_500, 4_000); + Assert.InRange(actualBytes, 1, 4_000); + Assert.NotEqual(true, actual.IsError); + } + + [Theory] + [InlineData("plain ASCII")] + [InlineData("quote \" slash \\\\ control \\n tab \\t")] + [InlineData("emoji 🙂 CJK 雪 HTML <>&")] + public void Streaming_structured_accounting_matches_materialized_json(string value) + { + var result = new BudgetProbeResult(value); + JsonElement structured = JsonSerializer.SerializeToElement(result, ToolJson.Options); + var materialized = new CallToolResult + { + Content = [new TextContentBlock { Text = structured.GetRawText() }], + StructuredContent = structured, + }; + + int expected = checked( + Utf8JsonBudget.GetByteCount(materialized, ToolJson.Options) + + Utf8JsonBudget.ProtocolMetadataReserve); + + Assert.Equal( + expected, + Utf8JsonBudget.GetStructuredToolResultByteCount(result, ToolJson.Options)); + } + + [Theory] + [InlineData("plain ASCII")] + [InlineData("quote \" slash \\\\ control \\n tab \\t")] + [InlineData("emoji 🙂 CJK 雪 HTML <>&")] + public void Streaming_fragment_accounting_matches_materialized_json(string value) + { + var result = new BudgetProbeResult(value); + byte[] raw = JsonSerializer.SerializeToUtf8Bytes(result, ToolJson.Options); + string text = Encoding.UTF8.GetString(raw); + int embeddedContentBytes = checked( + JsonSerializer.SerializeToUtf8Bytes(text, ToolJson.Options).Length - 2); + + Assert.Equal( + checked(raw.Length + embeddedContentBytes), + Utf8JsonBudget.GetStructuredJsonFragmentByteCount(result, ToolJson.Options)); + } + + [Fact] + public async Task Task_result_wrapper_stays_within_the_complete_byte_ceiling() + { + CancellationToken token = TestContext.Current.CancellationToken; + await using BudgetProtocolHarness harness = await BudgetProtocolHarness.StartAsync(token); + int wireOffset = harness.WireLength; + + CallToolResult actual = await harness.Client.CallToolWithPollingAsync( + new CallToolRequestParams { Name = "budget_probe_boundary" }, + cancellationToken: token); + + Assert.NotEqual(true, actual.IsError); + Assert.InRange(harness.CompletedTaskResultBytesSince(wireOffset), 1, 4_000); + } + + [McpServerToolType] + private sealed class BudgetProbeTools + { + internal const string PasteBuffer = "libtmux_mcp_0123456789ab"; + + internal static int LargeWriteErrorCalls { get; set; } + + [McpServerTool(Name = "budget_probe_large", UseStructuredContent = true)] + public static BudgetProbeResult Large() => new(new string('x', 16_000)); + + [McpServerTool(Name = "budget_probe_search", UseStructuredContent = true)] + public static SearchResult Search() + { + var budget = new SearchResultBudget("quote \\\" and \U0001f642", 2, 100, 4_000); + List matches = []; + int row = 0; + while (true) + { + var match = new MatchedLine(row, $"line {row}: \\\"\\n\U0001f642\U0001f642"); + if (budget.TryAdd("%1", "@1", "$1", matches, match) + != SearchMatchBudgetOutcome.Added) + { + break; + } + + row++; + } + + budget.Commit("%1", "@1", "$1", matches); + return budget.Build(2, truncated: true); + } + + [McpServerTool(Name = "budget_probe_boundary", UseStructuredContent = true)] + public static BudgetProbeResult Boundary() + { + BudgetProbeResult best = new(string.Empty); + int low = 1; + int high = 4_000; + while (low <= high) + { + int length = low + ((high - low) / 2); + var candidate = new BudgetProbeResult(new string('x', length)); + if (Utf8JsonBudget.GetStructuredToolResultByteCount(candidate, ToolJson.Options) + <= 4_000) + { + best = candidate; + low = length + 1; + } + else + { + high = length - 1; + } + } + + return best; + } + + [McpServerTool( + Name = "budget_probe_large_write_error", + Destructive = true, + OpenWorld = false, + UseStructuredContent = true)] + public static BudgetProbeResult LargeWriteError() + { + LargeWriteErrorCalls++; + throw new TmuxTransportException( + new string('w', 16_000), + ["send-keys"], + TmuxDispatchState.Unknown); + } + + [McpServerTool( + Name = "budget_probe_large_read_error", + ReadOnly = true, + OpenWorld = false, + UseStructuredContent = true)] + public static BudgetProbeResult LargeReadError() => + throw new TmuxTransportException( + new string('r', 16_000), + ["capture-pane"], + TmuxDispatchState.Unknown); + + [McpServerTool( + Name = "budget_probe_paste_cleanup_failure", + Destructive = true, + OpenWorld = true, + UseStructuredContent = true)] + public static BudgetProbeResult PasteCleanupFailure() + { + var error = new InvalidOperationException(new string('p', 16_000)); + error.Data[WriteTools.PasteBufferCleanupFailureDataKey] = + new IOException("delete-buffer failed"); + error.Data[WriteTools.PasteBufferCleanupBufferDataKey] = PasteBuffer; + throw error; + } + } + + private sealed record BudgetProbeResult(string Value); + + private sealed class BudgetProtocolHarness : IAsyncDisposable + { + private readonly McpServer _server; + private readonly RecordingWriteStream _wire; + private readonly ServiceProvider _services; + + private BudgetProtocolHarness( + McpServer server, + McpClient client, + RecordingWriteStream wire, + ServiceProvider services) + { + _server = server; + Client = client; + _wire = wire; + _services = services; + } + + internal McpClient Client { get; } + + internal int WireLength => _wire.RecordedLength; + + internal int CompletedTaskResultBytesSince(int offset) + { + foreach (string frame in Encoding.UTF8.GetString(_wire.Snapshot(offset)) + .Split('\n', StringSplitOptions.RemoveEmptyEntries)) + { + using JsonDocument document = JsonDocument.Parse(frame); + if (document.RootElement.TryGetProperty("result", out JsonElement result) + && result.TryGetProperty("status", out JsonElement status) + && status.ValueEquals("completed"u8) + && result.TryGetProperty("result", out _)) + { + return Encoding.UTF8.GetByteCount(result.GetRawText()); + } + } + + throw new InvalidOperationException("No completed task result was written."); + } + + internal static async Task StartAsync( + CancellationToken cancellationToken) + { + ServiceCollection services = new(); + services.AddLogging(); + services + .AddMcpServer() + .WithTools(ToolJson.Options) + .WithRequestFilters(filters => filters.AddCallToolFilter(next => + ToolResponseBudgetFilter.Create( + new ServerPolicy { MaxBytes = 4_000 })( + ToolFailureFilter.Create()(next)))) + .WithTasks( + new InMemoryMcpTaskStore(), + tasks => tasks.ExecutionModeSelector = _ => McpTaskExecutionMode.Optional); + ServiceProvider provider = services.BuildServiceProvider(); + + Pipe clientToServer = new(); + Pipe serverToClient = new(); + var wire = new RecordingWriteStream(serverToClient.Writer.AsStream()); + McpServer server = McpServer.Create( + new StreamServerTransport( + clientToServer.Reader.AsStream(), + wire), + provider.GetRequiredService>().Value, + provider.GetRequiredService(), + provider); + _ = server.RunAsync(CancellationToken.None); + McpClient client = await McpClient.CreateAsync( + new StreamClientTransport( + clientToServer.Writer.AsStream(), + serverToClient.Reader.AsStream()), + cancellationToken: cancellationToken); + + return new BudgetProtocolHarness(server, client, wire, provider); + } + + public async ValueTask DisposeAsync() + { + await Client.DisposeAsync().ConfigureAwait(false); + await _server.DisposeAsync().ConfigureAwait(false); + await _services.DisposeAsync().ConfigureAwait(false); + } + } + + private sealed class RecordingWriteStream(Stream inner) : Stream + { + private readonly object _gate = new(); + private readonly MemoryStream _recording = new(); + + internal int RecordedLength + { + get + { + lock (_gate) + { + return checked((int)_recording.Length); + } + } + } + + internal byte[] Snapshot(int offset) + { + lock (_gate) + { + return _recording.ToArray()[offset..]; + } + } + + public override bool CanRead => false; + + public override bool CanSeek => false; + + public override bool CanWrite => true; + + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override void Flush() => inner.Flush(); + + public override Task FlushAsync(CancellationToken cancellationToken) => + inner.FlushAsync(cancellationToken); + + public override int Read(byte[] buffer, int offset, int count) => + throw new NotSupportedException(); + + public override long Seek(long offset, SeekOrigin origin) => + throw new NotSupportedException(); + + public override void SetLength(long value) => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) + { + Record(buffer.AsSpan(offset, count)); + inner.Write(buffer, offset, count); + } + + public override void Write(ReadOnlySpan buffer) + { + Record(buffer); + inner.Write(buffer); + } + + public override async ValueTask WriteAsync( + ReadOnlyMemory buffer, + CancellationToken cancellationToken = default) + { + Record(buffer.Span); + await inner.WriteAsync(buffer, cancellationToken).ConfigureAwait(false); + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _recording.Dispose(); + inner.Dispose(); + } + + base.Dispose(disposing); + } + + private void Record(ReadOnlySpan bytes) + { + lock (_gate) + { + _recording.Write(bytes); + } + } + } +} diff --git a/tests/LibTmux.UnitTests/Mcp/WaitInputBudgetTests.cs b/tests/LibTmux.UnitTests/Mcp/WaitInputBudgetTests.cs new file mode 100644 index 0000000..bc7f22d --- /dev/null +++ b/tests/LibTmux.UnitTests/Mcp/WaitInputBudgetTests.cs @@ -0,0 +1,97 @@ +using System.Runtime.Versioning; +using System.Text.RegularExpressions; +using LibTmux.Internal; +using LibTmux.Mcp; +using ModelContextProtocol; + +namespace LibTmux.UnitTests; + +[UnsupportedOSPlatform("windows")] +public sealed class WaitInputBudgetTests +{ + [Fact] + public void Valid_patterns_and_channels_fit_the_minimum_policy() + { + ReadTools.ValidateWaitPatterns( + ["ready\\s+now"], + ["error|failed"], + resultMaxBytes: 4_000); + ReadTools.ValidateChannel("build-ready", resultMaxBytes: 4_000); + } + + [Fact] + public void Pattern_count_and_total_bytes_are_bounded() + { + string[] tooMany = Enumerable.Range(0, 33).Select(index => $"p{index}").ToArray(); + string[] tooLarge = Enumerable.Repeat(new string('x', 4_096), 5).ToArray(); + + McpException count = Assert.Throws(() => + ReadTools.ValidateWaitPatterns(tooMany, null, 4_000)); + McpException bytes = Assert.Throws(() => + ReadTools.ValidateWaitPatterns(tooLarge, null, 128_000)); + + Assert.Contains("32", count.Message, StringComparison.Ordinal); + Assert.Contains("16384", bytes.Message, StringComparison.Ordinal); + } + + [Fact] + public void Escaping_that_cannot_fit_the_result_is_rejected() + { + string escaped = new('\n', 700); + + McpException error = Assert.Throws(() => + ReadTools.ValidateWaitPatterns([escaped], null, 4_000)); + + Assert.Contains("result byte ceiling", error.Message, StringComparison.Ordinal); + } + + [Fact] + 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, _) => + { + Interlocked.Increment(ref dispatches); + return Task.FromResult(new TmuxCommandResult( + request.LogicalArguments, + 0, + ReadOnlyMemory.Empty, + 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); + await using var activity = new PaneActivityHub(); + var tools = new ReadTools( + accessor, + new ServerPolicy { MaxBytes = 4_000 }, + activity); + + _ = await Assert.ThrowsAsync(() => tools.WaitForTextAsync( + patterns: [new string('x', 4_097)], + cancellationToken: TestContext.Current.CancellationToken)); + _ = await Assert.ThrowsAsync(() => tools.WaitForChannelAsync( + new string('x', 4_097), + cancellationToken: TestContext.Current.CancellationToken)); + + Assert.Equal(0, dispatches); + } + + [Fact] + public void A_cancelled_wait_stops_before_scanning_pane_text() + { + Regex[] patterns = [ReadTools.CompilePattern("ready", ignoreCase: false)]; + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + + Assert.Throws(() => ReadTools.Match( + patterns, + Enumerable.Repeat("not yet", 32_768).ToArray(), + cancellation.Token)); + } +} diff --git a/tests/LibTmux.UnitTests/Mcp/WriteToolsExecutionSafetyTests.cs b/tests/LibTmux.UnitTests/Mcp/WriteToolsExecutionSafetyTests.cs new file mode 100644 index 0000000..f148f1f --- /dev/null +++ b/tests/LibTmux.UnitTests/Mcp/WriteToolsExecutionSafetyTests.cs @@ -0,0 +1,723 @@ +using System.Collections.Concurrent; +using System.Runtime.Versioning; +using System.Text; +using LibTmux.Internal; +using LibTmux.Mcp; +using ModelContextProtocol; + +namespace LibTmux.UnitTests.Mcp; + +[UnsupportedOSPlatform("windows")] +public sealed class WriteToolsExecutionSafetyTests +{ + [Fact] + public async Task Batch_rejects_every_invalid_shape_before_query_or_mutation() + { + await using var fixture = new ToolFixture( + new ServerPolicy + { + MaxBytes = 4_000, + WaitCeiling = TimeSpan.FromSeconds(1), + }); + IReadOnlyList> invalid = + [ + [], + Enumerable.Range(0, 65).Select(_ => new KeyStep("x")).ToArray(), + [null!], + [new KeyStep(null!)], + [new KeyStep(new string('x', 4_001))], + [new KeyStep("x", DelayMilliseconds: 2_001)], + [ + new KeyStep("a", DelayMilliseconds: 600), + new KeyStep("b", DelayMilliseconds: 600), + ], + ]; + + foreach (IReadOnlyList steps in invalid) + { + _ = await Assert.ThrowsAnyAsync(() => + fixture.Tools.SendKeysBatchAsync( + steps, + paneId: "%1", + cancellationToken: TestContext.Current.CancellationToken)); + } + + Assert.Empty(fixture.Commands); + Assert.Equal(0, fixture.SuccessfulSends); + } + + [Fact] + public async Task Batch_second_step_not_dispatched_reports_one_prior_mutation_as_unknown() + { + await using var fixture = new ToolFixture { FailSendAttempt = 2 }; + + LibTmuxException failure = await Assert.ThrowsAsync(() => + fixture.Tools.SendKeysBatchAsync( + [new KeyStep("first"), new KeyStep("second")], + paneId: "%1", + cancellationToken: TestContext.Current.CancellationToken)); + + Assert.Equal(TmuxDispatchState.Unknown, failure.Dispatch); + Assert.Contains("do not retry", failure.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal(1, fixture.SuccessfulSends); + Assert.Equal(2, fixture.SendAttempts); + } + + [Fact] + public async Task Batch_ambiguous_first_step_is_normalized_to_unknown() + { + await using var fixture = new ToolFixture { AmbiguousSendAttempt = 1 }; + + LibTmuxException failure = await Assert.ThrowsAsync(() => + fixture.Tools.SendKeysBatchAsync( + [new KeyStep("first")], + paneId: "%1", + cancellationToken: TestContext.Current.CancellationToken)); + + Assert.Equal(TmuxDispatchState.Unknown, failure.Dispatch); + Assert.Contains("do not retry", failure.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal(0, fixture.SuccessfulSends); + } + + [Fact] + public async Task Batch_cancellation_during_delay_after_a_step_is_unknown() + { + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource( + TestContext.Current.CancellationToken); + await using var fixture = new ToolFixture + { + CancelAfterSuccessfulSend = cancellation, + }; + + LibTmuxException failure = await Assert.ThrowsAsync(() => + fixture.Tools.SendKeysBatchAsync( + [new KeyStep("first", DelayMilliseconds: 1_000)], + paneId: "%1", + cancellationToken: cancellation.Token)); + + Assert.Equal(TmuxDispatchState.Unknown, failure.Dispatch); + Assert.Equal(1, fixture.SuccessfulSends); + } + + [Fact] + public async Task Clear_history_failure_after_clear_is_unknown() + { + await using var fixture = new ToolFixture { FailClearHistory = true }; + + LibTmuxException failure = await Assert.ThrowsAsync(() => + fixture.Tools.ClearPaneAsync( + paneId: "%1", + includeHistory: true, + cancellationToken: TestContext.Current.CancellationToken)); + + Assert.Equal(TmuxDispatchState.Unknown, failure.Dispatch); + Assert.Contains("do not retry", failure.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal(2, fixture.SuccessfulSends); + } + + [Fact] + public async Task Run_reads_only_output_after_its_bound_baseline() + { + await using var fixture = new ToolFixture + { + BeforeLines = ["old screen"], + AfterLines = ["old screen", "fresh output"], + }; + + RunResult result = await fixture.Tools.RunAsync( + "echo fresh output", + paneId: "%1", + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Contains("fresh output", result.Output.Lines); + Assert.DoesNotContain("old screen", result.Output.Lines); + Assert.False(result.LinesMissed); + Assert.False(result.AnchorLost); + string payload = Assert.Single( + fixture.Commands.SelectMany(static arguments => arguments), + argument => argument.Contains("run-shell", StringComparison.Ordinal)); + Assert.Contains("'run-shell' '-b' '-d' '90'", payload, StringComparison.Ordinal); + Assert.DoesNotContain("sleep ", payload, StringComparison.Ordinal); + Assert.Contains(fixture.Commands, IsStatusUnset); + } + + [Fact] + public async Task Run_rejects_an_oversized_command_before_any_query_or_mutation() + { + await using var fixture = new ToolFixture(new ServerPolicy { MaxBytes = 4_000 }); + + McpException failure = await Assert.ThrowsAsync(() => + fixture.Tools.RunAsync( + new string('x', 4_001), + paneId: "%1", + cancellationToken: TestContext.Current.CancellationToken)); + + Assert.Contains("longer script in a file", failure.Message, StringComparison.Ordinal); + Assert.Empty(fixture.Commands); + } + + [Fact] + public async Task Unstable_first_tail_returns_no_cursor_and_a_later_stable_read_recovers() + { + await using var fixture = new ToolFixture(); + fixture.DestabilizeNextStateSamples(6); + + McpException failure = await Assert.ThrowsAsync(() => + fixture.Reads.TailPaneAsync( + paneId: "%1", + cancellationToken: TestContext.Current.CancellationToken)); + + Assert.Contains("every snapshot attempt", failure.Message, StringComparison.Ordinal); + Assert.Equal(6, fixture.StateSampleCount); + Assert.DoesNotContain(fixture.Commands, IsSendKeys); + + TailResult recovered = await fixture.Reads.TailPaneAsync( + paneId: "%1", + cancellationToken: TestContext.Current.CancellationToken); + + Assert.StartsWith("tmux-tail-v3:", recovered.Cursor, StringComparison.Ordinal); + Assert.False(recovered.LinesMissed); + Assert.False(recovered.AnchorLost); + } + + [Fact] + public async Task Tail_cursor_fingerprints_the_same_capture_the_call_observed() + { + await using var fixture = new ToolFixture + { + CaptureSequence = [["progress 10%"], ["progress 20%"]], + }; + + TailResult initial = await fixture.Reads.TailPaneAsync( + paneId: "%1", + cancellationToken: TestContext.Current.CancellationToken); + TailResult next = await fixture.Reads.TailPaneAsync( + paneId: "%1", + cursor: initial.Cursor, + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Contains("progress 20%", next.Content.Lines); + Assert.Equal(2, fixture.CaptureCount); + } + + [Fact] + public async Task Tail_cursor_uses_the_rebased_origin_after_history_eviction() + { + string[] newLines = [.. Enumerable.Range(0, 35).Select(static index => $"new {index}")]; + string[] changedCapture = + [ + .. Enumerable.Range(0, 90).Select(static index => $"history {index}"), + .. Enumerable.Range(0, 4).Select(static index => $"visible {index}"), + "old cursor", + .. newLines, + ]; + await using var fixture = new ToolFixture + { + CaptureSequence = + [ + [ + .. Enumerable.Range(0, 39).Select(static index => $"before {index}"), + "old cursor", + ], + changedCapture, + changedCapture, + ], + StateSequence = [new StateSample(90, 100, 40, 39)], + }; + + TailResult initial = await fixture.Reads.TailPaneAsync( + paneId: "%1", + cancellationToken: TestContext.Current.CancellationToken); + TailResult changed = await fixture.Reads.TailPaneAsync( + paneId: "%1", + cursor: initial.Cursor, + cancellationToken: TestContext.Current.CancellationToken); + TailResult idle = await fixture.Reads.TailPaneAsync( + paneId: "%1", + cursor: changed.Cursor, + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(newLines, changed.Content.Lines); + Assert.Empty(idle.Content.Lines); + Assert.Equal(3, fixture.CaptureCount); + } + + [Fact] + public async Task Tail_cursor_upward_redraw_is_reported_once() + { + string[] redraw = ["rewritten cursor", "new middle", "new bottom"]; + await using var fixture = new ToolFixture + { + CaptureSequence = + [ + ["before 0", "before 1", "before 2", "old cursor"], + redraw, + redraw, + ], + StateSequence = + [ + new StateSample(0, 50_000, 4, 3), + new StateSample(0, 50_000, 4, 3), + new StateSample(0, 50_000, 4, 1), + new StateSample(0, 50_000, 4, 1), + new StateSample(0, 50_000, 4, 1), + new StateSample(0, 50_000, 4, 1), + ], + }; + + TailResult initial = await fixture.Reads.TailPaneAsync( + paneId: "%1", + cancellationToken: TestContext.Current.CancellationToken); + TailResult changed = await fixture.Reads.TailPaneAsync( + paneId: "%1", + cursor: initial.Cursor, + cancellationToken: TestContext.Current.CancellationToken); + TailResult idle = await fixture.Reads.TailPaneAsync( + paneId: "%1", + cursor: changed.Cursor, + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(redraw, changed.Content.Lines); + Assert.Empty(idle.Content.Lines); + Assert.Equal(3, fixture.CaptureCount); + } + + [Fact] + public async Task Tail_idle_read_skips_the_entire_suffix_below_its_cursor() + { + string[] staticRows = + [.. Enumerable.Range(0, 40).Select(static index => $"static {index}")]; + await using var fixture = new ToolFixture + { + CaptureSequence = [staticRows, staticRows], + StateSequence = [new StateSample(0, 50_000, 40, 0)], + }; + + TailResult initial = await fixture.Reads.TailPaneAsync( + paneId: "%1", + cancellationToken: TestContext.Current.CancellationToken); + TailResult idle = await fixture.Reads.TailPaneAsync( + paneId: "%1", + cursor: initial.Cursor, + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Empty(idle.Content.Lines); + Assert.Equal(2, fixture.CaptureCount); + } + + [Fact] + public async Task Wait_for_any_output_does_not_match_an_idle_suffix() + { + string[] staticRows = + [.. Enumerable.Range(0, 40).Select(static index => $"static {index}")]; + await using var fixture = new ToolFixture( + new ServerPolicy { WaitCeiling = TimeSpan.FromSeconds(1) }) + { + CaptureSequence = [staticRows, staticRows, staticRows], + StateSequence = [new StateSample(0, 50_000, 40, 0)], + }; + + WaitResult result = await fixture.Reads.WaitForTextAsync( + paneId: "%1", + timeoutSeconds: 0.2, + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(WaitOutcome.Timeout, result.Outcome); + } + + [Fact] + public async Task Read_since_busy_retry_falls_back_to_a_new_stable_cursor() + { + await using var fixture = new ToolFixture(); + TailResult initial = await fixture.Reads.TailPaneAsync( + paneId: "%1", + cancellationToken: TestContext.Current.CancellationToken); + fixture.DestabilizeNextStateSamples(6); + + TailResult recovered = await fixture.Reads.TailPaneAsync( + paneId: "%1", + cursor: initial.Cursor, + cancellationToken: TestContext.Current.CancellationToken); + + Assert.True(recovered.LinesMissed); + Assert.True(recovered.AnchorLost); + Assert.NotEqual(initial.Cursor, recovered.Cursor); + } + + [Fact] + public async Task Run_does_not_dispatch_when_its_baseline_never_stabilizes() + { + await using var fixture = new ToolFixture(); + fixture.DestabilizeNextStateSamples(6); + + McpException failure = await Assert.ThrowsAsync(() => + fixture.Tools.RunAsync( + "echo never", + paneId: "%1", + cancellationToken: TestContext.Current.CancellationToken)); + + Assert.Contains("every snapshot attempt", failure.Message, StringComparison.Ordinal); + Assert.DoesNotContain(fixture.Commands, IsSendKeys); + Assert.DoesNotContain(fixture.Commands, IsStatusUnset); + } + + [Fact] + public async Task Start_job_does_not_dispatch_or_publish_when_its_baseline_never_stabilizes() + { + await using var fixture = new ToolFixture(); + fixture.DestabilizeNextStateSamples(6); + + McpException failure = await Assert.ThrowsAsync(() => + fixture.Tools.StartJobAsync( + "echo never", + paneId: "%1", + cancellationToken: TestContext.Current.CancellationToken)); + + Assert.Contains("every snapshot attempt", failure.Message, StringComparison.Ordinal); + Assert.DoesNotContain(fixture.Commands, IsSendKeys); + Assert.Equal(0, fixture.TrackedJobs); + } + + [Fact] + public async Task Run_post_dispatch_failure_is_unknown_and_cleans_its_marker() + { + await using var fixture = new ToolFixture { FailWait = true }; + + LibTmuxException failure = await Assert.ThrowsAsync(() => + fixture.Tools.RunAsync( + "echo once", + paneId: "%1", + cancellationToken: TestContext.Current.CancellationToken)); + + Assert.Equal(TmuxDispatchState.Unknown, failure.Dispatch); + Assert.Contains("do not retry", failure.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal(2, fixture.SuccessfulSends); + Assert.Contains(fixture.Commands, IsStatusUnset); + } + + [Fact] + public async Task Run_cancellation_uses_an_independent_marker_cleanup_token() + { + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource( + TestContext.Current.CancellationToken); + await using var fixture = new ToolFixture + { + CancelDuringWait = cancellation, + }; + + LibTmuxException failure = await Assert.ThrowsAsync(() => + fixture.Tools.RunAsync( + "echo once", + paneId: "%1", + cancellationToken: cancellation.Token)); + + Assert.Equal(TmuxDispatchState.Unknown, failure.Dispatch); + Assert.False(fixture.StatusUnsetTokenWasCancelled); + Assert.Contains(fixture.Commands, IsStatusUnset); + } + + [Fact] + public async Task Run_unknown_send_dispatch_still_attempts_independent_cleanup() + { + await using var fixture = new ToolFixture { UnknownSendAttempt = 1 }; + + TmuxTransportException failure = await Assert.ThrowsAsync(() => + fixture.Tools.RunAsync( + "echo maybe", + paneId: "%1", + cancellationToken: TestContext.Current.CancellationToken)); + + Assert.Equal(TmuxDispatchState.Unknown, failure.Dispatch); + Assert.Contains(fixture.Commands, IsStatusUnset); + } + + private static bool IsStatusUnset(string[] arguments) => + arguments.Contains("set-option", StringComparer.Ordinal) + && arguments.Contains("-u", StringComparer.Ordinal) + && arguments.Any(static argument => argument.StartsWith("@lt_s_", StringComparison.Ordinal)); + + private static bool IsSendKeys(string[] arguments) => + arguments.Contains("send-keys", StringComparer.Ordinal); + + private sealed record StateSample( + int HistorySize, + int HistoryLimit, + int PaneHeight, + int CursorY); + + private sealed class ToolFixture : IAsyncDisposable + { + private static readonly ServerGeneration Generation = new(121, 1201); + + private readonly TmuxConnectionAccessor _accessor; + private readonly PaneActivityHub _activity; + private readonly JobStore _jobs = new(); + private readonly object _stateGate = new(); + private int _captureCount; + private int _runStarted; + private int _stateSampleCount; + private int _stateVersion; + private int _unstableStateSamples; + + internal ToolFixture(ServerPolicy? policy = null) + { + _activity = new PaneActivityHub(static (_, _) => + Task.FromException( + new InvalidOperationException("Fake control attach unavailable."))); + var connection = new TmuxConnection( + new ServerConnectionOptions(socketName: "execution-safety"), + ExecuteAsync, + implementation: TmuxImplementation.Tmux); + var server = new Server(connection, Generation, "tmux 3.7"); + _accessor = new TmuxConnectionAccessor(server); + ServerPolicy effectivePolicy = policy ?? new ServerPolicy(); + Tools = new WriteTools( + _accessor, + effectivePolicy, + _activity, + _jobs); + Reads = new ReadTools(_accessor, effectivePolicy, _activity); + } + + internal IReadOnlyList AfterLines { get; init; } = ["fresh output"]; + + internal int? AmbiguousSendAttempt { get; init; } + + internal IReadOnlyList BeforeLines { get; init; } = ["prompt"]; + + internal CancellationTokenSource? CancelAfterSuccessfulSend { get; init; } + + internal CancellationTokenSource? CancelDuringWait { get; init; } + + internal int CaptureCount => Volatile.Read(ref _captureCount); + + internal IReadOnlyList>? CaptureSequence { get; init; } + + internal ConcurrentQueue Commands { get; } = new(); + + internal bool FailClearHistory { get; init; } + + internal int? FailSendAttempt { get; init; } + + internal bool FailWait { get; init; } + + internal int SendAttempts { get; private set; } + + internal IReadOnlyList? StateSequence { get; init; } + + internal int StateSampleCount + { + get + { + lock (_stateGate) + { + return _stateSampleCount; + } + } + } + + internal int SuccessfulSends { get; private set; } + + internal bool StatusUnsetTokenWasCancelled { get; private set; } + + internal int TrackedJobs => _jobs.List().TotalJobs; + + internal int? UnknownSendAttempt { get; init; } + + internal WriteTools Tools { get; } + + internal ReadTools Reads { get; } + + internal void DestabilizeNextStateSamples(int count) + { + ArgumentOutOfRangeException.ThrowIfNegative(count); + lock (_stateGate) + { + _unstableStateSamples = count; + } + } + + public async ValueTask DisposeAsync() + { + await _activity.DisposeAsync().ConfigureAwait(false); + await _jobs.DisposeAsync().ConfigureAwait(false); + _accessor.Dispose(); + } + + private Task ExecuteAsync( + TmuxCommandRequest request, + CancellationToken cancellationToken) + { + string[] arguments = [.. request.LogicalArguments]; + Commands.Enqueue(arguments); + if (arguments.Length > 0 && arguments[0] == "wait-for") + { + if (CancelDuringWait is not null) + { + CancelDuringWait.Cancel(); + cancellationToken.ThrowIfCancellationRequested(); + } + + if (FailWait) + { + throw new TmuxTransportException( + "wait was not dispatched", + arguments, + TmuxDispatchState.NotDispatched); + } + } + + if (arguments.Contains("clear-history", StringComparer.Ordinal) + && FailClearHistory) + { + throw new TmuxTransportException( + "clear-history was not dispatched", + arguments, + TmuxDispatchState.NotDispatched); + } + + if (arguments.Contains("send-keys", StringComparer.Ordinal)) + { + SendAttempts++; + if (AmbiguousSendAttempt == SendAttempts) + { + throw new TmuxOperationCanceledException( + "send may have executed", + cancellationToken, + commandMayHaveExecuted: true, + clientProcessId: 1201); + } + + if (FailSendAttempt == SendAttempts) + { + throw new TmuxTransportException( + "send was not dispatched", + arguments, + TmuxDispatchState.NotDispatched); + } + + if (UnknownSendAttempt == SendAttempts) + { + throw new TmuxTransportException( + "send dispatch is unknown", + arguments, + TmuxDispatchState.Unknown); + } + + SuccessfulSends++; + Volatile.Write(ref _runStarted, 1); + CancelAfterSuccessfulSend?.Cancel(); + } + + if (IsStatusUnset(arguments)) + { + StatusUnsetTokenWasCancelled |= cancellationToken.IsCancellationRequested; + } + + return Task.FromResult(Success(arguments, Output(arguments))); + } + + private string Output(IReadOnlyList arguments) + { + string body = arguments.Contains("list-panes", StringComparer.Ordinal) + ? PaneListing() + : arguments.Any(static argument => argument.Contains( + "#{history_size}", + StringComparison.Ordinal)) + ? StateListing() + : arguments.Contains("capture-pane", StringComparer.Ordinal) + ? Lines(CaptureLines()) + : arguments.Contains("show-options", StringComparer.Ordinal) + ? $"{arguments[^1]} 0\n" + : string.Empty; + return IsGuarded(arguments) + ? $"{Generation.ProcessId}:{Generation.StartTime}\n{body}" + : body; + } + + private static string Lines(IReadOnlyList lines) => + lines.Count == 0 ? string.Empty : string.Join('\n', lines) + "\n"; + + private IReadOnlyList CaptureLines() + { + int index = Interlocked.Increment(ref _captureCount) - 1; + if (CaptureSequence is { Count: > 0 } sequence) + { + return sequence[Math.Min(index, sequence.Count - 1)]; + } + + return Volatile.Read(ref _runStarted) == 0 ? BeforeLines : AfterLines; + } + + private string StateListing() + { + StateSample state; + lock (_stateGate) + { + int sampleIndex = _stateSampleCount; + _stateSampleCount++; + if (StateSequence is { Count: > 0 } sequence) + { + state = sequence[Math.Min(sampleIndex, sequence.Count - 1)]; + } + else + { + if (_unstableStateSamples > 0) + { + _unstableStateSamples--; + _stateVersion++; + } + + int cursorY = Volatile.Read(ref _runStarted) == 0 ? 0 : 1; + state = new StateSample(_stateVersion, 50_000, 24, cursorY); + } + } + + return $"4242\t{state.HistorySize}\t{state.HistoryLimit}\t" + + $"{state.PaneHeight}\t{state.CursorY}\t0\t0\n"; + } + + private static bool IsGuarded(IReadOnlyList arguments) => + arguments.Count > 2 + && arguments[0] == "display-message" + && arguments[2] == "#{pid}:#{start_time}"; + + private static string PaneListing() + { + FormatProjection projection = FormatProjection.Create( + "list-panes", + TmuxVersion.Parse("3.7")); + return string.Concat(projection.Fields.Select( + static field => FieldValue(field.WireName) + FormatProjection.RowSeparator)) + "\n"; + } + + private static string FieldValue(string field) => field switch + { + "pid" => Generation.ProcessId.ToString(System.Globalization.CultureInfo.InvariantCulture), + "start_time" => Generation.StartTime.ToString( + System.Globalization.CultureInfo.InvariantCulture), + "session_id" => "$1", + "window_id" => "@1", + "pane_id" => "%1", + "pane_pid" => "4242", + "pane_width" => "80", + "pane_height" => "24", + "pane_active" => "1", + _ => string.Empty, + }; + + private static TmuxCommandResult Success( + IReadOnlyList arguments, + string standardOutput) + { + byte[] output = Encoding.UTF8.GetBytes(standardOutput); + return new TmuxCommandResult( + arguments, + 0, + output, + ReadOnlyMemory.Empty, + Utf8BackslashDecoder.ProjectOutputLines(output), + []); + } + } +} diff --git a/tests/LibTmux.UnitTests/Testing/TemporaryScopeCleanupTests.cs b/tests/LibTmux.UnitTests/Testing/TemporaryScopeCleanupTests.cs new file mode 100644 index 0000000..e46479e --- /dev/null +++ b/tests/LibTmux.UnitTests/Testing/TemporaryScopeCleanupTests.cs @@ -0,0 +1,60 @@ +using LibTmux.Testing; + +namespace LibTmux.UnitTests.Testing; + +public sealed class TemporaryScopeCleanupTests +{ + [Fact] + public async Task Disposal_unwinds_child_before_parent() + { + List order = []; + var child = new RecordingDisposable(() => order.Add("child")); + var parent = new RecordingDisposable(() => order.Add("parent")); + + await TemporaryScopeCleanup.DisposeAsync(child, parent); + + Assert.Equal(["child", "parent"], order); + } + + [Fact] + public async Task Parent_cleanup_runs_and_is_attached_when_child_cleanup_fails() + { + var childFailure = new IOException("child cleanup failed"); + var parentFailure = new IOException("parent cleanup failed"); + var child = new RecordingDisposable(() => throw childFailure); + var parent = new RecordingDisposable(() => throw parentFailure); + + IOException thrown = await Assert.ThrowsAsync(async () => + await TemporaryScopeCleanup.DisposeAsync(child, parent)); + + Assert.Same(childFailure, thrown); + Assert.Contains(parentFailure, thrown.Data.Values.Cast()); + Assert.Equal(1, child.Calls); + Assert.Equal(1, parent.Calls); + } + + [Fact] + public async Task Failed_creation_preserves_primary_and_attaches_cleanup_failure() + { + var primary = new InvalidOperationException("creation failed"); + var cleanup = new IOException("server cleanup failed"); + var parent = new RecordingDisposable(() => throw cleanup); + + await TemporaryScopeCleanup.DisposeAfterFailureAsync(parent, primary); + + Assert.Contains(cleanup, primary.Data.Values.Cast()); + Assert.Equal(1, parent.Calls); + } + + private sealed class RecordingDisposable(Action dispose) : IAsyncDisposable + { + internal int Calls { get; private set; } + + public ValueTask DisposeAsync() + { + Calls++; + dispose(); + return ValueTask.CompletedTask; + } + } +} diff --git a/tests/LibTmux.UnitTests/Transport/TmuxProcessTransportTests.cs b/tests/LibTmux.UnitTests/Transport/TmuxProcessTransportTests.cs index 99a2558..cae0448 100644 --- a/tests/LibTmux.UnitTests/Transport/TmuxProcessTransportTests.cs +++ b/tests/LibTmux.UnitTests/Transport/TmuxProcessTransportTests.cs @@ -223,6 +223,36 @@ public async Task Pre_start_cancellation_throws_OperationCanceledException_with_ Assert.Empty(launcher.StartInfos); } + [UnixFact] + public async Task Cancellation_during_async_preflight_prevents_process_start() + { + var entered = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + using var cancellation = new CancellationTokenSource(); + var launcher = new QueueProcessLauncher( + FakeProcessHandle.Completed(7057, [], [], exitCode: 0)); + var transport = new TmuxProcessTransport( + "tmux", + launcher: launcher, + beforeStart: async (_, token) => + { + entered.TrySetResult(); + await Task.Delay(Timeout.InfiniteTimeSpan, token); + }); + + Task execution = transport.ExecuteAsync( + ["list-sessions"], + cancellation.Token); + await entered.Task.WaitAsync(TestContext.Current.CancellationToken); + cancellation.Cancel(); + + OperationCanceledException error = await Assert.ThrowsAnyAsync( + () => execution); + + Assert.Equal(cancellation.Token, error.CancellationToken); + Assert.Empty(launcher.StartInfos); + } + [UnixFact] public async Task Cancellation_during_argv_setup_still_prevents_process_start() { @@ -1330,12 +1360,13 @@ public void Unix_process_tests_have_runtime_skip_metadata() [SuppressMessage( "Interoperability", "CA1416:Validate platform compatibility", - Justification = "This Windows-only test verifies the runtime platform guard.")] - public async Task Process_transport_throws_platform_exception_on_windows() + Justification = "This Windows-only test verifies process launch is reachable.")] + public async Task Process_transport_reaches_process_launch_on_windows() { - var transport = new TmuxProcessTransport("tmux"); + string missing = Path.Combine(Path.GetTempPath(), $"missing-tmux-{Guid.NewGuid():N}.exe"); + var transport = new TmuxProcessTransport(missing); - await Assert.ThrowsAsync( + await Assert.ThrowsAsync( () => transport.ExecuteAsync( ["display-message"], TestContext.Current.CancellationToken)); diff --git a/tests/LibTmux.UnitTests/Versioning/TmuxCapabilitiesTests.cs b/tests/LibTmux.UnitTests/Versioning/TmuxCapabilitiesTests.cs index 8c92eaa..47788d3 100644 --- a/tests/LibTmux.UnitTests/Versioning/TmuxCapabilitiesTests.cs +++ b/tests/LibTmux.UnitTests/Versioning/TmuxCapabilitiesTests.cs @@ -22,6 +22,7 @@ public void Comparisons_cover_equal_older_and_newer_versions() [Theory] [InlineData("3.7", 3, 7, null)] + [InlineData("3.3.7", 3, 3, "7")] [InlineData("3.7b", 3, 7, "b")] [InlineData("3.0-rc3", 3, 0, "rc3")] [InlineData("3.3a-openbsd", 3, 3, "a-openbsd")] @@ -55,7 +56,8 @@ public void Parsing_preserves_every_canonical_projection( [InlineData("03.7")] [InlineData("3.07")] [InlineData("3.7B")] - [InlineData("3.7.1")] + [InlineData("3.7.01")] + [InlineData("3.7.2147483648")] [InlineData("3.7-")] [InlineData("+3.7")] [InlineData("2147483648.7")] @@ -99,6 +101,9 @@ public void Parsing_distinguishes_null_and_normalizes_default() [InlineData("3.7-rc2", "3.7")] [InlineData("3.7", "3.7-openbsd")] [InlineData("3.7-openbsd", "3.7a")] + [InlineData("3.3", "3.3.1")] + [InlineData("3.3.1", "3.3.10")] + [InlineData("3.3.10", "3.3a")] [InlineData("3.7a", "3.7a-openbsd")] [InlineData("3.7a-openbsd", "3.7b")] [InlineData("3.7z", "3.7aa")] @@ -371,6 +376,7 @@ public void Capability_profiles_are_exact_and_never_floor_selected() } 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( @@ -510,6 +516,10 @@ public async Task Detection_rejects_malformed_success_output_without_trimming() "printf ' tmux 3.7b\\n'", "printf 'tmux 3.7b '", "printf 'tmux master\\n'", + "printf 'tmux 3.3.7\\npsmux 3.3.8\\n'", + "printf 'tmux 3.3.7\\npsmux 3.3.7 ()\\n'", + "printf 'tmux 3.3.7\\npsmux 3.3.7 (abc)\\nextra\\n'", + "printf 'tmux 3.3.7\\rpsmux 3.3.7\\n'", "printf '\\377'", ]; foreach (string script in scripts) diff --git a/tests/LibTmux.UnitTests/packages.lock.json b/tests/LibTmux.UnitTests/packages.lock.json index 843e73f..d922329 100644 --- a/tests/LibTmux.UnitTests/packages.lock.json +++ b/tests/LibTmux.UnitTests/packages.lock.json @@ -456,7 +456,7 @@ "libtmux.mcp": { "type": "Project", "dependencies": { - "LibTmux": "[0.0.0-alpha.6, )", + "LibTmux": "[0.0.0-alpha.8, )", "Microsoft.Extensions.Hosting": "[10.0.11, )", "ModelContextProtocol": "[2.2.0, )", "ModelContextProtocol.Extensions.Tasks": "[2.2.0, )" @@ -465,7 +465,7 @@ "libtmux.query.json": { "type": "Project", "dependencies": { - "LibTmux": "[0.0.0-alpha.6, )" + "LibTmux": "[0.0.0-alpha.8, )" } }, "Microsoft.Extensions.Hosting": { @@ -1032,13 +1032,13 @@ "libtmux": { "type": "Project", "dependencies": { - "Microsoft.Extensions.Logging.Abstractions": "[10.0.11, )" + "Microsoft.Extensions.Logging.Abstractions": "[8.0.0, )" } }, "libtmux.mcp": { "type": "Project", "dependencies": { - "LibTmux": "[0.0.0-alpha.6, )", + "LibTmux": "[0.0.0-alpha.8, )", "Microsoft.Extensions.Hosting": "[10.0.11, )", "ModelContextProtocol": "[2.2.0, )", "ModelContextProtocol.Extensions.Tasks": "[2.2.0, )" @@ -1047,7 +1047,7 @@ "libtmux.query.json": { "type": "Project", "dependencies": { - "LibTmux": "[0.0.0-alpha.6, )" + "LibTmux": "[0.0.0-alpha.8, )" } }, "Microsoft.Extensions.Hosting": { @@ -1082,7 +1082,7 @@ }, "Microsoft.Extensions.Logging.Abstractions": { "type": "CentralTransitive", - "requested": "[10.0.11, )", + "requested": "[8.0.0, )", "resolved": "10.0.11", "contentHash": "Ljd0Uxoq5XpScD2Bg0nM/r3mwx7Ao5Uq24eo2ARxbGvqJ7Zht6rt2cJtwVRH4Cv+1ZVMdXz6TB43KbpmsxRrvQ==", "dependencies": { @@ -1112,4 +1112,4 @@ } } } -} \ No newline at end of file +}