From 6e8251d137b109c4c6a6fda4507b4c8eb060b8c5 Mon Sep 17 00:00:00 2001 From: Ikiru Yoshizaki <3856350+guitarrapc@users.noreply.github.com> Date: Thu, 9 Jul 2026 14:18:08 +0900 Subject: [PATCH 01/13] feat: add MiniPty.Terminal to support Editor backend --- .github/docs/plans/plan_editor_backend.md | 50 ++ .github/docs/plans/plan_minipty_next.md | 23 +- .github/docs/references/pty_crossplatform.md | 2 + .github/docs/spec.md | 23 +- .github/docs/specs/console.md | 4 +- .github/docs/specs/core_session.md | 16 +- .github/docs/specs/lifecycle.md | 2 + .github/docs/specs/terminal.md | 157 ++++++ .github/workflows/build.yaml | 2 +- MiniPty.slnx | 1 + README.md | 23 + samples/WebTerminal.cs | 316 +++++++++++ scripts/pack-with-native.sh | 3 + .../Internal/BridgeFlowControl.cs | 81 +++ src/MiniPty.Terminal/Internal/BridgeJson.cs | 61 +++ src/MiniPty.Terminal/MiniPty.Terminal.csproj | 24 + src/MiniPty.Terminal/PtyBridgeOptions.cs | 58 ++ src/MiniPty.Terminal/PtyTerminal.cs | 239 +++++++++ src/MiniPty.Terminal/PtyTerminalOptions.cs | 33 ++ src/MiniPty.Terminal/PtyWebSocketBridge.cs | 339 ++++++++++++ src/MiniPty/Internal/IPtyBackend.cs | 6 + src/MiniPty/Internal/UnixInterop.cs | 9 + src/MiniPty/Internal/UnixPtyBackend.cs | 42 +- src/MiniPty/Internal/WindowsPtyBackend.cs | 16 + src/MiniPty/PtyExitStatus.cs | 16 + src/MiniPty/PtySession.cs | 44 ++ src/MiniPty/PtySignal.cs | 34 ++ tests/MiniPty.Tests/MiniPty.Tests.csproj | 1 + tests/MiniPty.Tests/PtyTerminalTests.cs | 241 +++++++++ tests/MiniPty.Tests/PtyTests.cs | 100 ++++ .../MiniPty.Tests/PtyWebSocketBridgeTests.cs | 503 ++++++++++++++++++ 31 files changed, 2443 insertions(+), 26 deletions(-) create mode 100644 .github/docs/plans/plan_editor_backend.md create mode 100644 .github/docs/specs/terminal.md create mode 100644 samples/WebTerminal.cs create mode 100644 src/MiniPty.Terminal/Internal/BridgeFlowControl.cs create mode 100644 src/MiniPty.Terminal/Internal/BridgeJson.cs create mode 100644 src/MiniPty.Terminal/MiniPty.Terminal.csproj create mode 100644 src/MiniPty.Terminal/PtyBridgeOptions.cs create mode 100644 src/MiniPty.Terminal/PtyTerminal.cs create mode 100644 src/MiniPty.Terminal/PtyTerminalOptions.cs create mode 100644 src/MiniPty.Terminal/PtyWebSocketBridge.cs create mode 100644 src/MiniPty/PtyExitStatus.cs create mode 100644 src/MiniPty/PtySignal.cs create mode 100644 tests/MiniPty.Tests/PtyTerminalTests.cs create mode 100644 tests/MiniPty.Tests/PtyWebSocketBridgeTests.cs diff --git a/.github/docs/plans/plan_editor_backend.md b/.github/docs/plans/plan_editor_backend.md new file mode 100644 index 0000000..2f33b36 --- /dev/null +++ b/.github/docs/plans/plan_editor_backend.md @@ -0,0 +1,50 @@ +# MiniPty Editor Backend Plan (Use Case 4) + +The "separate plan" deferred from [plan_minipty_next.md](plan_minipty_next.md) (Deferred: Editor Terminal Backend). Goal: make MiniPty usable as the backend PTY (node-pty–equivalent role) for xterm.js and editor terminals such as VS Code. + +**Status: implemented.** Contracts live in [specs/terminal.md](../specs/terminal.md) and [specs/core_session.md](../specs/core_session.md); this document records scope decisions and the gap analysis that drove them. + +## Gap analysis (node-pty / xterm.js / VS Code) + +Research inputs: the node-pty `IPty` API, the xterm.js flow-control guide (ACK watermark protocol), and the VS Code `Pseudoterminal` extension API. + +| Needed by frontends | node-pty shape | MiniPty before this plan | Resolution | +|---|---|---|---| +| Push output / exit-after-drain | `onData` / `onExit` | pull (`ReadOutputAsync` / `WaitForExitAsync`) | **MiniPty.Terminal** `PtyTerminal` (handler + `Completion`) | +| Flow control | `pause()` / `resume()` | strict handoff (consumer-driven) | `PtyTerminal.Pause/Resume` — pump gate; no core change needed | +| WebSocket bridging + ACK watermarks | external server code | none | `PtyWebSocketBridge` (binary=data, text=JSON control) | +| Exit signal reporting | `onExit {exitCode, signal}` | exit code only | core `PtyExitStatus` / `WaitForExitStatusAsync` / `ExitStatus` | +| Signal kill | `kill(signal)` | SIGKILL only | core `Kill(PtySignal)` (Windows: advisory, terminates — node-pty parity) | +| ConPTY `clear()` | conpty.dll signal pipe | — | **rejected**: not reachable via public Win32 API; node-pty ships its own conpty.dll | +| Pixel-size resize, uid/gid, `openpty` w/o spawn | supported | — | deferred (no frontend demand yet) | +| Process title tracking (`process`) | supported | — | deferred; needs per-OS foreground-process lookup (tcgetpgrp + /proc, libproc) | + +## Scope decisions + +- **Single new package `MiniPty.Terminal`** (facade + transport-agnostic bridge; depends on MiniPty only). One package because the bridge is thin and both layers target the same audience; a split (`.Xterm`) would multiply packaging for no dependency win. +- **Core additions limited to exit-status/signal parity** (`PtyExitStatus`, `WaitForExitStatusAsync`, `Kill(PtySignal)`). Flow control did not need core changes: strict handoff already makes "stop consuming" equal "backpressure the child", so pause/resume is a facade-level gate. +- **Sample = browser E2E** ([samples/WebTerminal.cs](../../../samples/WebTerminal.cs)): `HttpListener` + embedded xterm.js page + `--smoke` self-check wired into the CI NativeAOT sample loop. A VS Code extension is documented as a bridging pattern in [terminal.md](../specs/terminal.md), not shipped. + +## Implementation record (2026-07-09) + +| Stage | Delivered | +|---|---| +| Core parity | `PtySignal`, `PtyExitStatus`, `PtySession.ExitStatus` / `WaitForExitStatusAsync` / `Kill(PtySignal)`; `IPtyBackend.ExitSignal` / `Kill(signal)`; Unix `_termSignal` capture in `TryRefreshExitState`; portable `WIFSIGNALED` fix (see lessons). No native shim change — waitpid decoding was already managed and the exited/signaled status layout is identical on Linux/macOS/FreeBSD. | +| Facade | `PtyTerminal` + `PtyTerminalOptions` (required output handler, `Completion`, `Pause`/`Resume` gate). | +| Bridge | `PtyWebSocketBridge` + `PtyBridgeOptions`; `Internal/BridgeFlowControl` (locked watermark state, `Disable` for teardown), `Internal/BridgeJson` (source-generated `System.Text.Json`). | +| Sample/CI | `samples/WebTerminal.cs` added to the `build.yaml` NativeAOT sample loop; `scripts/pack-with-native.sh` packs the new package; verified interactively in a real browser (input, output, resize, exit banner, NormalClosure). | +| Tests | `PtyTests` exit-status/kill-signal additions; `PtyTerminalTests`; `PtyWebSocketBridgeTests` over `WebSocket.CreateFromStream` on an in-memory duplex stream pair (framing, flow-control stall/resume, exit ordering, close semantics, policy violation). | + +## Lessons learned + +Contract-level lessons live in [specs/terminal.md](../specs/terminal.md), [core_session.md](../specs/core_session.md), and [lifecycle.md](../specs/lifecycle.md). Plan-level: + +- The direct C# transcription of glibc's `WIFSIGNALED` macro loses the signed-char cast and misclassifies the stopped marker `0x7f`; unreachable under WNOHANG-only polling, but it became load-bearing the moment the signal turned into public API. Use `(status & 0x7f) != 0 && (status & 0x7f) != 0x7f`. +- SIGUSR1/SIGUSR2 numbering differs per OS (Linux 10/12, macOS/FreeBSD 30/31), which is why `Kill` takes a `PtySignal` enum mapped per platform instead of a raw int, while `PtyExitStatus.Signal` reports the raw OS number (node-pty parity). +- `PtyExitStatus.ExitCode` deliberately keeps MiniPty's `128 + signal` semantics instead of node-pty's 0-on-signal so a killed child never reads as success and `ExitStatus.ExitCode == ExitCode` always holds. + +## Deferred / future + +- `Attach(PtySession)` facade overload → prerequisite for bridge reconnect/detach options. +- Process title tracking, pixel-size resize, uid/gid, `openpty` without spawn — revisit on demand. +- ConPTY `clear()` — only viable by shipping conpty.dll (third-party dependency; against AGENTS.md) or if Windows exposes a public API. diff --git a/.github/docs/plans/plan_minipty_next.md b/.github/docs/plans/plan_minipty_next.md index 9443d8c..7fd9ff2 100644 --- a/.github/docs/plans/plan_minipty_next.md +++ b/.github/docs/plans/plan_minipty_next.md @@ -214,10 +214,7 @@ public readonly record struct PtyExitStatus( string? Signal = null); ``` -Open questions: - -- Whether signal reporting is worth adding now or should remain future work. -- Whether `ExitCode` should remain the simple cross-platform contract and `PtyExitStatus` be introduced only when needed. +Resolved (editor backend plan): implemented as `PtyExitStatus(int ExitCode, int? Signal)` with `WaitForExitStatusAsync` / `ExitStatus`. The signal is the raw OS number (node-pty parity), not a string; `ExitCode` keeps the `128 + signal` contract. See [specs/core_session.md](../specs/core_session.md) and [plan_editor_backend.md](plan_editor_backend.md). ### Persistent Session Convenience @@ -665,16 +662,16 @@ This sample does not use host raw mode. It demonstrates persistent **transport** - [ ] Manual smoke on host TTY (vim or minimal TUI) — verify locally on macOS / interactive host. - [x] README / spec.md updated when implemented. -### Deferred: Editor Terminal Backend (use case 4 — separate plan) +### Editor Terminal Backend (use case 4 — **implemented in a separate plan**) -Not part of Milestone 5. Track in a **separate plan** when VS Code / xterm.js backend work starts. +Implemented as **MiniPty.Terminal** plus core exit-status parity; see [plan_editor_backend.md](plan_editor_backend.md) and [specs/terminal.md](../specs/terminal.md). -Candidate **MiniPty core** parity items (formerly “Milestone 5 node-pty”): +Resolution of the candidate parity items: -- Flow control (`pause` / `resume`, XON/XOFF). -- Windows ConPTY `clear()`. -- `PtyExitStatus` / signal reporting; `Kill(signal)` on Unix. -- Unix pixel winsize; `uid` / `gid`; `openpty` without spawn; ConPTY cursor inheritance. +- Flow control (`pause` / `resume`) — implemented in `PtyTerminal` (facade pump gate; no core change needed under strict handoff). +- `PtyExitStatus` / signal reporting; `Kill(signal)` — implemented in **MiniPty** core. +- Windows ConPTY `clear()` — rejected: requires node-pty's bundled conpty.dll signal pipe; not reachable via public Win32 API. +- Unix pixel winsize; `uid` / `gid`; `openpty` without spawn; ConPTY cursor inheritance — deferred (no frontend demand yet). **MiniPty.Console** is not used for use case 4. @@ -719,9 +716,9 @@ As milestones land, update: - Should backpressure limits (buffer upper bound, chunk size) become configurable start/read options? - Is Unix `uid` / `gid` worth supporting in a minimal AOT-friendly library? - Does Windows ConPTY require a public ready state or only internal write/resize deferral? -- Should flow control be explicit (`Pause` / `Resume`) or expressed through bounded async streams/channels? +- ~~Should flow control be explicit (`Pause` / `Resume`) or expressed through bounded async streams/channels?~~ — resolved: explicit `Pause` / `Resume` on **MiniPty.Terminal** `PtyTerminal`; core keeps strict-handoff backpressure only ([terminal.md](../specs/terminal.md)). - ~~Should `MiniPty.Console` be a package, a sample, or both at first?~~ — resolved: separate **MiniPty.Console** NuGet; spec in [console.md](../specs/console.md). -- Editor terminal backend (use case 4) parity items deferred to a **separate plan** (not Milestone 5). +- ~~Editor terminal backend (use case 4) parity items deferred to a **separate plan** (not Milestone 5).~~ — resolved: implemented; see [plan_editor_backend.md](plan_editor_backend.md). ## Guiding Principle diff --git a/.github/docs/references/pty_crossplatform.md b/.github/docs/references/pty_crossplatform.md index 6e604b9..ac30d07 100644 --- a/.github/docs/references/pty_crossplatform.md +++ b/.github/docs/references/pty_crossplatform.md @@ -44,6 +44,8 @@ Library callers use `Pty.Start` → `PtySession`. `PtyCapture.RunAsync` wraps th | `WaitForExitAsync(CancellationToken)` | Polls the child. Cancellation stops waiting only; the child keeps running (`OperationCanceledException`). | | `CompleteAsync(PtyCompleteOptions, CancellationToken)` | Optional stdin, wait for exit, drain output, return `PtyResult`. Cancellation kills the child when `KillOnCancellation` is true (default). | | `Kill()` | `TerminateProcess` (Windows) or `kill(SIGKILL)` (Unix). Does not release handles; call `Dispose` afterward. | +| `Kill(PtySignal)` | **Unix:** `kill(2)` with the per-OS native number (SIGUSR1/2 differ: Linux 10/12, macOS/FreeBSD 30/31). **Windows:** signal is advisory; validates the enum then `TerminateProcess` (node-pty parity). | +| `WaitForExitStatusAsync` / `ExitStatus` | Adds the Unix termination signal (`waitpid` `WTERMSIG`) to the exit code; decoding is fully managed (identical exited/signaled status layout on Linux/macOS/FreeBSD). Windows always reports a null signal. | | `PtyCompleteOptions.OutputDrainGrace` | Default 1s—post-exit drain before closing transport. | | `PtyCompleteOptions.OutputReaderCloseTimeout` | Default 5s—wait after transport close for the reader to finish. | | `Dispose()` | If the child is still running, **kills** it, then closes ConPTY/pipes/process handles. On Unix, `Dispose` also attempts a bounded `waitpid` after `SIGKILL` to avoid leaving a zombie (up to ~1s). | diff --git a/.github/docs/spec.md b/.github/docs/spec.md index 9f7e541..9a981e3 100644 --- a/.github/docs/spec.md +++ b/.github/docs/spec.md @@ -1,6 +1,6 @@ # MiniPty Specification -User-facing specification entry point for **MiniPty**, **MiniPty.Capture**, and **MiniPty.Console** NuGet packages. Detailed contracts are split by behavior area under [specs/](specs/). OS-level implementation notes live in [references/](references/) (for example [pty_crossplatform.md](references/pty_crossplatform.md), [windows_console_input.md](references/windows_console_input.md)). +User-facing specification entry point for **MiniPty**, **MiniPty.Capture**, **MiniPty.Console**, and **MiniPty.Terminal** NuGet packages. Detailed contracts are split by behavior area under [specs/](specs/). OS-level implementation notes live in [references/](references/) (for example [pty_crossplatform.md](references/pty_crossplatform.md), [windows_console_input.md](references/windows_console_input.md)). ## Motivation @@ -15,7 +15,7 @@ MiniPty exists as a **standalone, NativeAOT-friendly** library so any .NET progr | **1** | PTY transport (node-pty–equivalent core) | **MiniPty** | [Core session](specs/core_session.md), [Lifecycle](specs/lifecycle.md) | | **2** | One-shot stdin + timestamped record | **MiniPty.Capture** | [Capture](specs/capture.md) | | **3** | Interactive host (vim, etc.) — human types on real terminal | **MiniPty** + **MiniPty.Console** | [Console](specs/console.md) (embedder owns `ReadOutputAsync`) | -| **4** | In-editor terminal backend (xterm.js, etc.) | **MiniPty** only | Core embedder pattern in [Core session](specs/core_session.md); editor parity features are a **separate plan** | +| **4** | In-editor terminal backend (xterm.js, etc.) | **MiniPty** + **MiniPty.Terminal** | [Terminal](specs/terminal.md) (push facade, WebSocket bridge, flow control) | Use case 3 recording and cast format remain **scenetake** (or other embedder) responsibilities. **MiniPty.Console** does not record output. @@ -26,23 +26,27 @@ Use case 3 recording and cast format remain **scenetake** (or other embedder) re | **MiniPty** | Core PTY transport: spawn, streams, lifecycle | — | | **MiniPty.Capture** | One-shot run with per-read timestamps (`PtyCapture.RunAsync`) | MiniPty | | **MiniPty.Console** | Host keyboard → PTY input (`PtyConsoleInput.Attach`) | MiniPty | +| **MiniPty.Terminal** | Frontend terminal backend: push facade (`PtyTerminal`), xterm.js WebSocket bridge (`PtyWebSocketBridge`) | MiniPty | ```mermaid flowchart TB M["MiniPty"] C["MiniPty.Capture"] O["MiniPty.Console"] + T["MiniPty.Terminal"] C --> M O --> M + T --> M ``` | You need… | Packages | |---|---| -| General PTY I/O, `ReadOutputAsync`, `CompleteAsync`, editor backend | **MiniPty** | +| General PTY I/O, `ReadOutputAsync`, `CompleteAsync` | **MiniPty** | | One-shot command with timestamped output chunks | **MiniPty** + **MiniPty.Capture** | | Human types on the host terminal (vim, etc.) | **MiniPty** + **MiniPty.Console** (+ your `ReadOutputAsync` for display/record) | +| Backend PTY for xterm.js / editor terminals (push events, flow control, WebSocket) | **MiniPty** + **MiniPty.Terminal** | -**MiniPty.Capture** and **MiniPty.Console** are optional add-ons. Both depend on core only; Console does not read PTY output. +**MiniPty.Capture**, **MiniPty.Console**, and **MiniPty.Terminal** are optional add-ons. All depend on core only; Console does not read PTY output; Terminal owns its session's output exclusively. ## Implemented Scope @@ -57,21 +61,25 @@ flowchart TB | Understand supported OS targets and public platform guarantees | [Platform support](specs/platform_support.md) | | Persistent transport sample (`ReadOutputAsync` command loop) | [samples/Interactive.cs](../../samples/Interactive.cs) | | Attach host terminal input to an existing `PtySession` (use case 3) | [Console](specs/console.md) | +| Exit status with Unix termination signal; `Kill(PtySignal)` | [Core session](specs/core_session.md) | +| Backend PTY for frontend terminals: push facade, flow control, xterm.js WebSocket bridge (use case 4) | [Terminal](specs/terminal.md) | +| Browser terminal sample (xterm.js over WebSocket) | [samples/WebTerminal.cs](../../samples/WebTerminal.cs) | ## Planned Scope -_(No open package milestones. Editor terminal backend parity for use case 4 is tracked in a separate plan.)_ +_(No open package milestones.)_ ## Out of Scope For The Current Implementation - Terminal emulation, TUI replay, or faithful screen-buffer rendering - Cast / asciinema recording (embedder responsibility; scenetake for example) -- In-editor terminal integration and node-pty parity (pause, ConPTY clear, exit signal split) — separate plan for use case 4 +- Windows ConPTY `clear()` (requires the conpty.dll signal pipe; not reachable via public Win32 API) +- Terminal session reconnect / detach-reattach ([Terminal](specs/terminal.md) non-goal N5) - Remote shells (`ssh`) - Spilling capture to disk when memory is exhausted - Capture tuning such as max chunk size or chunk timestamp modes -Planning notes for Console implementation and deferred editor parity are in [plans/plan_minipty_next.md](plans/plan_minipty_next.md). Planning documents are not implemented API contracts unless mirrored in [specs/](specs/). +Planning notes for Console implementation are in [plans/plan_minipty_next.md](plans/plan_minipty_next.md); the editor terminal backend (use case 4) decision record is in [plans/plan_editor_backend.md](plans/plan_editor_backend.md). Planning documents are not implemented API contracts unless mirrored in [specs/](specs/). ## Related Documents @@ -79,6 +87,7 @@ Planning notes for Console implementation and deferred editor parity are in [pla - [specs/completion.md](specs/completion.md) — `CompleteAsync`, `PtyCompleteOptions`, `PtyResult` - [specs/capture.md](specs/capture.md) — `MiniPty.Capture` timestamped observation - [specs/console.md](specs/console.md) — `MiniPty.Console` host input attach +- [specs/terminal.md](specs/terminal.md) — `MiniPty.Terminal` frontend terminal backend (use case 4) - [specs/display_text.md](specs/display_text.md) — `PtyOutput.ToDisplayText` - [specs/lifecycle.md](specs/lifecycle.md) — mental model, session flow, cancellation, EOF, drain, disposal, failure behavior - [specs/platform_support.md](specs/platform_support.md) — public platform support and verification constraints diff --git a/.github/docs/specs/console.md b/.github/docs/specs/console.md index 532806f..3b8f6e8 100644 --- a/.github/docs/specs/console.md +++ b/.github/docs/specs/console.md @@ -30,7 +30,7 @@ PTY output **display** on the host and **timestamped recording** stay embedder r | N2 | Writing PTY output to the host display (embedder writes `stdout` after reading the PTY) | | N3 | Cast / timestamped recording / `MiniPty.Capture` orchestration | | N4 | Terminal emulation (screen buffer, ANSI parsing, scrollback) | -| N5 | In-editor terminal integration (VS Code / xterm.js backend — separate plan) | +| N5 | In-editor terminal integration (VS Code / xterm.js backend — **MiniPty.Terminal**, [terminal.md](terminal.md)) | | N6 | Spawning the child (`Pty.Start` remains **MiniPty** core) | | N7 | Generating or interpreting bracketed-paste sequences | @@ -186,7 +186,7 @@ Typical interactive host flow: `Attach` returns **`PtyConsoleInputHandle`** (`IDisposable`); do not use `await using` unless a future API adds `IAsyncDisposable`. -Use case 2 (one-shot recorded steps) continues to use [Capture](capture.md) only. Use case 4 (editor terminal backend) uses **MiniPty** core without **MiniPty.Console**. +Use case 2 (one-shot recorded steps) continues to use [Capture](capture.md) only. Use case 4 (editor terminal backend) uses **MiniPty.Terminal** ([terminal.md](terminal.md)) without **MiniPty.Console**. ## Failure Behavior diff --git a/.github/docs/specs/core_session.md b/.github/docs/specs/core_session.md index 5e72e18..6c6ffa8 100644 --- a/.github/docs/specs/core_session.md +++ b/.github/docs/specs/core_session.md @@ -46,11 +46,22 @@ MiniPty environment inheritance follows normal process-spawn behavior and is not | `SendEof()` | Signals end of stdin using platform-specific behavior. See [Lifecycle](lifecycle.md). | | `Resize(PtySize)` | Resizes the terminal after spawn. | | `WaitForExitAsync` | Waits for child exit. Cancellation stops waiting only; the child keeps running. | +| `WaitForExitStatusAsync` | Same wait/cancellation semantics as `WaitForExitAsync`; returns `PtyExitStatus` (exit code plus Unix termination signal). | | `CompleteAsync` | Convenience API for one-shot input, wait, drain, and result materialization. See [Completion](completion.md). | | `Kill()` | Terminates the child process without releasing handles. | +| `Kill(PtySignal)` | Sends a signal on Unix (`kill(2)`; the child may handle or ignore catchable signals). On Windows the signal is advisory and the child is terminated (node-pty parity). Undefined enum values throw `ArgumentOutOfRangeException` on both platforms. | | `HasExited` / `ExitCode?` | Polls exit state. `ExitCode` is null until the child has exited. | +| `ExitStatus?` | `PtyExitStatus` after exit; null before. | | `Dispose` / `DisposeAsync` | Kills the child if still running, then releases handles. | +## Exit Status + +`PtyExitStatus` reports `ExitCode` and `Signal`: + +- `ExitCode` always equals `PtySession.ExitCode`. On Unix a signal-terminated child keeps MiniPty's existing `128 + signal` mapping (for example 143 for SIGTERM) — deliberately not node-pty's 0-on-signal, so a killed child never reads as success and there is one exit-code story across the library. +- `Signal` is the raw OS signal number (`waitpid` `WTERMSIG`), null on normal exit and when the wait status was lost (ECHILD reap-lost path). Windows never reports a signal. +- `PtySignal` members are logical identifiers mapped to native numbers per platform (SIGUSR1/SIGUSR2 numbering differs between Linux and macOS/FreeBSD); reporting uses raw OS numbers. + ## Embedder Patterns MiniPty core is the PTY transport for multiple embedder shapes ([spec.md](../spec.md)). The core API is unchanged; patterns differ by who reads output and who writes input. @@ -60,11 +71,11 @@ MiniPty core is the PTY transport for multiple embedder shapes ([spec.md](../spe | **1 — General transport** | Embedder (`ReadOutputAsync` or raw `Output`) | Embedder (`WriteInputAsync`) | **MiniPty** | | **2 — One-shot record** | `PtyCapture.RunAsync` (internal transport pump) | `PtyCompleteOptions` / capture options | **MiniPty.Capture** | | **3 — Interactive host** | Embedder `ReadOutputAsync` (record + write host `stdout`) | **MiniPty.Console** host keyboard + optional embedder writes | **MiniPty** + **MiniPty.Console** | -| **4 — Editor terminal** | Frontend integration (`ReadOutputAsync`) | Frontend key events → `WriteInputAsync` | **MiniPty** | +| **4 — Editor terminal** | **MiniPty.Terminal** `PtyTerminal` (owns `ReadOutputAsync`) | Frontend key events → bridge → `WriteInputAsync` | **MiniPty** + **MiniPty.Terminal** | **Use case 3** does not add a second PTY output reader. [Console](console.md) configures the host terminal and forwards host stdin bytes; the embedder keeps the single `ReadOutputAsync` enumeration for PTY → host display and recording. -**Use case 4** does not use **MiniPty.Console** (no host console attach). Optional node-pty parity features for editors are tracked outside the Console specification. +**Use case 4** does not use **MiniPty.Console** (no host console attach). The push facade, flow control, and WebSocket bridge live in [Terminal](terminal.md). ## Backpressure @@ -80,6 +91,7 @@ Only one active `ReadOutputAsync` reader is allowed per session. A concurrent re ## Lessons Learned +- The textbook glibc `WIFSIGNALED` macro relies on a signed-char cast that a direct C# transcription loses, misclassifying the stopped marker `0x7f` as signaled. Unreachable under WNOHANG-only polling, but load-bearing once the termination signal became public API; the managed decode uses the explicit `(status & 0x7f) != 0 && (status & 0x7f) != 0x7f` form. The exited/signaled wait-status layout is identical on Linux, macOS, and FreeBSD, so decoding stays managed with no native shim involvement. - Environment overlay is safer for MiniPty than node-pty-style replacement because Windows child startup is fragile when inherited variables such as `SystemRoot` are accidentally omitted. - Unix terminal-size variables such as `COLUMNS` and `LINES` can make a fresh PTY behave like the parent terminal. Sanitizing them before overlay avoids stale child-visible terminal state. - Windows does not preserve empty environment variables as child-visible empty values. MiniPty keeps the API distinction so Unix can express empty values, but Windows children observe them like missing variables. diff --git a/.github/docs/specs/lifecycle.md b/.github/docs/specs/lifecycle.md index f5bcf73..00fa916 100644 --- a/.github/docs/specs/lifecycle.md +++ b/.github/docs/specs/lifecycle.md @@ -76,6 +76,7 @@ Persistent embedders usually keep **one** output consumer active for the whole ` | API | On cancellation | |---|---| | `WaitForExitAsync` | Waiting stops with `OperationCanceledException`; the child continues running. | +| `WaitForExitStatusAsync` | Same as `WaitForExitAsync`; the child continues running. | | `ReadOutputAsync` | Output enumeration stops with `OperationCanceledException`; the child continues running. | | `CompleteAsync` | When `KillOnCancellation` is true, the child is killed and `OperationCanceledException` is thrown. | | `PtyCapture.RunAsync` | Same as `CompleteAsync`; it uses completion options. | @@ -146,6 +147,7 @@ Windows may defer stdin EOF until the wait loop has given the child time to atta | `Dispose` / `DisposeAsync` while child running | Kills the child, then releases handles. | | `Dispose` while operations are in flight | All in-flight `ReadOutputAsync`, `WaitForExitAsync`, and `WriteInputAsync` operations fail immediately with `ObjectDisposedException`. | | `Kill()` | Terminates the child but does not release handles. Call `Dispose` afterward. | +| `Kill(PtySignal)` | Unix: delivers the mapped native signal via `kill(2)`; catchable signals may be handled or ignored, so exit is not guaranteed. Windows: validates the enum, then terminates unconditionally (node-pty parity). Drain-after-kill behavior is unchanged. | ## Failure Behavior diff --git a/.github/docs/specs/terminal.md b/.github/docs/specs/terminal.md new file mode 100644 index 0000000..d75b1aa --- /dev/null +++ b/.github/docs/specs/terminal.md @@ -0,0 +1,157 @@ +# Terminal Backend Specification + +Implemented user-facing contract for the **MiniPty.Terminal** NuGet package. + +This package serves **use case 4** in [spec.md](../spec.md): MiniPty as the backend PTY (node-pty–equivalent role) behind a frontend terminal such as xterm.js in a browser or an editor-integrated terminal. + +**Status: implemented** (editor backend plan; see [plan_editor_backend.md](../plans/plan_editor_backend.md)). + +## Motivation + +PTY transport ([Core session](core_session.md)) is pull-based: an embedder enumerates `ReadOutputAsync` and awaits `WaitForExitAsync`. Frontend terminals expect the node-pty shape instead: output pushed to a callback, exit reported after all output, `pause`/`resume` for flow control, and `kill(signal)`. + +Bridging to a browser needs one more layer. The WebSocket protocol has no backpressure hooks, so the xterm.js guidance is an ACK-based watermark protocol implemented by the backend: the server pauses the PTY when unacknowledged bytes exceed a high watermark and resumes on client ACKs. **MiniPty.Terminal** provides both layers: a push facade (`PtyTerminal`) and a WebSocket bridge (`PtyWebSocketBridge`) with that flow control built in. + +## Package + +| Item | Value | +|---|---| +| NuGet | **MiniPty.Terminal** | +| Depends on | **MiniPty** only (BCL otherwise; JSON via source-generated `System.Text.Json`) | +| NativeAOT | Required (same bar as core) | + +## Non-Goals + +| ID | Out of scope | +|---|---| +| N1 | Terminal emulation (screen buffer, ANSI parsing, scrollback) — the frontend renders | +| N2 | Output recording / timestamping ([Capture](capture.md)) | +| N3 | Host console attach ([Console](console.md); use case 3) | +| N4 | Remote shells (`ssh`), authentication, or multi-user session management | +| N5 | Session reconnect / detach-reattach (v1 bridge owns the session end-to-end; requires an `Attach` overload design first) | +| N6 | HTTP serving — the embedder accepts the WebSocket (Kestrel, `HttpListener`, …) and hands it to the bridge | +| N7 | Windows ConPTY `clear()` — requires the conpty.dll signal pipe node-pty ships; not reachable through the public Win32 API | + +## `PtyTerminal` — push facade + +```csharp +PtyTerminal.Start(PtyStartInfo startInfo, PtyTerminalOptions options) // options.Output is required +``` + +| Member | Contract | +|---|---| +| `Output` handler (options) | `ValueTask handler(ReadOnlyMemory data, CancellationToken ct)`. Data is valid **only until the returned task completes**; copy to retain. Invoked sequentially. | +| `Completion` | `Task` that completes **after every output handler invocation has finished** (drain-then-exit, node-pty onData/onExit ordering). Faults with the handler exception when a handler throws (child is killed first). | +| `Pause()` / `Resume()` | Stops/resumes output delivery. Paused delivery parks the pump; the strict-handoff producer stops reading, the OS PTY buffer fills, and the child blocks on write. No managed buffering beyond one in-flight chunk; no drops. | +| `WriteInputAsync` / `SendEof` / `Resize` / `Kill` / `Kill(PtySignal)` | Pass-through to the owned session; callable concurrently with output delivery. | +| `ProcessId` / `Size` / `HasExited` | Session state. | +| `DisposeAsync` | Stops the pump, kills the child if running, releases the PTY. | + +Design decisions (WHY): + +- **The facade owns its session.** `Start` is the only entry point and the underlying `PtySession` is not exposed. Core allows exactly one output consumer; wrapping an existing session would invite the second-reader and unread-early-output misuse modes. An `Attach(PtySession)` overload can be added later without breaking changes. +- **The output handler is a required start option, not an event.** .NET events cannot carry async completion, so zero-copy delivery with backpressure would be impossible, and attach-after-start races would drop early output. A handler fixed at `Start` eliminates both. +- **Exit is an awaitable `Completion` task, not an event.** It cannot be subscribed too late and composes with `await`. +- **Slow consumers throttle the child for free.** The pump does not advance the core enumerator until the handler's task completes, which satisfies the core chunk-lifetime contract verbatim and turns a slow WebSocket send into PTY backpressure with zero copies. + +## `PtyWebSocketBridge` — frontend bridge + +```csharp +Task PtyWebSocketBridge.RunAsync( + PtyStartInfo startInfo, WebSocket webSocket, PtyBridgeOptions? options = null, CancellationToken ct = default) +``` + +Takes the abstract BCL `System.Net.WebSockets.WebSocket`, so it works with Kestrel accepts, `HttpListener`, `ClientWebSocket`, and `WebSocket.CreateFromStream` (tests) while the library stays dependency-free. + +### Protocol + +Binary frames carry data; text frames carry JSON control messages. Raw PTY bytes never pass through JSON or base64. + +| Direction | Frame | Payload | Meaning | +|---|---|---|---| +| server → client | Binary | raw PTY output | feed to `term.write(new Uint8Array(data), ackCallback)` | +| client → server | Binary | raw input bytes | written to the PTY verbatim (keystrokes, paste) | +| client → server | Text | `{"type":"resize","cols":120,"rows":30}` | resize the PTY | +| client → server | Text | `{"type":"ack","bytes":131072}` | flow-control credit: client finished processing N bytes | +| server → client | Text | `{"type":"exit","exitCode":0,"signal":15}` | child exited; always after the final output frame; `signal` omitted when null | + +Unknown `type` values are ignored (forward compatibility). Malformed JSON or a control message larger than `MaxControlMessageSize` closes the socket with `PolicyViolation`, kills the child, and faults `RunAsync` with `InvalidDataException` — a broken client must not hold a shell open. + +### Flow control + +Server-side watermark over unacknowledged bytes; ACK credit was chosen over pause/resume messages because byte counting is self-clocking — a lost resume message cannot deadlock the stream. + +| Option | Default | Role | +|---|---|---| +| `HighWatermark` | 384 KiB | pause output delivery at/above this unACKed count (keep < 500 KB per xterm.js buffer guidance) | +| `LowWatermark` | 128 KiB (2^17) | resume at/below; matches the recommended client ACK chunk | +| `ReceiveBufferSize` | 16 KiB | client input/control receive buffer | +| `MaxControlMessageSize` | 4 KiB | control JSON bound | +| `SendExitMessage` | `true` | emit the `exit` control message | +| `CloseTimeout` | 5 s | bound on close handshakes against dead clients | + +The client counts binary payload bytes it has fed through `term.write` callbacks and ACKs them; both sides measure the same metric, which the xterm.js guide calls out as mandatory for the stream not to stall. + +### Close and failure behavior + +| Condition | Behavior | +|---|---| +| Child exits | drain → final output frame → `exit` text message → `NormalClosure` (bounded by `CloseTimeout`) → dispose → return status | +| Client closes the socket | kill child → drain (output discarded) → close response → return killed status | +| Protocol violation | `PolicyViolation` close → kill → `InvalidDataException` | +| Socket send failure | kill via disposal → the socket exception propagates from `RunAsync` | +| Cancellation | kill → dispose → `OperationCanceledException` | +| Invalid options / null args | `ArgumentOutOfRangeException` / `ArgumentNullException` before spawn | + +v1 has exactly one close behavior (kill on disconnect). A keep-alive/reconnect option was rejected: with bridge-owned session creation there is no handle to reattach, so the option would leak sessions (see N5). + +## VS Code integration pattern + +VS Code cannot swap its internal node-pty; integration is a **`vscode.Pseudoterminal` extension** bridging to a MiniPty-based helper process (the extension host cannot P/Invoke). The helper speaks this bridge protocol (WebSocket or an equivalent framed stdio transport); the extension maps: + +| Pseudoterminal side | Bridge side | +|---|---| +| `handleInput(data: string)` | UTF-8 encode → binary frame | +| binary frame | decode with an **incremental `TextDecoder`** → `onDidWrite.fire(string)` — the API is UTF-16 strings and a chunk can split a multi-byte UTF-8 sequence, so per-chunk decoding corrupts output | +| `setDimensions({columns, rows})` | `resize` control message (dimensions can be `undefined` until the panel is visible — skip until first real value) | +| `exit` control message | flush pending `onDidWrite`, then `onDidClose.fire(exitCode)` — VS Code buffers pty output on a short timer, so closing immediately after the last write can drop tail output | + +`onDidWrite` has no flow-control hook, so the ACK side of this protocol should live in the extension's transport client (count bytes handed to `onDidWrite` and ACK) to keep server-side watermarks meaningful. + +## Embedder Pattern (Use Case 4) + +Browser (xterm.js) host flow — see [samples/WebTerminal.cs](../../../samples/WebTerminal.cs) for the complete version: + +1. Accept a WebSocket (`HttpListenerContext.AcceptWebSocketAsync`, Kestrel `UseWebSockets`, …) +2. `var status = await PtyWebSocketBridge.RunAsync(shellStartInfo, webSocket, ct);` +3. Client side: `term.onData(d => ws.send(encode(d)))`, binary `onmessage` → `term.write(bytes, ackCallback)`, ACK every 2^17 processed bytes, resize on `term.onResize`. + +Embedders that need a custom transport (stdio framing, SignalR, …) use `PtyTerminal` directly and reimplement only the framing; pause/resume and drain-then-exit ordering come from the facade. + +## Failure Behavior (`PtyTerminal`) + +| Condition | Behavior | +|---|---| +| Handler throws | pump stops, child killed, `Completion` faults with the handler exception | +| `Kill()` during delivery | remaining output drained and delivered, then `Completion` resolves with the status | +| `Kill()` / child exit while `Pause()`d | `Completion` stays pending until `Resume()` or disposal — flow control holds the drain by design | +| Dispose before exit | `Completion` faults with `OperationCanceledException`; child killed | +| Member call after dispose | `ObjectDisposedException` | + +## Lessons Learned + +- Disposing a `PtySession` immediately after canceling an active `ReadOutputAsync` races the session's internal buffer teardown (`ManualResetValueTaskSourceCore` double-signal). The facade cancels, awaits its pump to unwind, and only then disposes the session. +- Bridge teardown must disable flow control and release any pause **inside the same lock** that sets pauses (`BridgeFlowControl.Disable`), or an in-flight send can re-pause after the teardown resume and park the drain forever. +- A WebSocket allows one outstanding send; the bridge serializes the output pump and the exit message with a semaphore. Test clients need the same discipline (ACK sends vs test-driven input sends). +- ConPTY line submission needs CR — sending `\n` echoes but never completes a `cmd.exe` `set /p` read. Cross-platform clients should send `\r` (xterm.js `onData` already does) and tests must not assert on LF-terminated input. +- `cmd.exe /c "set /p LINE= & echo %LINE%"` prints the literal `%LINE%` because expansion happens at parse time; interactive-input children in tests need `/v:on` with `!LINE!`. +- TerminateProcess-based `Kill` is fire-and-forget: `HasExited` can lag `Completion` by a scheduler tick; tests must poll, not assert immediately. +- `HttpListener` on `http://localhost:/` works for the sample cross-platform without URL ACLs; ephemeral binding retries random ports because `HttpListener` cannot bind port 0. + +## Related Documents + +- [spec.md](../spec.md) — four use cases and package map +- [core_session.md](core_session.md) — `ReadOutputAsync` strict handoff, chunk lifetime, backpressure +- [lifecycle.md](lifecycle.md) — exit, kill, disposal semantics the facade builds on +- [plans/plan_editor_backend.md](../plans/plan_editor_backend.md) — implementation plan and decision record +- [samples/WebTerminal.cs](../../../samples/WebTerminal.cs) — browser demo with the client-side protocol diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 9824fec..e004e6e 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -63,7 +63,7 @@ jobs: - name: Publish and run samples (NativeAOT) shell: bash run: | - for sample in Capture Session Observe Interactive ConsoleAttach; do + for sample in Capture Session Observe Interactive ConsoleAttach WebTerminal; do out="$RUNNER_TEMP/minipty-${sample}-aot" dotnet publish "samples/${sample}.cs" -c Release -r "${{ matrix.rid }}" --self-contained true -p:PublishAot=true -p:StripSymbols=true -p:DebugType=None -o "$out" exe="$out/${sample}" diff --git a/MiniPty.slnx b/MiniPty.slnx index a067439..d4886d6 100644 --- a/MiniPty.slnx +++ b/MiniPty.slnx @@ -4,5 +4,6 @@ + diff --git a/README.md b/README.md index be711a8..770895b 100644 --- a/README.md +++ b/README.md @@ -31,8 +31,10 @@ Ubuntu 24.04, .NET 10 - Persistent bytes-only output streaming (`ReadOutputAsync`) - One-shot run with optional stdin and drained output (`CompleteAsync`) - Resize the terminal after spawn (`PtySession.Resize`) +- Exit status with Unix termination signal (`WaitForExitStatusAsync`, `ExitStatus`) and signal kill (`Kill(PtySignal)`) - Per-read timestamps for observation or recording (**MiniPty.Capture**, `PtyCapture.RunAsync`) - Host terminal input attach for interactive programs (**MiniPty.Console**, `PtyConsoleInput.Attach`) +- Backend PTY for xterm.js / editor terminals: push facade, pause/resume flow control, WebSocket bridge (**MiniPty.Terminal**, `PtyTerminal`, `PtyWebSocketBridge`) - Plain or colored host output from PTY bytes (`PtyOutput.ToDisplayText`) **Not supported** @@ -65,6 +67,9 @@ dotnet add package MiniPty.Capture # Host terminal input attach for interactive sessions (vim, etc.) dotnet add package MiniPty.Console + +# Backend PTY for xterm.js / editor terminals (push events, flow control, WebSocket bridge) +dotnet add package MiniPty.Terminal ``` **MiniPty** start a session with `Pty.Start`, then choose one pattern: @@ -265,6 +270,22 @@ sealed class OutputStats On Unix, write status messages to **stderr before** `Attach` and emit `\r\n` on **stdout after** dispose if the parent shell prompt drifts (see [ConsoleAttach.cs](samples/ConsoleAttach.cs)). +**MiniPty.Terminal** turns MiniPty into a backend PTY for frontend terminals (xterm.js, editor integrations) — the role node-pty plays behind VS Code. `PtyWebSocketBridge.RunAsync` runs a full session over one WebSocket: binary frames carry raw PTY data, text frames carry JSON control messages (`resize` / `ack` from the client, `exit` from the server), with ACK-based watermark flow control per the xterm.js guidance. Try `dotnet samples/WebTerminal.cs` and open the printed URL for a real shell in the browser. + +```csharp +using MiniPty; +using MiniPty.Terminal; + +// webSocket: any System.Net.WebSockets.WebSocket (Kestrel accept, HttpListener, ...) +var status = await PtyWebSocketBridge.RunAsync( + new PtyStartInfo { FileName = "/bin/bash", Arguments = ["-i"], Size = new PtySize(120, 30) }, + webSocket, + cancellationToken: ct); +Console.WriteLine($"shell exited: {status.ExitCode} (signal {status.Signal?.ToString() ?? "none"})"); +``` + +For custom transports (stdio framing to a VS Code extension, SignalR, …), use the `PtyTerminal` facade directly: a node-pty-shaped push model with an output handler, awaitable `Completion` (exit after all output), and `Pause()` / `Resume()` flow control. See [terminal.md](.github/docs/specs/terminal.md) for the protocol and the VS Code `Pseudoterminal` bridging pattern. + ## Samples | Sample | Shows | @@ -274,6 +295,7 @@ On Unix, write status messages to **stderr before** `Attach` and emit `\r\n` on | [Interactive.cs](samples/Interactive.cs) | `ReadOutputAsync` persistent loop, marker-driven writes, mid-session `Resize`, natural child exit | | [ConsoleAttach.cs](samples/ConsoleAttach.cs) | **MiniPty.Console** host attach: keyboard → PTY, `ReadOutputAsync` → host display (requires interactive TTY) | | [Observe.cs](samples/Observe.cs) | `PtyCapture.RunAsync`, per-read chunk timelines, stdin via `PtyCaptureOptions.Completion` | +| [WebTerminal.cs](samples/WebTerminal.cs) | **MiniPty.Terminal** xterm.js in the browser: WebSocket bridge, ACK flow control, resize, exit banner | Run a sample locally (JIT): @@ -283,6 +305,7 @@ dotnet samples/Interactive.cs dotnet samples/ConsoleAttach.cs dotnet samples/Observe.cs dotnet samples/Capture.cs +dotnet samples/WebTerminal.cs # then open http://localhost:5170/ ``` NativeAOT publish (same flags as CI): diff --git a/samples/WebTerminal.cs b/samples/WebTerminal.cs new file mode 100644 index 0000000..8582a5a --- /dev/null +++ b/samples/WebTerminal.cs @@ -0,0 +1,316 @@ +#:sdk Microsoft.NET.Sdk +#:property TargetFramework=net10.0 +#:property Nullable=enable +#:property ImplicitUsings=enable +#:property PublishAot=true +#:project ../src/MiniPty/MiniPty.csproj +#:project ../src/MiniPty.Terminal/MiniPty.Terminal.csproj + +// WebTerminal: xterm.js in a browser, MiniPty.Terminal as the backend PTY. +// +// Interactive mode: dotnet samples/WebTerminal.cs +// Serves http://localhost:5170/ with an xterm.js page; each WebSocket connection +// at /ws spawns the platform shell through PtyWebSocketBridge. +// +// Smoke mode (CI): dotnet samples/WebTerminal.cs --smoke (or redirected stdin) +// Starts the server on an ephemeral port, connects a ClientWebSocket to itself, +// drives resize + a marker command + exit, and asserts the marker output and the +// exit control message round-trip. Exits 0 on success. + +using System.Net; +using System.Net.Sockets; +using System.Net.WebSockets; +using System.Runtime.InteropServices; +using System.Text; +using System.Text.Json; +using MiniPty; +using MiniPty.Terminal; + +if (!Pty.IsSupported) +{ + Console.Error.WriteLine("PTY is not supported on this operating system."); + return 1; +} + +// --serve forces interactive mode (e.g. when launched from a script with redirected stdin). +var smoke = !args.Contains("--serve") && (args.Contains("--smoke") || Console.IsInputRedirected); + +try +{ + return smoke ? await RunSmokeAsync() : await RunInteractiveAsync(ParsePort(args) ?? 5170); +} +catch (Exception ex) +{ + Console.Error.WriteLine($"{ex.GetType().Name}: {ex.Message}"); + return 1; +} + +static async Task RunInteractiveAsync(int port) +{ + using var listener = StartListener(port); + Console.Error.WriteLine($"WebTerminal listening on http://localhost:{port}/ (Ctrl+C to stop)"); + await ServeAsync(listener, CancellationToken.None); + return 0; +} + +static async Task RunSmokeAsync() +{ + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(60)); + using var listener = StartEphemeralListener(out var port); + var serveTask = ServeAsync(listener, timeout.Token); + + using var client = new ClientWebSocket(); + await client.ConnectAsync(new Uri($"ws://localhost:{port}/ws"), timeout.Token); + + var enter = OperatingSystem.IsWindows() ? "\r\n" : "\n"; + await SendTextAsync(client, "{\"type\":\"resize\",\"cols\":100,\"rows\":30}", timeout.Token); + await SendBinaryAsync(client, "echo WEBTERM_SMOKE_OK" + enter, timeout.Token); + await SendBinaryAsync(client, "exit" + enter, timeout.Token); + + var output = new StringBuilder(); + string? exitMessage = null; + var buffer = new byte[64 * 1024]; + var message = new MemoryStream(); + while (true) + { + var result = await client.ReceiveAsync(buffer.AsMemory(), timeout.Token); + if (result.MessageType == WebSocketMessageType.Close) + { + if (client.State == WebSocketState.CloseReceived) + await client.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, null, timeout.Token); + break; + } + + message.Write(buffer, 0, result.Count); + if (!result.EndOfMessage) + continue; + + var payload = message.ToArray(); + message.SetLength(0); + if (result.MessageType == WebSocketMessageType.Binary) + output.Append(Encoding.UTF8.GetString(payload)); + else + exitMessage = Encoding.UTF8.GetString(payload); + } + + listener.Stop(); + try + { + await serveTask; + } + catch (OperationCanceledException) + { + } + + if (!output.ToString().Contains("WEBTERM_SMOKE_OK")) + { + Console.Error.WriteLine("Smoke failed: marker output not observed."); + Console.Error.WriteLine(output.ToString()); + return 1; + } + + if (exitMessage is null) + { + Console.Error.WriteLine("Smoke failed: exit control message not received."); + return 1; + } + + using var exitJson = JsonDocument.Parse(exitMessage); + if (exitJson.RootElement.GetProperty("type").GetString() != "exit") + { + Console.Error.WriteLine($"Smoke failed: unexpected control message: {exitMessage}"); + return 1; + } + + Console.WriteLine($"WebTerminal smoke OK (exit message: {exitMessage})"); + return 0; +} + +static int? ParsePort(string[] args) +{ + for (var i = 0; i < args.Length - 1; i++) + { + if (args[i] == "--port" && int.TryParse(args[i + 1], out var port)) + return port; + } + + return null; +} + +static HttpListener StartListener(int port) +{ + var listener = new HttpListener(); + listener.Prefixes.Add($"http://localhost:{port}/"); + listener.Start(); + return listener; +} + +static HttpListener StartEphemeralListener(out int port) +{ + for (var attempt = 0; attempt < 20; attempt++) + { + port = Random.Shared.Next(20000, 60000); + var listener = new HttpListener(); + listener.Prefixes.Add($"http://localhost:{port}/"); + try + { + listener.Start(); + return listener; + } + catch (HttpListenerException) + { + } + catch (SocketException) + { + } + } + + throw new InvalidOperationException("Could not bind an ephemeral port for the smoke server."); +} + +static async Task ServeAsync(HttpListener listener, CancellationToken cancellationToken) +{ + while (!cancellationToken.IsCancellationRequested) + { + HttpListenerContext context; + try + { + context = await listener.GetContextAsync().WaitAsync(cancellationToken); + } + catch (Exception e) when (e is OperationCanceledException or HttpListenerException or ObjectDisposedException) + { + return; + } + + _ = HandleRequestAsync(context, cancellationToken); + } +} + +static async Task HandleRequestAsync(HttpListenerContext context, CancellationToken cancellationToken) +{ + try + { + var path = context.Request.Url?.AbsolutePath ?? "/"; + if (path == "/ws" && context.Request.IsWebSocketRequest) + { + var wsContext = await context.AcceptWebSocketAsync(subProtocol: null); + Console.Error.WriteLine("terminal session started"); + var status = await PtyWebSocketBridge.RunAsync( + CreateShellStartInfo(), + wsContext.WebSocket, + cancellationToken: cancellationToken); + Console.Error.WriteLine($"terminal session ended: exit {status.ExitCode}" + + (status.Signal is { } signal ? $" (signal {signal})" : "")); + return; + } + + if (path == "/") + { + var body = Encoding.UTF8.GetBytes(IndexHtml); + context.Response.ContentType = "text/html; charset=utf-8"; + context.Response.ContentLength64 = body.Length; + await context.Response.OutputStream.WriteAsync(body, cancellationToken); + context.Response.Close(); + return; + } + + context.Response.StatusCode = 404; + context.Response.Close(); + } + catch (Exception ex) + { + Console.Error.WriteLine($"request failed: {ex.GetType().Name}: {ex.Message}"); + try + { + context.Response.Abort(); + } + catch + { + } + } +} + +static async Task SendBinaryAsync(ClientWebSocket socket, string text, CancellationToken cancellationToken) => + await socket.SendAsync(Encoding.UTF8.GetBytes(text), WebSocketMessageType.Binary, endOfMessage: true, cancellationToken); + +static async Task SendTextAsync(ClientWebSocket socket, string json, CancellationToken cancellationToken) => + await socket.SendAsync(Encoding.UTF8.GetBytes(json), WebSocketMessageType.Text, endOfMessage: true, cancellationToken); + +static PtyStartInfo CreateShellStartInfo() +{ + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + var cmd = Environment.GetEnvironmentVariable("ComSpec") ?? @"C:\Windows\System32\cmd.exe"; + return new PtyStartInfo { FileName = cmd, Size = new PtySize(80, 24) }; + } + + var shell = File.Exists("/bin/bash") ? "/bin/bash" : "/bin/sh"; + return new PtyStartInfo { FileName = shell, Arguments = ["-i"], Size = new PtySize(80, 24) }; +} + +// The page is embedded so the NativeAOT publish stays a single asset-free executable. +// Client protocol: binary frames = terminal data; text frames = JSON control messages +// (resize / ack from the client, exit from the server). The ack chunk size matches the +// server's default LowWatermark for self-clocking flow control. +partial class Program +{ + const string IndexHtml = """ + + + + +MiniPty WebTerminal + + + + +
+ + + + + +"""; +} diff --git a/scripts/pack-with-native.sh b/scripts/pack-with-native.sh index b57a0f2..045c802 100644 --- a/scripts/pack-with-native.sh +++ b/scripts/pack-with-native.sh @@ -38,6 +38,9 @@ dotnet pack "$root/src/MiniPty.Capture/MiniPty.Capture.csproj" -c Release -o "$o dotnet pack "$root/src/MiniPty.Console/MiniPty.Console.csproj" -c Release -o "$out_dir" \ -p:Version="$version" +dotnet pack "$root/src/MiniPty.Terminal/MiniPty.Terminal.csproj" -c Release -o "$out_dir" \ + -p:Version="$version" + echo "Packed to $out_dir:" ls -la "$out_dir"/*.nupkg diff --git a/src/MiniPty.Terminal/Internal/BridgeFlowControl.cs b/src/MiniPty.Terminal/Internal/BridgeFlowControl.cs new file mode 100644 index 0000000..8bfc881 --- /dev/null +++ b/src/MiniPty.Terminal/Internal/BridgeFlowControl.cs @@ -0,0 +1,81 @@ +namespace MiniPty.Terminal.Internal; + +/// +/// Watermark flow control for the WebSocket bridge: counts bytes sent to the client but not yet +/// acknowledged, pausing the terminal above the high watermark and resuming at or below the low +/// watermark. Transitions are guarded by a lock so a racing ACK cannot lose a resume; the +/// worst-case race cost is one extra in-flight chunk, which stays well under the xterm.js +/// client-buffer guidance. +/// +internal sealed class BridgeFlowControl +{ + private readonly long _highWatermark; + private readonly long _lowWatermark; + private readonly Lock _lock = new(); + private long _unacknowledged; + private bool _paused; + private bool _disabled; + private PtyTerminal? _terminal; + + public BridgeFlowControl(long highWatermark, long lowWatermark) + { + _highWatermark = highWatermark; + _lowWatermark = lowWatermark; + } + + /// Set once right after the terminal starts; the output handler needs flow control first. + public void Attach(PtyTerminal terminal) => _terminal = terminal; + + public void OnSent(int bytes) + { + lock (_lock) + { + if (_disabled) + return; + + _unacknowledged += bytes; + if (!_paused && _unacknowledged >= _highWatermark) + { + _paused = true; + _terminal?.Pause(); + } + } + } + + /// + /// Permanently stops flow control and releases any pause, used during bridge teardown so the + /// post-kill drain cannot park on a pause the client will never acknowledge. Running inside + /// the same lock as makes disable-then-pause races impossible. + /// + public void Disable() + { + lock (_lock) + { + _disabled = true; + if (_paused) + { + _paused = false; + _terminal?.Resume(); + } + } + } + + public void OnAcknowledged(long bytes) + { + if (bytes <= 0) + return; + + lock (_lock) + { + if (_disabled) + return; + + _unacknowledged = Math.Max(0, _unacknowledged - bytes); + if (_paused && _unacknowledged <= _lowWatermark) + { + _paused = false; + _terminal?.Resume(); + } + } + } +} diff --git a/src/MiniPty.Terminal/Internal/BridgeJson.cs b/src/MiniPty.Terminal/Internal/BridgeJson.cs new file mode 100644 index 0000000..181535b --- /dev/null +++ b/src/MiniPty.Terminal/Internal/BridgeJson.cs @@ -0,0 +1,61 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace MiniPty.Terminal.Internal; + +/// +/// Flat control-message DTO for the WebSocket bridge protocol. One shape covers every message +/// type; unused members stay null and are omitted on write. Unknown values are +/// ignored by the receiver for forward compatibility. +/// +internal sealed class BridgeMessage +{ + public string? Type { get; set; } + public int? Cols { get; set; } + public int? Rows { get; set; } + public long? Bytes { get; set; } + public int? ExitCode { get; set; } + public int? Signal { get; set; } +} + +/// +/// Source-generated JSON context so control-message parsing stays reflection-free under +/// NativeAOT and trimming. +/// +[JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] +[JsonSerializable(typeof(BridgeMessage))] +internal sealed partial class BridgeJsonContext : JsonSerializerContext +{ +} + +internal static class BridgeJson +{ + internal const string TypeResize = "resize"; + internal const string TypeAck = "ack"; + internal const string TypeExit = "exit"; + + /// + /// Parses a control message. Returns on malformed JSON; the caller + /// treats that as a protocol violation. + /// + public static bool TryParse(ReadOnlySpan utf8Json, out BridgeMessage? message) + { + try + { + message = JsonSerializer.Deserialize(utf8Json, BridgeJsonContext.Default.BridgeMessage); + return message is not null; + } + catch (JsonException) + { + message = null; + return false; + } + } + + public static byte[] SerializeExit(PtyExitStatus status) => + JsonSerializer.SerializeToUtf8Bytes( + new BridgeMessage { Type = TypeExit, ExitCode = status.ExitCode, Signal = status.Signal }, + BridgeJsonContext.Default.BridgeMessage); +} diff --git a/src/MiniPty.Terminal/MiniPty.Terminal.csproj b/src/MiniPty.Terminal/MiniPty.Terminal.csproj new file mode 100644 index 0000000..8823d11 --- /dev/null +++ b/src/MiniPty.Terminal/MiniPty.Terminal.csproj @@ -0,0 +1,24 @@ + + + + net10.0 + false + true + true + Frontend terminal backend (xterm.js and editor integrations) for MiniPty. + + + + + <_Parameter1>MiniPty.Tests + + + <_Parameter1>MiniPty.Benchmarks + + + + + + + + diff --git a/src/MiniPty.Terminal/PtyBridgeOptions.cs b/src/MiniPty.Terminal/PtyBridgeOptions.cs new file mode 100644 index 0000000..14d7cad --- /dev/null +++ b/src/MiniPty.Terminal/PtyBridgeOptions.cs @@ -0,0 +1,58 @@ +namespace MiniPty.Terminal; + +/// +/// Options for . +/// +/// +/// Flow control follows the xterm.js watermark/ACK guidance: the server counts bytes sent but not +/// yet acknowledged by the client; above output delivery pauses (which +/// ultimately blocks the child via PTY backpressure), and an ack control message that brings +/// the count to or below resumes delivery. Byte-counted credit is +/// self-clocking: a lost resume message cannot deadlock the stream. +/// +public sealed record PtyBridgeOptions +{ + /// + /// Gets the unacknowledged-byte count above which output delivery pauses. + /// Default is 384 KiB; keep well under the ~500 KB xterm.js write-buffer guidance. + /// + public long HighWatermark { get; init; } = 3 * 131_072; + + /// + /// Gets the unacknowledged-byte count at or below which output delivery resumes. + /// Default is 128 KiB (2^17), matching the recommended client ACK chunk size. + /// + public long LowWatermark { get; init; } = 131_072; + + /// + /// Gets the receive buffer size in bytes for client input and control frames. Default is 16 KiB. + /// + public int ReceiveBufferSize { get; init; } = 16 * 1024; + + /// + /// Gets the maximum accepted control (text) message size in bytes. Larger messages close the + /// socket with PolicyViolation. Default is 4 KiB. + /// + public int MaxControlMessageSize { get; init; } = 4096; + + /// + /// Gets a value indicating whether an exit control message is sent after the final + /// output frame when the child exits. Default is . + /// + public bool SendExitMessage { get; init; } = true; + + /// + /// Gets the maximum time to wait for the close handshake against a slow or dead client. + /// Default is 5 seconds. + /// + public TimeSpan CloseTimeout { get; init; } = TimeSpan.FromSeconds(5); + + internal void Validate() + { + ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(LowWatermark, 0); + ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(HighWatermark, LowWatermark); + ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(ReceiveBufferSize, 0); + ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(MaxControlMessageSize, 0); + ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(CloseTimeout, TimeSpan.Zero); + } +} diff --git a/src/MiniPty.Terminal/PtyTerminal.cs b/src/MiniPty.Terminal/PtyTerminal.cs new file mode 100644 index 0000000..894aaf7 --- /dev/null +++ b/src/MiniPty.Terminal/PtyTerminal.cs @@ -0,0 +1,239 @@ +using System.Text; + +namespace MiniPty.Terminal; + +/// +/// Push-model terminal backend over a for frontend integrations +/// (xterm.js, editor terminals). Equivalent role to node-pty: output is pushed to a handler, +/// exit is observed after all output has been delivered, and / +/// provide the flow-control primitive frontends need. +/// +/// +/// +/// The terminal owns its session end-to-end: it is created by , is the sole +/// output consumer, and disposing the terminal kills the child if still running. The underlying +/// session is not exposed so a second output reader cannot be attached by mistake. +/// +/// +/// Output ordering contract: every output handler invocation completes before +/// transitions to completed, so tail output is never lost +/// (drain-then-exit, matching node-pty's onData/onExit ordering). +/// +/// +public sealed class PtyTerminal : IAsyncDisposable +{ + private readonly PtySession _session; + private readonly PtyTerminalOutputHandler _output; + private readonly CancellationTokenSource _pumpCts = new(); + private readonly Task _pumpTask; + private TaskCompletionSource? _pauseGate; + private int _disposed; + + private PtyTerminal(PtySession session, PtyTerminalOutputHandler output) + { + _session = session; + _output = output; + _pumpTask = Task.Run(PumpAsync); + } + + /// + /// Spawns a child process attached to a new pseudo-terminal and starts pushing its output to + /// . + /// + /// Child process launch configuration. + /// Terminal behavior; the output handler is required. + /// A running terminal. Dispose it to kill the child and release the PTY. + public static PtyTerminal Start(PtyStartInfo startInfo, PtyTerminalOptions options) + { + ArgumentNullException.ThrowIfNull(startInfo); + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(options.Output); + + return new PtyTerminal(Pty.Start(startInfo), options.Output); + } + + /// Gets the OS process id of the child. + public int ProcessId => _session.ProcessId; + + /// Gets the current terminal dimensions. + public PtySize Size => _session.Size; + + /// Gets a value indicating whether the child process has exited. + public bool HasExited => _session.HasExited; + + /// + /// Gets a task that completes with the child's after the child has + /// exited and every output handler invocation has completed. Faults with the handler + /// exception when a handler throws (the child is killed first), and with + /// when the terminal is disposed before exit. + /// + public Task Completion => _pumpTask; + + /// + /// Writes raw bytes to the PTY stdin. Safe to call concurrently with output delivery. + /// + /// Bytes to write. Empty buffers are ignored. + /// Token used to cancel the write operation. + public ValueTask WriteInputAsync(ReadOnlyMemory bytes, CancellationToken cancellationToken = default) + { + ThrowIfDisposed(); + return _session.WriteInputAsync(bytes, cancellationToken); + } + + /// + /// Writes text to the PTY stdin using the specified character encoding (UTF-8 by default). + /// + /// Text to encode and write. Empty strings are ignored. + /// Encoding used to convert to bytes. + /// Token used to cancel the write operation. + public ValueTask WriteInputAsync(string text, Encoding? encoding = null, CancellationToken cancellationToken = default) + { + ThrowIfDisposed(); + return _session.WriteInputAsync(text, encoding, cancellationToken); + } + + /// Signals end of stdin to the child. See . + public void SendEof() + { + ThrowIfDisposed(); + _session.SendEof(); + } + + /// Resizes the pseudo-terminal to the given dimensions. + /// New width and height in character cells. + public void Resize(PtySize size) + { + ThrowIfDisposed(); + _session.Resize(size); + } + + /// + /// Pauses output delivery for flow control. No output handler runs while paused. + /// + /// + /// Pausing parks the pump: the session's strict-handoff producer stops reading the PTY + /// transport, the OS PTY buffer fills, and the child eventually blocks on write. No data is + /// dropped or buffered in managed memory beyond the single in-flight chunk. Equivalent to + /// node-pty's pause(). + /// + public void Pause() + { + ThrowIfDisposed(); + if (Volatile.Read(ref _pauseGate) is null) + { + Interlocked.CompareExchange( + ref _pauseGate, + new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously), + null); + } + } + + /// Resumes output delivery after . Equivalent to node-pty's resume(). + public void Resume() + { + ThrowIfDisposed(); + Interlocked.Exchange(ref _pauseGate, null)?.TrySetResult(); + } + + /// + /// Terminates the child process. Remaining output is drained and delivered before + /// completes. + /// + public void Kill() + { + ThrowIfDisposed(); + _session.Kill(); + } + + /// + /// Sends a signal to the child process. See for + /// platform semantics (Windows terminates regardless of signal). + /// + /// Signal to deliver. + public void Kill(PtySignal signal) + { + ThrowIfDisposed(); + _session.Kill(signal); + } + + /// + /// Stops output delivery and disposes the session, killing the child if still running. + /// + public async ValueTask DisposeAsync() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) + return; + + // Cancel first so the enumerator, a parked pause gate, and in-flight handlers observe + // cancellation, then wait for the pump to stop before disposing the session: disposing + // while a canceled read is still unwinding races the session's internal buffer teardown. + _pumpCts.Cancel(); + Interlocked.Exchange(ref _pauseGate, null)?.TrySetResult(); + + try + { + await _pumpTask.ConfigureAwait(false); + } + catch + { + // Completion observers see the original fault; disposal must not rethrow it. + } + + await _session.DisposeAsync().ConfigureAwait(false); + _pumpCts.Dispose(); + } + + private async Task PumpAsync() + { + var cancellationToken = _pumpCts.Token; + try + { + await foreach (var chunk in _session.ReadOutputAsync(cancellationToken).ConfigureAwait(false)) + { + // Gate before delivery: while paused the in-flight chunk stays valid (the pump + // does not advance the enumerator) and the producer stops reading the transport. + await WaitWhilePausedAsync(cancellationToken).ConfigureAwait(false); + await _output(chunk.Data, cancellationToken).ConfigureAwait(false); + } + + // Enumeration completes only after child exit and post-exit drain, so every handler + // invocation has finished before the exit status is observed and published. + return await _session.WaitForExitStatusAsync(CancellationToken.None).ConfigureAwait(false); + } + catch + { + // Handler exception, transport failure, or disposal: deterministic teardown. Killing + // an already-exited or disposed session is a no-op / benign here. + if (Volatile.Read(ref _disposed) == 0) + { + try + { + _session.Kill(); + } + catch (ObjectDisposedException) + { + } + } + + throw; + } + } + + private async ValueTask WaitWhilePausedAsync(CancellationToken cancellationToken) + { + while (true) + { + var gate = Volatile.Read(ref _pauseGate); + if (gate is null) + return; + + await gate.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + } + } + + private void ThrowIfDisposed() + { + if (Volatile.Read(ref _disposed) != 0) + throw new ObjectDisposedException(nameof(PtyTerminal)); + } +} diff --git a/src/MiniPty.Terminal/PtyTerminalOptions.cs b/src/MiniPty.Terminal/PtyTerminalOptions.cs new file mode 100644 index 0000000..a5548d5 --- /dev/null +++ b/src/MiniPty.Terminal/PtyTerminalOptions.cs @@ -0,0 +1,33 @@ +namespace MiniPty.Terminal; + +/// +/// Receives one PTY output chunk pushed by . +/// +/// +/// +/// is valid only until the returned completes; +/// handlers that retain bytes must copy them. The terminal does not read further output until the +/// returned task completes, so a slow handler applies backpressure all the way to the child +/// process (strict handoff, then OS PTY pipe fill) without copying or dropping data. +/// +/// +/// Handlers must honor ; it is canceled when the terminal is +/// disposed. A handler exception stops the pump, kills the child, and faults +/// with that exception. +/// +/// +/// Output bytes. Ephemeral; valid until the returned task completes. +/// Canceled when the terminal is disposed. +public delegate ValueTask PtyTerminalOutputHandler(ReadOnlyMemory data, CancellationToken cancellationToken); + +/// +/// Options for . +/// +public sealed class PtyTerminalOptions +{ + /// + /// Gets the handler that receives PTY output. Required; supplying it at start eliminates any + /// window where early child output could be produced with no consumer attached. + /// + public required PtyTerminalOutputHandler Output { get; init; } +} diff --git a/src/MiniPty.Terminal/PtyWebSocketBridge.cs b/src/MiniPty.Terminal/PtyWebSocketBridge.cs new file mode 100644 index 0000000..d799d4d --- /dev/null +++ b/src/MiniPty.Terminal/PtyWebSocketBridge.cs @@ -0,0 +1,339 @@ +using System.Net.WebSockets; +using MiniPty.Terminal.Internal; + +namespace MiniPty.Terminal; + +/// +/// Runs a full terminal session over one WebSocket: spawn, output pump with watermark/ACK flow +/// control, input and resize handling, exit notification, and close. Designed for xterm.js +/// frontends; the protocol is transport-shape only (binary frames = raw PTY data, text frames = +/// JSON control messages) so any client can implement it. +/// +/// +/// Protocol: +/// +/// server → client binary: raw PTY output bytes. +/// client → server binary: raw input bytes, written to the PTY verbatim. +/// client → server text: {"type":"resize","cols":120,"rows":30} or {"type":"ack","bytes":131072}. +/// server → client text: {"type":"exit","exitCode":0,"signal":15}, sent after the final output frame. +/// +/// +/// Unknown control type values are ignored (forward compatibility). Malformed JSON or an +/// oversize control message closes the socket with PolicyViolation and kills the child. +/// +/// +public static class PtyWebSocketBridge +{ + /// + /// Spawns the child and bridges it to until the child exits or + /// the socket closes. The bridge owns the terminal: on child exit it sends the exit message + /// and closes the socket; on socket close, error, or cancellation it kills the child. The + /// session is disposed before this method returns. + /// + /// Child process launch configuration. + /// + /// An open (Kestrel/HttpListener accept, , + /// or ). + /// + /// Bridge behavior, or for defaults. + /// When canceled, the child is killed and the session torn down. + /// The child exit status. + /// The client sent a malformed or oversize control message. + /// The bridge was canceled. + public static Task RunAsync( + PtyStartInfo startInfo, + WebSocket webSocket, + PtyBridgeOptions? options = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(startInfo); + ArgumentNullException.ThrowIfNull(webSocket); + var effectiveOptions = options ?? new PtyBridgeOptions(); + effectiveOptions.Validate(); + + return new BridgeSession(webSocket, effectiveOptions).RunAsync(startInfo, cancellationToken); + } + + private sealed class BridgeSession + { + private readonly WebSocket _webSocket; + private readonly PtyBridgeOptions _options; + private readonly BridgeFlowControl _flowControl; + private readonly SemaphoreSlim _sendLock = new(1, 1); + private volatile bool _discardOutput; + + public BridgeSession(WebSocket webSocket, PtyBridgeOptions options) + { + _webSocket = webSocket; + _options = options; + _flowControl = new BridgeFlowControl(options.HighWatermark, options.LowWatermark); + } + + public async Task RunAsync(PtyStartInfo startInfo, CancellationToken cancellationToken) + { + using var receiveCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var terminal = PtyTerminal.Start(startInfo, new PtyTerminalOptions { Output = SendOutputAsync }); + _flowControl.Attach(terminal); + + Task? receiveTask = null; + try + { + receiveTask = ReceiveLoopAsync(terminal, receiveCts.Token); + var first = await Task.WhenAny(terminal.Completion, receiveTask).ConfigureAwait(false); + + if (first == terminal.Completion) + { + // Child exited (or the output pump faulted; the await rethrows in that case). + // Completion resolves only after the final output frame was sent, so the exit + // message is guaranteed to follow all data. + var status = await terminal.Completion.ConfigureAwait(false); + await SendExitAndCloseAsync(status, cancellationToken).ConfigureAwait(false); + await AwaitReceiveShutdownAsync(receiveTask).ConfigureAwait(false); + return status; + } + + // The receive side ended first: client close, protocol violation, socket fault, or + // cancellation. Stop sending, release any flow-control pause so the post-kill drain + // can complete, and kill the child. + _discardOutput = true; + _flowControl.Disable(); + terminal.Kill(); + var killedStatus = await terminal.Completion.ConfigureAwait(false); + // Rethrows InvalidDataException (protocol violation) or OperationCanceledException. + await receiveTask.ConfigureAwait(false); + await RespondToClientCloseAsync().ConfigureAwait(false); + return killedStatus; + } + finally + { + _discardOutput = true; + _flowControl.Disable(); + receiveCts.Cancel(); + if (receiveTask is not null) + { + try + { + await receiveTask.ConfigureAwait(false); + } + catch + { + // The primary outcome (return value or exception) is already decided. + } + } + + await terminal.DisposeAsync().ConfigureAwait(false); + } + } + + private async ValueTask SendOutputAsync(ReadOnlyMemory data, CancellationToken cancellationToken) + { + if (_discardOutput) + return; + + await _sendLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if (_discardOutput) + return; + + await _webSocket.SendAsync(data, WebSocketMessageType.Binary, endOfMessage: true, cancellationToken).ConfigureAwait(false); + } + finally + { + _sendLock.Release(); + } + + _flowControl.OnSent(data.Length); + } + + private async Task ReceiveLoopAsync(PtyTerminal terminal, CancellationToken cancellationToken) + { + var buffer = new byte[_options.ReceiveBufferSize]; + byte[]? controlBuffer = null; + var controlLength = 0; + + while (true) + { + ValueWebSocketReceiveResult result; + try + { + result = await _webSocket.ReceiveAsync(buffer.AsMemory(), cancellationToken).ConfigureAwait(false); + } + catch (WebSocketException) + { + // Abrupt client disconnect: same outcome as a clean close. + return; + } + + if (result.MessageType == WebSocketMessageType.Close) + return; + + if (result.MessageType == WebSocketMessageType.Binary) + { + // Fragments arrive in order; each one is raw input and can be written directly. + if (result.Count > 0) + await WriteInputAsync(terminal, buffer.AsMemory(0, result.Count), cancellationToken).ConfigureAwait(false); + continue; + } + + // Text frame: control message. Accumulate fragments up to the configured cap. + if (controlLength + result.Count > _options.MaxControlMessageSize) + await ViolateProtocolAsync().ConfigureAwait(false); + + if (result.EndOfMessage && controlLength == 0) + { + await HandleControlAsync(buffer.AsMemory(0, result.Count), terminal).ConfigureAwait(false); + continue; + } + + controlBuffer ??= new byte[_options.MaxControlMessageSize]; + buffer.AsSpan(0, result.Count).CopyTo(controlBuffer.AsSpan(controlLength)); + controlLength += result.Count; + if (!result.EndOfMessage) + continue; + + var message = controlBuffer.AsMemory(0, controlLength); + controlLength = 0; + await HandleControlAsync(message, terminal).ConfigureAwait(false); + } + } + + private static async ValueTask WriteInputAsync(PtyTerminal terminal, ReadOnlyMemory bytes, CancellationToken cancellationToken) + { + try + { + await terminal.WriteInputAsync(bytes, cancellationToken).ConfigureAwait(false); + } + catch (Exception e) when (e is IOException or InvalidOperationException or ObjectDisposedException) + { + // Input racing child exit / teardown is benign; the session outcome is decided elsewhere. + } + } + + private async ValueTask HandleControlAsync(ReadOnlyMemory utf8Json, PtyTerminal terminal) + { + if (!BridgeJson.TryParse(utf8Json.Span, out var message)) + { + await ViolateProtocolAsync().ConfigureAwait(false); + return; + } + + switch (message!.Type) + { + case BridgeJson.TypeResize: + if (message.Cols is > 0 && message.Rows is > 0) + { + try + { + terminal.Resize(new PtySize(message.Cols.Value, message.Rows.Value)); + } + catch (Exception e) when (e is InvalidOperationException or ObjectDisposedException) + { + // Resize racing child exit / teardown is benign. + } + } + + break; + + case BridgeJson.TypeAck: + _flowControl.OnAcknowledged(message.Bytes ?? 0); + break; + + default: + // Unknown control types are ignored for forward compatibility. + break; + } + } + + /// Closes with PolicyViolation and throws; a broken client must not hold a shell open. + private async Task ViolateProtocolAsync() + { + try + { + using var closeCts = new CancellationTokenSource(_options.CloseTimeout); + await _webSocket.CloseOutputAsync( + WebSocketCloseStatus.PolicyViolation, + "malformed control message", + closeCts.Token).ConfigureAwait(false); + } + catch + { + // Best-effort close; the violation exception below is the outcome that matters. + } + + throw new InvalidDataException("Malformed or oversize control message received on the terminal WebSocket."); + } + + private async Task SendExitAndCloseAsync(PtyExitStatus status, CancellationToken cancellationToken) + { + try + { + using var closeCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + closeCts.CancelAfter(_options.CloseTimeout); + + if (_options.SendExitMessage) + { + var payload = BridgeJson.SerializeExit(status); + await _sendLock.WaitAsync(closeCts.Token).ConfigureAwait(false); + try + { + await _webSocket.SendAsync(payload, WebSocketMessageType.Text, endOfMessage: true, closeCts.Token).ConfigureAwait(false); + } + finally + { + _sendLock.Release(); + } + } + + if (_webSocket.State is WebSocketState.Open or WebSocketState.CloseReceived) + { + await _webSocket.CloseOutputAsync( + WebSocketCloseStatus.NormalClosure, + "child exited", + closeCts.Token).ConfigureAwait(false); + } + } + catch (Exception e) when (e is OperationCanceledException or WebSocketException or ObjectDisposedException) + { + // Best-effort exit notification against a slow or dead client; the exit status is + // still returned to the caller. + } + } + + /// Completes the close handshake after a client-initiated close. + private async Task RespondToClientCloseAsync() + { + if (_webSocket.State != WebSocketState.CloseReceived) + return; + + try + { + using var closeCts = new CancellationTokenSource(_options.CloseTimeout); + await _webSocket.CloseOutputAsync( + WebSocketCloseStatus.NormalClosure, + "session closed", + closeCts.Token).ConfigureAwait(false); + } + catch + { + // Best-effort close response. + } + } + + private async Task AwaitReceiveShutdownAsync(Task receiveTask) + { + try + { + await receiveTask.WaitAsync(_options.CloseTimeout).ConfigureAwait(false); + } + catch (TimeoutException) + { + _webSocket.Abort(); + } + catch + { + // Close-handshake races are benign once the exit path owns the outcome. + } + } + } +} diff --git a/src/MiniPty/Internal/IPtyBackend.cs b/src/MiniPty/Internal/IPtyBackend.cs index aafb24e..10d53af 100644 --- a/src/MiniPty/Internal/IPtyBackend.cs +++ b/src/MiniPty/Internal/IPtyBackend.cs @@ -12,10 +12,16 @@ internal interface IPtyBackend : IDisposable public int ProcessId { get; } public bool HasExited { get; } public int ExitCode { get; } + /// + /// Raw OS signal that terminated the child, observed after exit. Null on normal exit, before + /// exit, when the wait status was lost (ECHILD), and always on Windows. + /// + public int? ExitSignal { get; } public PtySize Size { get; } public void Resize(int columns, int rows); public void SendEof(); public void Kill(); + public void Kill(PtySignal signal); public Task WaitForExitAsync(CancellationToken cancellationToken, bool killOnCancellation, bool closeTransportOnExit = true); /// /// Blocks until the child exits or is canceled; allocation-free path for exit observation. diff --git a/src/MiniPty/Internal/UnixInterop.cs b/src/MiniPty/Internal/UnixInterop.cs index 72787fd..f792911 100644 --- a/src/MiniPty/Internal/UnixInterop.cs +++ b/src/MiniPty/Internal/UnixInterop.cs @@ -10,7 +10,16 @@ internal static partial class UnixInterop internal const int ECHILD = 10; internal const int EPIPE = 32; internal const int WaitNoHang = 1; + internal const int SigHup = 1; + internal const int SigInt = 2; + internal const int SigQuit = 3; internal const int SigKill = 9; + internal const int SigTerm = 15; + // SIGUSR1/SIGUSR2 numbering differs: Linux uses 10/12, macOS and FreeBSD use BSD 30/31. + internal const int SigUsr1Linux = 10; + internal const int SigUsr2Linux = 12; + internal const int SigUsr1Bsd = 30; + internal const int SigUsr2Bsd = 31; [LibraryImport("libc", EntryPoint = "read", SetLastError = true)] internal static unsafe partial int Read(int fd, byte* buf, nuint count); diff --git a/src/MiniPty/Internal/UnixPtyBackend.cs b/src/MiniPty/Internal/UnixPtyBackend.cs index 82bbf87..8a55f2a 100644 --- a/src/MiniPty/Internal/UnixPtyBackend.cs +++ b/src/MiniPty/Internal/UnixPtyBackend.cs @@ -122,6 +122,7 @@ private sealed class UnixPtyBackendInstance : IPtyBackend private bool _masterClosed; private bool _exited; private int _exitCode; + private int _termSignal; private bool _disposed; private PtySize _size; @@ -158,6 +159,15 @@ public int ExitCode } } + public int? ExitSignal + { + get + { + TryRefreshExitState(); + return _exited && _termSignal != 0 ? _termSignal : null; + } + } + public PtySize Size => _size; public void Resize(int columns, int rows) @@ -194,11 +204,34 @@ public void Kill() KillCore(); } + public void Kill(PtySignal signal) + { + // Validate before the disposed/exited guards so misuse always throws. + var nativeSignal = MapSignal(signal); + if (_disposed || TryRefreshExitState()) + return; + + // Fire-and-forget like KillCore; ESRCH after a racing exit is benign. + UnixInterop.kill(_pid, nativeSignal); + } + private void KillCore() { UnixInterop.kill(_pid, UnixInterop.SigKill); } + private static int MapSignal(PtySignal signal) => signal switch + { + PtySignal.Hangup => UnixInterop.SigHup, + PtySignal.Interrupt => UnixInterop.SigInt, + PtySignal.Quit => UnixInterop.SigQuit, + PtySignal.Kill => UnixInterop.SigKill, + PtySignal.User1 => OperatingSystem.IsLinux() ? UnixInterop.SigUsr1Linux : UnixInterop.SigUsr1Bsd, + PtySignal.User2 => OperatingSystem.IsLinux() ? UnixInterop.SigUsr2Linux : UnixInterop.SigUsr2Bsd, + PtySignal.Terminate => UnixInterop.SigTerm, + _ => throw new ArgumentOutOfRangeException(nameof(signal)), + }; + public Task WaitForExitAsync(CancellationToken cancellationToken, bool killOnCancellation, bool closeTransportOnExit = true) => WaitForExitCoreAsync(cancellationToken, killOnCancellation); @@ -321,6 +354,7 @@ private bool TryRefreshExitState() return false; _exitCode = MapWaitStatusToExitCode(status); + _termSignal = WIFSIGNALED(status) ? WTERMSIG(status) : 0; _exited = true; return true; } @@ -514,9 +548,15 @@ private static unsafe void FreeUtf8Allocations(List owned) owned.Clear(); } + // The exited/signaled wait-status layout (low 7 bits = signal, bits 8-15 = exit code) is + // identical on Linux, macOS, and FreeBSD, so decoding stays managed. WIFSIGNALED uses the + // explicit form excluding the stopped marker 0x7f: the textbook glibc macro relies on a + // signed-char cast that a direct C# transcription loses, which would misclassify a stopped + // status as signaled. Unreachable under WNOHANG-only polling today, but load-bearing now + // that the termination signal is public API. private static bool WIFEXITED(int status) => (status & 0x7f) == 0; private static int WEXITSTATUS(int status) => (status >> 8) & 0xff; - private static bool WIFSIGNALED(int status) => (((status & 0x7f) + 1) >> 1) > 0; + private static bool WIFSIGNALED(int status) => (status & 0x7f) != 0 && (status & 0x7f) != 0x7f; private static int WTERMSIG(int status) => status & 0x7f; private static int MapWaitStatusToExitCode(int status) diff --git a/src/MiniPty/Internal/WindowsPtyBackend.cs b/src/MiniPty/Internal/WindowsPtyBackend.cs index f507810..dc05e67 100644 --- a/src/MiniPty/Internal/WindowsPtyBackend.cs +++ b/src/MiniPty/Internal/WindowsPtyBackend.cs @@ -305,6 +305,9 @@ public int ExitCode } } + // Windows has no signal concept for exit reporting; ConPTY children always report null. + public int? ExitSignal => null; + public PtySize Size => _size; public void Resize(int columns, int rows) @@ -345,6 +348,19 @@ public void Kill() KillCore(); } + public void Kill(PtySignal signal) + { + // node-pty semantics: the signal is advisory on Windows and the child is terminated. + // Validate the enum the same way the Unix backend does so misuse throws on both platforms. + _ = signal switch + { + PtySignal.Hangup or PtySignal.Interrupt or PtySignal.Quit or PtySignal.Kill + or PtySignal.User1 or PtySignal.User2 or PtySignal.Terminate => 0, + _ => throw new ArgumentOutOfRangeException(nameof(signal)), + }; + Kill(); + } + private void KillCore() { WindowsInterop.TerminateProcess(_processInfo.hProcess, 1); diff --git a/src/MiniPty/PtyExitStatus.cs b/src/MiniPty/PtyExitStatus.cs new file mode 100644 index 0000000..724fa8a --- /dev/null +++ b/src/MiniPty/PtyExitStatus.cs @@ -0,0 +1,16 @@ +namespace MiniPty; + +/// +/// Exit status of the PTY child: the exit code plus, on Unix, the terminating signal. +/// +/// +/// Same value as : the child's exit code, or +/// 128 + signal when a Unix child was terminated by a signal (for example 143 for SIGTERM). +/// +/// +/// Raw OS signal number that terminated the child (waitpid WTERMSIG), or +/// when the child exited normally or the signal is unknown. +/// Always on Windows. The number is the OS's own value and can differ +/// per platform for uncommon signals. +/// +public readonly record struct PtyExitStatus(int ExitCode, int? Signal); diff --git a/src/MiniPty/PtySession.cs b/src/MiniPty/PtySession.cs index 66edffa..5cc2af8 100644 --- a/src/MiniPty/PtySession.cs +++ b/src/MiniPty/PtySession.cs @@ -182,6 +182,17 @@ public async IAsyncEnumerable ReadOutputAsync( /// public int? ExitCode => _backend.HasExited ? _backend.ExitCode : null; + /// + /// Gets the exit status (exit code plus Unix termination signal) when + /// is ; otherwise . + /// + /// + /// always equals . The signal is + /// reported on Unix when the child was terminated by a signal; Windows never reports a signal. + /// + public PtyExitStatus? ExitStatus => + _backend.HasExited ? new PtyExitStatus(_backend.ExitCode, _backend.ExitSignal) : null; + /// /// Writes raw bytes to the PTY stdin. /// @@ -252,6 +263,23 @@ public void Kill() _backend.Kill(); } + /// + /// Sends a signal to the child process without releasing PTY handles. + /// + /// + /// On Unix the mapped native signal is delivered via kill(2); the child may handle or + /// ignore catchable signals, so termination is not guaranteed. On Windows the signal is + /// advisory and the child is terminated unconditionally, matching node-pty semantics. + /// Call afterward to release resources. + /// + /// Signal to deliver. + /// is not a defined value. + public void Kill(PtySignal signal) + { + ThrowIfDisposed(); + _backend.Kill(signal); + } + /// /// Asynchronously waits until the child process exits. /// @@ -267,6 +295,22 @@ public Task WaitForExitAsync(CancellationToken cancellationToken = default) return WaitForExitInternalScopedAsync(cancellationToken, killOnCancellation: false); } + /// + /// Asynchronously waits until the child process exits and returns its . + /// + /// + /// When canceled, waiting stops and is thrown. + /// The child process continues running, identical to . + /// + /// A task that completes with the child exit code and Unix termination signal. + /// Waiting was canceled. + public async Task WaitForExitStatusAsync(CancellationToken cancellationToken = default) + { + ThrowIfDisposed(); + var exitCode = await WaitForExitInternalScopedAsync(cancellationToken, killOnCancellation: false).ConfigureAwait(false); + return new PtyExitStatus(exitCode, _backend.ExitSignal); + } + /// /// Pumps and drains the PTY output stream, optionally writes stdin, waits for exit, and returns captured bytes. /// diff --git a/src/MiniPty/PtySignal.cs b/src/MiniPty/PtySignal.cs new file mode 100644 index 0000000..45211eb --- /dev/null +++ b/src/MiniPty/PtySignal.cs @@ -0,0 +1,34 @@ +namespace MiniPty; + +/// +/// Signals that can be sent to the PTY child with . +/// +/// +/// Members are logical identifiers, not raw OS numbers: on Unix each member is mapped to the +/// platform's native signal number internally (SIGUSR1/SIGUSR2 numbering differs between Linux +/// and macOS/FreeBSD). On Windows the signal is advisory; any value terminates the child, +/// matching node-pty semantics. +/// +public enum PtySignal +{ + /// SIGHUP: terminal hangup. node-pty's default kill signal. + Hangup = 1, + + /// SIGINT: interrupt, Ctrl+C semantics. + Interrupt = 2, + + /// SIGQUIT: quit. + Quit = 3, + + /// SIGKILL: forced, unhandleable termination. Same effect as . + Kill = 9, + + /// SIGUSR1: user-defined signal 1. Native number differs per OS. + User1 = 10, + + /// SIGUSR2: user-defined signal 2. Native number differs per OS. + User2 = 12, + + /// SIGTERM: graceful termination request. + Terminate = 15, +} diff --git a/tests/MiniPty.Tests/MiniPty.Tests.csproj b/tests/MiniPty.Tests/MiniPty.Tests.csproj index 73fca26..fac2bf7 100644 --- a/tests/MiniPty.Tests/MiniPty.Tests.csproj +++ b/tests/MiniPty.Tests/MiniPty.Tests.csproj @@ -11,6 +11,7 @@ +
diff --git a/tests/MiniPty.Tests/PtyTerminalTests.cs b/tests/MiniPty.Tests/PtyTerminalTests.cs new file mode 100644 index 0000000..4888935 --- /dev/null +++ b/tests/MiniPty.Tests/PtyTerminalTests.cs @@ -0,0 +1,241 @@ +using System.Runtime.InteropServices; +using System.Text; +using MiniPty.Terminal; + +namespace MiniPty.Tests; + +public sealed class PtyTerminalTests +{ + [Test] + public async Task PtyTerminalDeliversOutputBeforeCompletion() + { + var buffer = new MemoryStream(); + var handlerCompletedBeforeExit = true; + PtyTerminal terminal = null!; + + terminal = PtyTerminal.Start(EchoMarkerChild("TERMINAL_MARKER"), new PtyTerminalOptions + { + Output = (data, _) => + { + if (terminal.Completion.IsCompleted) + handlerCompletedBeforeExit = false; + buffer.Write(data.Span); + return ValueTask.CompletedTask; + }, + }); + + await using (terminal) + { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + var status = await terminal.Completion.WaitAsync(cts.Token); + + await Assert.That(status.ExitCode).IsEqualTo(0); + await Assert.That(Encoding.UTF8.GetString(buffer.ToArray())).Contains("TERMINAL_MARKER"); + await Assert.That(handlerCompletedBeforeExit).IsTrue(); + } + } + + [Test] + public async Task PtyTerminalSlowHandlerDoesNotDropOutput() + { + var buffer = new MemoryStream(); + + await using var terminal = PtyTerminal.Start(EchoMarkerChild("SLOW_HANDLER_MARKER"), new PtyTerminalOptions + { + Output = async (data, ct) => + { + await Task.Delay(20, ct); + buffer.Write(data.Span); + }, + }); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + var status = await terminal.Completion.WaitAsync(cts.Token); + + await Assert.That(status.ExitCode).IsEqualTo(0); + await Assert.That(Encoding.UTF8.GetString(buffer.ToArray())).Contains("SLOW_HANDLER_MARKER"); + } + + [Test] + public async Task PtyTerminalPauseStopsDeliveryAndResumeRecovers() + { + var delivered = 0L; + await using var terminal = PtyTerminal.Start(BulkOutputChild(), new PtyTerminalOptions + { + Output = (data, _) => + { + Interlocked.Add(ref delivered, data.Length); + return ValueTask.CompletedTask; + }, + }); + + // Wait for first delivery so the pump is known to be running. + using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + while (Interlocked.Read(ref delivered) == 0) + { + startCts.Token.ThrowIfCancellationRequested(); + await Task.Delay(10, startCts.Token); + } + + terminal.Pause(); + // Allow at most the single in-flight chunk to land, then verify no further delivery. + await Task.Delay(200); + var afterPause = Interlocked.Read(ref delivered); + await Task.Delay(300); + await Assert.That(Interlocked.Read(ref delivered)).IsEqualTo(afterPause); + + terminal.Resume(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + var status = await terminal.Completion.WaitAsync(cts.Token); + + await Assert.That(status.ExitCode).IsEqualTo(0); + await Assert.That(Interlocked.Read(ref delivered)).IsGreaterThan(afterPause); + } + + [Test] + public async Task PtyTerminalWriteInputAndResizePassThrough() + { + var buffer = new MemoryStream(); + var bufferLock = new Lock(); + + await using var terminal = PtyTerminal.Start(EchoInputChild(), new PtyTerminalOptions + { + Output = (data, _) => + { + lock (bufferLock) + { + buffer.Write(data.Span); + } + return ValueTask.CompletedTask; + }, + }); + + terminal.Resize(new PtySize(100, 40)); + await Assert.That(terminal.Size).IsEqualTo(new PtySize(100, 40)); + await Assert.That(terminal.ProcessId).IsGreaterThan(0); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + await terminal.WriteInputAsync("INPUT_MARKER\n", cancellationToken: cts.Token); + + while (true) + { + cts.Token.ThrowIfCancellationRequested(); + lock (bufferLock) + { + if (Encoding.UTF8.GetString(buffer.ToArray()).Contains("INPUT_MARKER")) + break; + } + await Task.Delay(10, cts.Token); + } + + terminal.Kill(); + await terminal.Completion.WaitAsync(cts.Token); + await Assert.That(terminal.HasExited).IsTrue(); + } + + [Test] + public async Task PtyTerminalKillResolvesCompletion() + { + await using var terminal = PtyTerminal.Start(StdinBlockingChild(), new PtyTerminalOptions + { + Output = (_, _) => ValueTask.CompletedTask, + }); + + terminal.Kill(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + var status = await terminal.Completion.WaitAsync(cts.Token); + + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + await Assert.That(status.Signal).IsEqualTo(9); + await Assert.That(status.ExitCode).IsEqualTo(137); + } + else + { + await Assert.That(status.Signal).IsNull(); + } + } + + [Test] + public async Task PtyTerminalHandlerExceptionFaultsCompletionAndKillsChild() + { + await using var terminal = PtyTerminal.Start(EchoMarkerChild("BOOM_TRIGGER"), new PtyTerminalOptions + { + Output = (_, _) => throw new InvalidOperationException("handler failed"), + }); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + var exception = await Assert.ThrowsAsync( + async () => await terminal.Completion.WaitAsync(cts.Token)); + + await Assert.That(exception!.Message).IsEqualTo("handler failed"); + + // Kill is fire-and-forget; the OS may report exit slightly after Completion faults. + while (!terminal.HasExited) + { + cts.Token.ThrowIfCancellationRequested(); + await Task.Delay(10, cts.Token); + } + await Assert.That(terminal.HasExited).IsTrue(); + } + + [Test] + public async Task PtyTerminalDisposeKillsRunningChild() + { + var terminal = PtyTerminal.Start(StdinBlockingChild(), new PtyTerminalOptions + { + Output = (_, _) => ValueTask.CompletedTask, + }); + + await terminal.DisposeAsync(); + + await Assert.ThrowsAsync(async () => await terminal.WriteInputAsync("x")); + Assert.Throws(() => terminal.Kill()); + } + + [Test] + public async Task PtyTerminalStartValidatesArguments() + { + Assert.Throws(() => PtyTerminal.Start(null!, new PtyTerminalOptions + { + Output = (_, _) => ValueTask.CompletedTask, + })); + Assert.Throws(() => PtyTerminal.Start(StdinBlockingChild(), null!)); + Assert.Throws(() => PtyTerminal.Start(StdinBlockingChild(), new PtyTerminalOptions + { + Output = null!, + })); + await Task.CompletedTask; + } + + private static PtyStartInfo Spawn(string fileName, IReadOnlyList arguments) => + new() { FileName = fileName, Arguments = arguments, Size = new(80, 24) }; + + /// Child that prints the marker and exits 0. + private static PtyStartInfo EchoMarkerChild(string marker) => + RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? Spawn(WindowsComSpec(), ["/c", $"echo {marker}"]) + : Spawn("sh", ["-c", $"printf '{marker}\\n'"]); + + /// Child that echoes one stdin line back to stdout, then waits for another line (killed by test). + /// Windows uses delayed expansion because %LINE% would be expanded before set /p runs. + private static PtyStartInfo EchoInputChild() => + RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? Spawn(WindowsComSpec(), ["/v:on", "/c", "set /p LINE= & echo GOT:!LINE! & set /p DUMMY="]) + : Spawn("sh", ["-c", "IFS= read -r line; printf 'GOT:%s\\n' \"$line\"; IFS= read -r _"]); + + /// Child blocked on stdin until killed. + private static PtyStartInfo StdinBlockingChild() => + RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? Spawn(WindowsComSpec(), ["/c", "set /p DUMMY="]) + : Spawn("sh", ["-c", "IFS= read -r _"]); + + /// Child that emits sustained bulk output (256 KiB) then exits 0. + private static PtyStartInfo BulkOutputChild() => + RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? Spawn(WindowsComSpec(), ["/c", "for /l %i in (1,1,2048) do @echo 0123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567"]) + : Spawn("sh", ["-c", "i=0; while [ $i -lt 2048 ]; do printf '%0128d\\n' \"$i\"; i=$((i+1)); done"]); + + private static string WindowsComSpec() => + Environment.GetEnvironmentVariable("ComSpec") ?? @"C:\Windows\System32\cmd.exe"; +} diff --git a/tests/MiniPty.Tests/PtyTests.cs b/tests/MiniPty.Tests/PtyTests.cs index ed90d79..b240075 100644 --- a/tests/MiniPty.Tests/PtyTests.cs +++ b/tests/MiniPty.Tests/PtyTests.cs @@ -630,6 +630,106 @@ public async Task PtySignalExitCodeIsCapturedOnUnix() await Assert.That(result.ExitCode).IsEqualTo(143); } + [Test] + public async Task PtyWaitForExitStatusAsyncReportsSignalOnUnix() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + return; + + await using var session = Pty.Start(UnixShell("kill -TERM $$")); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + var status = await session.WaitForExitStatusAsync(cts.Token); + + await Assert.That(status.ExitCode).IsEqualTo(143); + await Assert.That(status.Signal).IsEqualTo(15); + // ExitCode compatibility pin: PtyExitStatus.ExitCode always equals PtySession.ExitCode. + await Assert.That(session.ExitCode).IsEqualTo(143); + await Assert.That(session.ExitStatus).IsEqualTo(status); + } + + [Test] + public async Task PtyWaitForExitStatusAsyncHasNoSignalOnNormalExit() + { + await using var session = Pty.Start(RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? WindowsCommand("exit /b 42") + : UnixShell("exit 42")); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + var status = await session.WaitForExitStatusAsync(cts.Token); + + await Assert.That(status.ExitCode).IsEqualTo(42); + await Assert.That(status.Signal).IsNull(); + await Assert.That(session.ExitStatus).IsEqualTo(status); + } + + [Test] + public async Task PtyKillWithSignalTerminatesChildAndReportsSignalOnUnix() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + return; + + await using (var session = Pty.Start(UnixShell("IFS= read -r _"))) + { + session.Kill(PtySignal.Terminate); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + var status = await session.WaitForExitStatusAsync(cts.Token); + + await Assert.That(status.ExitCode).IsEqualTo(143); + await Assert.That(status.Signal).IsEqualTo(15); + } + + await using (var session = Pty.Start(UnixShell("IFS= read -r _"))) + { + session.Kill(PtySignal.Kill); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + var status = await session.WaitForExitStatusAsync(cts.Token); + + await Assert.That(status.ExitCode).IsEqualTo(137); + await Assert.That(status.Signal).IsEqualTo(9); + } + } + + [Test] + public async Task PtyKillWithSignalTerminatesChildOnWindows() + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + return; + + await using var session = Pty.Start(WindowsCommand("set /p DUMMY=")); + session.Kill(PtySignal.Terminate); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + var status = await session.WaitForExitStatusAsync(cts.Token); + + // Windows terminate is advisory-signal: TerminateProcess(h, 1) and never a signal report. + await Assert.That(status.ExitCode).IsEqualTo(1); + await Assert.That(status.Signal).IsNull(); + } + + [Test] + public async Task PtyKillWithUndefinedSignalThrows() + { + await using var session = Pty.Start(RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? WindowsCommand("set /p DUMMY=") + : UnixShell("IFS= read -r _")); + + Assert.Throws(() => session.Kill((PtySignal)9999)); + await Assert.That(session.HasExited).IsFalse(); + } + + [Test] + public async Task PtyWaitForExitStatusAsyncCancellationDoesNotKillChild() + { + await using var session = Pty.Start(RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? WindowsCommand("set /p DUMMY=") + : UnixShell("IFS= read -r _")); + + using var cts = new CancellationTokenSource(); + var waitTask = session.WaitForExitStatusAsync(cts.Token); + await cts.CancelAsync(); + + await Assert.ThrowsAsync(async () => await waitTask); + await Assert.That(session.HasExited).IsFalse(); + } + [Test] public async Task PtyChildSeesTtyOutput() { diff --git a/tests/MiniPty.Tests/PtyWebSocketBridgeTests.cs b/tests/MiniPty.Tests/PtyWebSocketBridgeTests.cs new file mode 100644 index 0000000..fb7e1c6 --- /dev/null +++ b/tests/MiniPty.Tests/PtyWebSocketBridgeTests.cs @@ -0,0 +1,503 @@ +using System.Net.WebSockets; +using System.Runtime.InteropServices; +using System.Text; +using System.Text.Json; +using MiniPty.Terminal; + +namespace MiniPty.Tests; + +public sealed class PtyWebSocketBridgeTests +{ + [Test] + public async Task BridgeDeliversChildOutputAsBinaryFrames() + { + var (serverSocket, clientSocket) = CreateSocketPair(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + + var bridgeTask = PtyWebSocketBridge.RunAsync(EchoMarkerChild("BRIDGE_MARKER"), serverSocket, cancellationToken: cts.Token); + var client = new BridgeTestClient(clientSocket); + await client.RunToCloseAsync(ackEverything: true, cts.Token); + + var status = await bridgeTask.WaitAsync(cts.Token); + + await Assert.That(status.ExitCode).IsEqualTo(0); + await Assert.That(client.BinaryText).Contains("BRIDGE_MARKER"); + } + + [Test] + public async Task BridgeWritesClientBinaryFramesToChildInput() + { + var (serverSocket, clientSocket) = CreateSocketPair(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + + var bridgeTask = PtyWebSocketBridge.RunAsync(EchoInputThenExitChild(), serverSocket, cancellationToken: cts.Token); + var client = new BridgeTestClient(clientSocket); + var clientTask = client.RunToCloseAsync(ackEverything: true, cts.Token); + + await client.SendBinaryAsync("ROUND_TRIP" + Enter, cts.Token); + + await clientTask; + var status = await bridgeTask.WaitAsync(cts.Token); + + await Assert.That(status.ExitCode).IsEqualTo(0); + await Assert.That(client.BinaryText).Contains("GOT:ROUND_TRIP"); + } + + [Test] + public async Task BridgeResizeControlMessageResizesTerminal() + { + var (serverSocket, clientSocket) = CreateSocketPair(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + + PtyStartInfo startInfo; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + // Windows has no simple size-reporting console child; assert the resize message is + // accepted and the session completes normally. + startInfo = EchoInputThenExitChild(); + } + else + { + startInfo = Spawn("sh", ["-c", "IFS= read -r _; stty size"]); + } + + var bridgeTask = PtyWebSocketBridge.RunAsync(startInfo, serverSocket, cancellationToken: cts.Token); + var client = new BridgeTestClient(clientSocket); + var clientTask = client.RunToCloseAsync(ackEverything: true, cts.Token); + + await client.SendTextAsync("{\"type\":\"resize\",\"cols\":100,\"rows\":40}", cts.Token); + // Give the resize a moment to apply before the child samples the size. + await Task.Delay(200, cts.Token); + await client.SendBinaryAsync("go" + Enter, cts.Token); + + await clientTask; + var status = await bridgeTask.WaitAsync(cts.Token); + + await Assert.That(status.ExitCode).IsEqualTo(0); + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + await Assert.That(client.BinaryText).Contains("40 100"); + } + + [Test] + public async Task BridgeFlowControlPausesWithoutAcksAndResumesOnAck() + { + var (serverSocket, clientSocket) = CreateSocketPair(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(60)); + var options = new PtyBridgeOptions { HighWatermark = 4096, LowWatermark = 1024 }; + + var bridgeTask = PtyWebSocketBridge.RunAsync(BulkOutputChild(), serverSocket, options, cts.Token); + var client = new BridgeTestClient(clientSocket); + var clientTask = client.RunToCloseAsync(ackEverything: false, cts.Token); + + // Withhold acks: delivery must stall once unacknowledged bytes reach the high watermark. + long stalledAt = 0; + while (true) + { + cts.Token.ThrowIfCancellationRequested(); + var before = client.BinaryByteCount; + await Task.Delay(400, cts.Token); + if (client.BinaryByteCount == before && before >= options.HighWatermark) + { + stalledAt = before; + break; + } + } + + // A stalled stream must stay stalled without acks. + await Task.Delay(300, cts.Token); + await Assert.That(client.BinaryByteCount).IsEqualTo(stalledAt); + + // Ack everything received so far: delivery must resume and the session must complete. + client.AckEverythingFromNowOn(); + await client.SendAckAsync(stalledAt, cts.Token); + + await clientTask; + var status = await bridgeTask.WaitAsync(cts.Token); + + await Assert.That(status.ExitCode).IsEqualTo(0); + await Assert.That(client.BinaryByteCount).IsGreaterThan(stalledAt); + } + + [Test] + public async Task BridgeSendsExitMessageAfterFinalOutputThenClosesNormally() + { + var (serverSocket, clientSocket) = CreateSocketPair(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + + var bridgeTask = PtyWebSocketBridge.RunAsync(EchoMarkerChild("ORDERED_MARKER"), serverSocket, cancellationToken: cts.Token); + var client = new BridgeTestClient(clientSocket); + await client.RunToCloseAsync(ackEverything: true, cts.Token); + + var status = await bridgeTask.WaitAsync(cts.Token); + + await Assert.That(status.ExitCode).IsEqualTo(0); + await Assert.That(client.ExitMessage).IsNotNull(); + await Assert.That(client.ExitMessage!.Value.GetProperty("exitCode").GetInt32()).IsEqualTo(0); + // The exit control message arrived after every binary frame. + await Assert.That(client.ExitMessageIndex).IsEqualTo(client.MessageCount - 1); + await Assert.That(client.CloseStatus).IsEqualTo(WebSocketCloseStatus.NormalClosure); + } + + [Test] + public async Task BridgeClientCloseKillsChildAndReturnsStatus() + { + var (serverSocket, clientSocket) = CreateSocketPair(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + + var bridgeTask = PtyWebSocketBridge.RunAsync(StdinBlockingChild(), serverSocket, cancellationToken: cts.Token); + + // Wait for the session to be live (prompt bytes flowing is not guaranteed; a short delay + // plus close exercises the mid-session teardown path deterministically enough). + await Task.Delay(300, cts.Token); + await clientSocket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "bye", cts.Token); + + var status = await bridgeTask.WaitAsync(cts.Token); + + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + await Assert.That(status.Signal).IsEqualTo(9); + } + else + { + await Assert.That(status.ExitCode).IsEqualTo(1); + await Assert.That(status.Signal).IsNull(); + } + } + + [Test] + public async Task BridgeMalformedControlMessageClosesWithPolicyViolation() + { + var (serverSocket, clientSocket) = CreateSocketPair(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + + var bridgeTask = PtyWebSocketBridge.RunAsync(StdinBlockingChild(), serverSocket, cancellationToken: cts.Token); + + await clientSocket.SendAsync( + Encoding.UTF8.GetBytes("this is not json"), + WebSocketMessageType.Text, + endOfMessage: true, + cts.Token); + + await Assert.ThrowsAsync(async () => await bridgeTask.WaitAsync(cts.Token)); + + // The client observes the PolicyViolation close frame. + var buffer = new byte[1024]; + while (true) + { + var result = await clientSocket.ReceiveAsync(buffer.AsMemory(), cts.Token); + if (result.MessageType == WebSocketMessageType.Close) + break; + } + + await Assert.That(clientSocket.CloseStatus).IsEqualTo(WebSocketCloseStatus.PolicyViolation); + } + + [Test] + public async Task BridgeUnknownControlTypeIsIgnored() + { + var (serverSocket, clientSocket) = CreateSocketPair(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + + var bridgeTask = PtyWebSocketBridge.RunAsync(EchoInputThenExitChild(), serverSocket, cancellationToken: cts.Token); + var client = new BridgeTestClient(clientSocket); + var clientTask = client.RunToCloseAsync(ackEverything: true, cts.Token); + + await client.SendTextAsync("{\"type\":\"future-extension\",\"payload\":123}", cts.Token); + await client.SendBinaryAsync("STILL_ALIVE" + Enter, cts.Token); + + await clientTask; + var status = await bridgeTask.WaitAsync(cts.Token); + + await Assert.That(status.ExitCode).IsEqualTo(0); + await Assert.That(client.BinaryText).Contains("GOT:STILL_ALIVE"); + } + + [Test] + public async Task BridgeOptionValidationRejectsInvertedWatermarks() + { + var (serverSocket, _) = CreateSocketPair(); + var options = new PtyBridgeOptions { HighWatermark = 100, LowWatermark = 100 }; + + await Assert.ThrowsAsync( + async () => await PtyWebSocketBridge.RunAsync(StdinBlockingChild(), serverSocket, options)); + } + + // ---- test doubles and helpers ---- + + /// + /// Client-side driver: receives every message in order, tracks binary bytes, optionally acks + /// each binary frame, records the exit control message, and completes the close handshake. + /// + private sealed class BridgeTestClient + { + private readonly WebSocket _socket; + private readonly StringBuilder _binaryText = new(); + private readonly SemaphoreSlim _sendLock = new(1, 1); + private long _binaryByteCount; + private int _messageCount; + private volatile bool _ackEverything; + + public BridgeTestClient(WebSocket socket) => _socket = socket; + + public string BinaryText + { + get + { + lock (_binaryText) + { + return _binaryText.ToString(); + } + } + } + + public long BinaryByteCount => Interlocked.Read(ref _binaryByteCount); + public int MessageCount => _messageCount; + public JsonElement? ExitMessage { get; private set; } + public int ExitMessageIndex { get; private set; } = -1; + public WebSocketCloseStatus? CloseStatus { get; private set; } + + public void AckEverythingFromNowOn() => _ackEverything = true; + + public Task SendAckAsync(long bytes, CancellationToken cancellationToken) => + SendAsync(Encoding.UTF8.GetBytes($"{{\"type\":\"ack\",\"bytes\":{bytes}}}"), WebSocketMessageType.Text, cancellationToken); + + public Task SendBinaryAsync(string text, CancellationToken cancellationToken) => + SendAsync(Encoding.UTF8.GetBytes(text), WebSocketMessageType.Binary, cancellationToken); + + public Task SendTextAsync(string json, CancellationToken cancellationToken) => + SendAsync(Encoding.UTF8.GetBytes(json), WebSocketMessageType.Text, cancellationToken); + + /// WebSocket allows only one outstanding send; serialize acks and test-driven sends. + private async Task SendAsync(byte[] payload, WebSocketMessageType messageType, CancellationToken cancellationToken) + { + await _sendLock.WaitAsync(cancellationToken); + try + { + await _socket.SendAsync(payload, messageType, endOfMessage: true, cancellationToken); + } + finally + { + _sendLock.Release(); + } + } + + public async Task RunToCloseAsync(bool ackEverything, CancellationToken cancellationToken) + { + _ackEverything = ackEverything; + var buffer = new byte[64 * 1024]; + var message = new MemoryStream(); + + while (true) + { + var result = await _socket.ReceiveAsync(buffer.AsMemory(), cancellationToken); + if (result.MessageType == WebSocketMessageType.Close) + { + CloseStatus = _socket.CloseStatus; + if (_socket.State == WebSocketState.CloseReceived) + await _socket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, null, cancellationToken); + return; + } + + message.Write(buffer, 0, result.Count); + if (!result.EndOfMessage) + continue; + + var payload = message.ToArray(); + message.SetLength(0); + _messageCount++; + + if (result.MessageType == WebSocketMessageType.Binary) + { + Interlocked.Add(ref _binaryByteCount, payload.Length); + lock (_binaryText) + { + _binaryText.Append(Encoding.UTF8.GetString(payload)); + } + + if (_ackEverything && payload.Length > 0) + await SendAckAsync(payload.Length, cancellationToken); + continue; + } + + var json = JsonDocument.Parse(payload).RootElement.Clone(); + if (json.TryGetProperty("type", out var type) && type.GetString() == "exit") + { + ExitMessage = json; + ExitMessageIndex = _messageCount - 1; + } + } + } + } + + private static (WebSocket Server, WebSocket Client) CreateSocketPair() + { + var (serverStream, clientStream) = InMemoryDuplexStream.CreatePair(); + var server = WebSocket.CreateFromStream(serverStream, new WebSocketCreationOptions { IsServer = true }); + var client = WebSocket.CreateFromStream(clientStream, new WebSocketCreationOptions { IsServer = false }); + return (server, client); + } + + /// + /// In-memory duplex stream pair carrying the raw WebSocket protocol between + /// endpoints. + /// Unbounded, so transport buffering never interferes with flow-control assertions. + /// + private sealed class InMemoryDuplexStream : Stream + { + private readonly BytePipe _readPipe; + private readonly BytePipe _writePipe; + + private InMemoryDuplexStream(BytePipe readPipe, BytePipe writePipe) + { + _readPipe = readPipe; + _writePipe = writePipe; + } + + public static (InMemoryDuplexStream A, InMemoryDuplexStream B) CreatePair() + { + var aToB = new BytePipe(); + var bToA = new BytePipe(); + return (new InMemoryDuplexStream(bToA, aToB), new InMemoryDuplexStream(aToB, bToA)); + } + + public override bool CanRead => true; + 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() { } + public override Task FlushAsync(CancellationToken cancellationToken) => Task.CompletedTask; + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + + public override int Read(byte[] buffer, int offset, int count) => + _readPipe.ReadAsync(buffer.AsMemory(offset, count), CancellationToken.None).AsTask().GetAwaiter().GetResult(); + + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) => + _readPipe.ReadAsync(buffer, cancellationToken); + + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) => + _readPipe.ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); + + public override void Write(byte[] buffer, int offset, int count) => + _writePipe.Write(buffer.AsSpan(offset, count)); + + public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + _writePipe.Write(buffer.Span); + return ValueTask.CompletedTask; + } + + public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + _writePipe.Write(buffer.AsSpan(offset, count)); + return Task.CompletedTask; + } + + protected override void Dispose(bool disposing) + { + if (disposing) + _writePipe.Complete(); + base.Dispose(disposing); + } + } + + private sealed class BytePipe + { + private readonly Lock _lock = new(); + private readonly Queue _chunks = new(); + private readonly SemaphoreSlim _signal = new(0); + private byte[]? _current; + private int _currentOffset; + private bool _completed; + + public void Write(ReadOnlySpan data) + { + if (data.IsEmpty) + return; + + lock (_lock) + { + if (_completed) + return; + _chunks.Enqueue(data.ToArray()); + } + + _signal.Release(); + } + + public void Complete() + { + lock (_lock) + { + _completed = true; + } + + _signal.Release(); + } + + public async ValueTask ReadAsync(Memory destination, CancellationToken cancellationToken) + { + while (true) + { + lock (_lock) + { + if (_current is null && _chunks.Count > 0) + { + _current = _chunks.Dequeue(); + _currentOffset = 0; + } + + if (_current is not null) + { + var available = _current.Length - _currentOffset; + var count = Math.Min(available, destination.Length); + _current.AsSpan(_currentOffset, count).CopyTo(destination.Span); + _currentOffset += count; + if (_currentOffset == _current.Length) + _current = null; + return count; + } + + if (_completed) + return 0; + } + + await _signal.WaitAsync(cancellationToken).ConfigureAwait(false); + } + } + } + + private static PtyStartInfo Spawn(string fileName, IReadOnlyList arguments) => + new() { FileName = fileName, Arguments = arguments, Size = new(80, 24) }; + + private static PtyStartInfo EchoMarkerChild(string marker) => + RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? Spawn(WindowsComSpec(), ["/c", $"echo {marker}"]) + : Spawn("sh", ["-c", $"printf '{marker}\\n'"]); + + /// Child that echoes one stdin line back to stdout, then exits 0. Windows uses delayed + /// expansion because %LINE% would be expanded before set /p runs. + private static PtyStartInfo EchoInputThenExitChild() => + RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? Spawn(WindowsComSpec(), ["/v:on", "/c", "set /p LINE= & echo GOT:!LINE!"]) + : Spawn("sh", ["-c", "IFS= read -r line; printf 'GOT:%s\\n' \"$line\""]); + + private static PtyStartInfo StdinBlockingChild() => + RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? Spawn(WindowsComSpec(), ["/c", "set /p DUMMY="]) + : Spawn("sh", ["-c", "IFS= read -r _"]); + + /// Child that emits sustained bulk output (~256 KiB) then exits 0. + private static PtyStartInfo BulkOutputChild() => + RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? Spawn(WindowsComSpec(), ["/c", "for /l %i in (1,1,2048) do @echo 0123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567"]) + : Spawn("sh", ["-c", "i=0; while [ $i -lt 2048 ]; do printf '%0128d\\n' \"$i\"; i=$((i+1)); done"]); + + private static string WindowsComSpec() => + Environment.GetEnvironmentVariable("ComSpec") ?? @"C:\Windows\System32\cmd.exe"; + + /// ConPTY line submission needs CR; Unix canonical mode accepts LF. + private static string Enter => + RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "\r\n" : "\n"; +} From adff060b399677ba952a655ff66c80d381b9c807 Mon Sep 17 00:00:00 2001 From: Ikiru Yoshizaki <3856350+guitarrapc@users.noreply.github.com> Date: Mon, 13 Jul 2026 18:39:37 +0900 Subject: [PATCH 02/13] feat: Terminal Backend PTY Support --- .github/docs/specs/terminal.md | 5 +- .github/workflows/benchmark.yaml | 2 +- src/MiniPty.Terminal/PtyTerminal.cs | 13 +- src/MiniPty.Terminal/PtyWebSocketBridge.cs | 76 ++++++++++-- .../MiniPty.Tests/PtyWebSocketBridgeTests.cs | 114 +++++++++++++++--- 5 files changed, 180 insertions(+), 30 deletions(-) diff --git a/.github/docs/specs/terminal.md b/.github/docs/specs/terminal.md index d75b1aa..559ca86 100644 --- a/.github/docs/specs/terminal.md +++ b/.github/docs/specs/terminal.md @@ -44,7 +44,7 @@ PtyTerminal.Start(PtyStartInfo startInfo, PtyTerminalOptions options) // option | `Completion` | `Task` that completes **after every output handler invocation has finished** (drain-then-exit, node-pty onData/onExit ordering). Faults with the handler exception when a handler throws (child is killed first). | | `Pause()` / `Resume()` | Stops/resumes output delivery. Paused delivery parks the pump; the strict-handoff producer stops reading, the OS PTY buffer fills, and the child blocks on write. No managed buffering beyond one in-flight chunk; no drops. | | `WriteInputAsync` / `SendEof` / `Resize` / `Kill` / `Kill(PtySignal)` | Pass-through to the owned session; callable concurrently with output delivery. | -| `ProcessId` / `Size` / `HasExited` | Session state. | +| `ProcessId` / `Size` / `HasExited` / `ExitStatus` | Session state. `ExitStatus` is readable independently of `Completion` — useful when the pump faulted before draining. | | `DisposeAsync` | Stops the pump, kills the child if running, releases the PTY. | Design decisions (WHY): @@ -141,6 +141,9 @@ Embedders that need a custom transport (stdio framing, SignalR, …) use `PtyTer ## Lessons Learned - Disposing a `PtySession` immediately after canceling an active `ReadOutputAsync` races the session's internal buffer teardown (`ManualResetValueTaskSourceCore` double-signal). The facade cancels, awaits its pump to unwind, and only then disposes the session. +- A client that stops reading wedges `SendAsync` via transport backpressure, and a wedged send parks the pump beyond the reach of `Kill()`. Bridge sends must be cancelable by a bridge-lifetime teardown token (canceled on client close, protocol violation, and caller cancellation), with the exit status read from the session when the drain faulted. Verified red/green with a bounded in-memory pipe simulating a full TCP send buffer. +- `Task.WaitAsync(CancellationToken)` cannot assert "does not hang" in tests: its timeout also surfaces as `OperationCanceledException`, masking a hang. Use `WaitAsync(TimeSpan)` so a hang fails as `TimeoutException`. +- Close frames are send-type operations under the WebSocket one-outstanding-send rule; a `PolicyViolation` close issued from the receive loop must take the same send lock as the output pump (bounded by the close timeout). - Bridge teardown must disable flow control and release any pause **inside the same lock** that sets pauses (`BridgeFlowControl.Disable`), or an in-flight send can re-pause after the teardown resume and park the drain forever. - A WebSocket allows one outstanding send; the bridge serializes the output pump and the exit message with a semaphore. Test clients need the same discipline (ACK sends vs test-driven input sends). - ConPTY line submission needs CR — sending `\n` echoes but never completes a `cmd.exe` `set /p` read. Cross-platform clients should send `\r` (xterm.js `onData` already does) and tests must not assert on LF-terminated input. diff --git a/.github/workflows/benchmark.yaml b/.github/workflows/benchmark.yaml index c19c550..4e58c46 100644 --- a/.github/workflows/benchmark.yaml +++ b/.github/workflows/benchmark.yaml @@ -38,7 +38,7 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - - uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0 + - uses: guitarrapc/actions/.github/actions/setup-dotnet@9122a18b0dea07585a242b9eb6ed7886f8587c49 # v1.0.1 with: dotnet-version: 10.0.x - name: dotnet build diff --git a/src/MiniPty.Terminal/PtyTerminal.cs b/src/MiniPty.Terminal/PtyTerminal.cs index 894aaf7..51e11c6 100644 --- a/src/MiniPty.Terminal/PtyTerminal.cs +++ b/src/MiniPty.Terminal/PtyTerminal.cs @@ -61,6 +61,13 @@ public static PtyTerminal Start(PtyStartInfo startInfo, PtyTerminalOptions optio /// Gets a value indicating whether the child process has exited. public bool HasExited => _session.HasExited; + /// + /// Gets the exit status when is ; otherwise + /// . Unlike this does not wait for output + /// delivery, so it is readable even when the pump faulted before draining. + /// + public PtyExitStatus? ExitStatus => _session.ExitStatus; + /// /// Gets a task that completes with the child's after the child has /// exited and every output handler invocation has completed. Faults with the handler @@ -192,7 +199,11 @@ private async Task PumpAsync() { // Gate before delivery: while paused the in-flight chunk stays valid (the pump // does not advance the enumerator) and the producer stops reading the transport. - await WaitWhilePausedAsync(cancellationToken).ConfigureAwait(false); + // The inline null check keeps the unpaused per-chunk path free of async-method + // overhead; the await runs only while actually paused. + if (Volatile.Read(ref _pauseGate) is not null) + await WaitWhilePausedAsync(cancellationToken).ConfigureAwait(false); + await _output(chunk.Data, cancellationToken).ConfigureAwait(false); } diff --git a/src/MiniPty.Terminal/PtyWebSocketBridge.cs b/src/MiniPty.Terminal/PtyWebSocketBridge.cs index d799d4d..f66ba67 100644 --- a/src/MiniPty.Terminal/PtyWebSocketBridge.cs +++ b/src/MiniPty.Terminal/PtyWebSocketBridge.cs @@ -61,6 +61,10 @@ private sealed class BridgeSession private readonly BridgeFlowControl _flowControl; private readonly SemaphoreSlim _sendLock = new(1, 1); private volatile bool _discardOutput; + // Bridge-lifetime token used for output sends instead of the pump token: a client that + // stops reading can wedge SendAsync via transport backpressure, and the pump token is not + // canceled until disposal. Canceling this token in every teardown path unwedges the send. + private CancellationToken _sendCancellation; public BridgeSession(WebSocket webSocket, PtyBridgeOptions options) { @@ -71,14 +75,15 @@ public BridgeSession(WebSocket webSocket, PtyBridgeOptions options) public async Task RunAsync(PtyStartInfo startInfo, CancellationToken cancellationToken) { - using var receiveCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + using var teardownCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + _sendCancellation = teardownCts.Token; var terminal = PtyTerminal.Start(startInfo, new PtyTerminalOptions { Output = SendOutputAsync }); _flowControl.Attach(terminal); Task? receiveTask = null; try { - receiveTask = ReceiveLoopAsync(terminal, receiveCts.Token); + receiveTask = ReceiveLoopAsync(terminal, teardownCts.Token); var first = await Task.WhenAny(terminal.Completion, receiveTask).ConfigureAwait(false); if (first == terminal.Completion) @@ -93,12 +98,26 @@ public async Task RunAsync(PtyStartInfo startInfo, CancellationTo } // The receive side ended first: client close, protocol violation, socket fault, or - // cancellation. Stop sending, release any flow-control pause so the post-kill drain - // can complete, and kill the child. + // cancellation. Stop sending (canceling the teardown token unwedges a SendAsync + // blocked on a client that stopped reading), release any flow-control pause so the + // post-kill drain can complete, and kill the child. _discardOutput = true; _flowControl.Disable(); + teardownCts.Cancel(); terminal.Kill(); - var killedStatus = await terminal.Completion.ConfigureAwait(false); + + PtyExitStatus killedStatus; + try + { + killedStatus = await terminal.Completion.ConfigureAwait(false); + } + catch + { + // The pump faulted because teardown canceled its in-flight send (or the socket + // died mid-send). The child was killed either way; read the status directly. + killedStatus = await WaitForKilledStatusAsync(terminal, _options.CloseTimeout).ConfigureAwait(false); + } + // Rethrows InvalidDataException (protocol violation) or OperationCanceledException. await receiveTask.ConfigureAwait(false); await RespondToClientCloseAsync().ConfigureAwait(false); @@ -108,7 +127,7 @@ public async Task RunAsync(PtyStartInfo startInfo, CancellationTo { _discardOutput = true; _flowControl.Disable(); - receiveCts.Cancel(); + teardownCts.Cancel(); if (receiveTask is not null) { try @@ -130,13 +149,16 @@ private async ValueTask SendOutputAsync(ReadOnlyMemory data, CancellationT if (_discardOutput) return; - await _sendLock.WaitAsync(cancellationToken).ConfigureAwait(false); + // The bridge-lifetime token, not the pump token: it cancels on every teardown path, so + // a send wedged by a client that stopped reading cannot park the pump forever. + var sendCancellation = _sendCancellation; + await _sendLock.WaitAsync(sendCancellation).ConfigureAwait(false); try { if (_discardOutput) return; - await _webSocket.SendAsync(data, WebSocketMessageType.Binary, endOfMessage: true, cancellationToken).ConfigureAwait(false); + await _webSocket.SendAsync(data, WebSocketMessageType.Binary, endOfMessage: true, sendCancellation).ConfigureAwait(false); } finally { @@ -146,6 +168,25 @@ private async ValueTask SendOutputAsync(ReadOnlyMemory data, CancellationT _flowControl.OnSent(data.Length); } + /// + /// Reads the exit status directly from the session after a kill whose drain faulted. + /// SIGKILL / TerminateProcess complete quickly; the bounded poll covers scheduler lag. + /// + private static async Task WaitForKilledStatusAsync(PtyTerminal terminal, TimeSpan timeout) + { + var deadline = Environment.TickCount64 + (long)timeout.TotalMilliseconds; + while (true) + { + if (terminal.ExitStatus is { } status) + return status; + + if (Environment.TickCount64 >= deadline) + throw new TimeoutException("The child process did not report exit after being killed."); + + await Task.Delay(10).ConfigureAwait(false); + } + } + private async Task ReceiveLoopAsync(PtyTerminal terminal, CancellationToken cancellationToken) { var buffer = new byte[_options.ReceiveBufferSize]; @@ -250,11 +291,22 @@ private async Task ViolateProtocolAsync() { try { + // CloseOutputAsync is a send-type operation and this runs on the receive loop while + // the output pump may be mid-SendAsync; a WebSocket allows one outstanding send, so + // the close frame must take the same send lock (bounded by the close timeout). using var closeCts = new CancellationTokenSource(_options.CloseTimeout); - await _webSocket.CloseOutputAsync( - WebSocketCloseStatus.PolicyViolation, - "malformed control message", - closeCts.Token).ConfigureAwait(false); + await _sendLock.WaitAsync(closeCts.Token).ConfigureAwait(false); + try + { + await _webSocket.CloseOutputAsync( + WebSocketCloseStatus.PolicyViolation, + "malformed control message", + closeCts.Token).ConfigureAwait(false); + } + finally + { + _sendLock.Release(); + } } catch { diff --git a/tests/MiniPty.Tests/PtyWebSocketBridgeTests.cs b/tests/MiniPty.Tests/PtyWebSocketBridgeTests.cs index fb7e1c6..13df0a1 100644 --- a/tests/MiniPty.Tests/PtyWebSocketBridgeTests.cs +++ b/tests/MiniPty.Tests/PtyWebSocketBridgeTests.cs @@ -212,6 +212,73 @@ public async Task BridgeUnknownControlTypeIsIgnored() await Assert.That(client.BinaryText).Contains("GOT:STILL_ALIVE"); } + [Test] + public async Task BridgeOversizeControlMessageClosesWithPolicyViolation() + { + var (serverSocket, clientSocket) = CreateSocketPair(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + var options = new PtyBridgeOptions { MaxControlMessageSize = 64 }; + + var bridgeTask = PtyWebSocketBridge.RunAsync(StdinBlockingChild(), serverSocket, options, cts.Token); + + // A valid-JSON control message over the cap must still violate: the bound is on size, not syntax. + var oversize = "{\"type\":\"ack\",\"bytes\":1,\"padding\":\"" + new string('x', 128) + "\"}"; + await clientSocket.SendAsync( + Encoding.UTF8.GetBytes(oversize), + WebSocketMessageType.Text, + endOfMessage: true, + cts.Token); + + await Assert.ThrowsAsync(async () => await bridgeTask.WaitAsync(cts.Token)); + } + + [Test] + public async Task BridgeFragmentedControlMessageIsReassembled() + { + var (serverSocket, clientSocket) = CreateSocketPair(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + + var bridgeTask = PtyWebSocketBridge.RunAsync(EchoInputThenExitChild(), serverSocket, cancellationToken: cts.Token); + var client = new BridgeTestClient(clientSocket); + var clientTask = client.RunToCloseAsync(ackEverything: true, cts.Token); + + // Split one resize message across two text frames; a broken accumulation path would parse + // half a JSON document and fail the session with PolicyViolation. + var resize = Encoding.UTF8.GetBytes("{\"type\":\"resize\",\"cols\":100,\"rows\":40}"); + await clientSocket.SendAsync(resize.AsMemory(0, 10), WebSocketMessageType.Text, endOfMessage: false, cts.Token); + await clientSocket.SendAsync(resize.AsMemory(10), WebSocketMessageType.Text, endOfMessage: true, cts.Token); + await client.SendBinaryAsync("FRAGMENTS_OK" + Enter, cts.Token); + + await clientTask; + var status = await bridgeTask.WaitAsync(cts.Token); + + await Assert.That(status.ExitCode).IsEqualTo(0); + await Assert.That(client.BinaryText).Contains("GOT:FRAGMENTS_OK"); + } + + [Test] + public async Task BridgeCancellationUnwedgesSendBlockedByStalledClient() + { + // Server→client direction bounded like a full TCP send buffer; the client never reads. + var (serverSocket, _) = CreateSocketPair(serverToClientCapacityChunks: 2); + using var cts = new CancellationTokenSource(); + // Disable watermark pausing so the pump drives straight into the wedged send. + var options = new PtyBridgeOptions { HighWatermark = long.MaxValue / 2, LowWatermark = 1 }; + + var bridgeTask = PtyWebSocketBridge.RunAsync(BulkOutputChild(), serverSocket, options, cts.Token); + + // Give the pump time to fill the pipe and wedge inside SendAsync, then cancel. + await Task.Delay(500); + await Assert.That(bridgeTask.IsCompleted).IsFalse(); + await cts.CancelAsync(); + + // Without teardown-token sends this hangs forever. WaitAsync(TimeSpan) is deliberate: a + // hang surfaces as TimeoutException and fails the assertion, unlike WaitAsync(token) + // whose timeout would also throw an OperationCanceledException and mask the hang. + await Assert.ThrowsAsync(async () => await bridgeTask.WaitAsync(TimeSpan.FromSeconds(15))); + await Assert.That(bridgeTask.IsCompleted).IsTrue(); + } + [Test] public async Task BridgeOptionValidationRejectsInvertedWatermarks() { @@ -329,9 +396,9 @@ public async Task RunToCloseAsync(bool ackEverything, CancellationToken cancella } } - private static (WebSocket Server, WebSocket Client) CreateSocketPair() + private static (WebSocket Server, WebSocket Client) CreateSocketPair(int serverToClientCapacityChunks = 0) { - var (serverStream, clientStream) = InMemoryDuplexStream.CreatePair(); + var (serverStream, clientStream) = InMemoryDuplexStream.CreatePair(serverToClientCapacityChunks); var server = WebSocket.CreateFromStream(serverStream, new WebSocketCreationOptions { IsServer = true }); var client = WebSocket.CreateFromStream(clientStream, new WebSocketCreationOptions { IsServer = false }); return (server, client); @@ -353,10 +420,14 @@ private InMemoryDuplexStream(BytePipe readPipe, BytePipe writePipe) _writePipe = writePipe; } - public static (InMemoryDuplexStream A, InMemoryDuplexStream B) CreatePair() + /// + /// When positive, bounds the A→B direction to that many written chunks so writes block like + /// a full TCP send buffer (for wedged-client tests). 0 keeps the direction unbounded. + /// + public static (InMemoryDuplexStream A, InMemoryDuplexStream B) CreatePair(int aToBCapacityChunks = 0) { - var aToB = new BytePipe(); - var bToA = new BytePipe(); + var aToB = new BytePipe(aToBCapacityChunks); + var bToA = new BytePipe(0); return (new InMemoryDuplexStream(bToA, aToB), new InMemoryDuplexStream(aToB, bToA)); } @@ -382,17 +453,11 @@ public override Task ReadAsync(byte[] buffer, int offset, int count, Cancel public override void Write(byte[] buffer, int offset, int count) => _writePipe.Write(buffer.AsSpan(offset, count)); - public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) - { - _writePipe.Write(buffer.Span); - return ValueTask.CompletedTask; - } + public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) => + _writePipe.WriteAsync(buffer, cancellationToken); - public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) - { - _writePipe.Write(buffer.AsSpan(offset, count)); - return Task.CompletedTask; - } + public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) => + _writePipe.WriteAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); protected override void Dispose(bool disposing) { @@ -407,10 +472,25 @@ private sealed class BytePipe private readonly Lock _lock = new(); private readonly Queue _chunks = new(); private readonly SemaphoreSlim _signal = new(0); + private readonly SemaphoreSlim? _space; private byte[]? _current; private int _currentOffset; private bool _completed; + public BytePipe(int capacityChunks) => + _space = capacityChunks > 0 ? new SemaphoreSlim(capacityChunks) : null; + + public async ValueTask WriteAsync(ReadOnlyMemory data, CancellationToken cancellationToken) + { + if (data.IsEmpty) + return; + + if (_space is not null) + await _space.WaitAsync(cancellationToken).ConfigureAwait(false); + + Write(data.Span); + } + public void Write(ReadOnlySpan data) { if (data.IsEmpty) @@ -455,7 +535,11 @@ public async ValueTask ReadAsync(Memory destination, CancellationToke _current.AsSpan(_currentOffset, count).CopyTo(destination.Span); _currentOffset += count; if (_currentOffset == _current.Length) + { _current = null; + _space?.Release(); + } + return count; } From eef271511edb7b533de39921a800bff34a74f09b Mon Sep 17 00:00:00 2001 From: Ikiru Yoshizaki <3856350+guitarrapc@users.noreply.github.com> Date: Mon, 13 Jul 2026 19:38:49 +0900 Subject: [PATCH 03/13] fix: WebSocket cancellation/teardown problems --- src/MiniPty.Terminal/PtyWebSocketBridge.cs | 57 +++++++++++++++++-- tests/MiniPty.Tests/PtyTerminalTests.cs | 50 +++++++++------- .../MiniPty.Tests/PtyWebSocketBridgeTests.cs | 19 ++++++- 3 files changed, 100 insertions(+), 26 deletions(-) diff --git a/src/MiniPty.Terminal/PtyWebSocketBridge.cs b/src/MiniPty.Terminal/PtyWebSocketBridge.cs index f66ba67..8e4332a 100644 --- a/src/MiniPty.Terminal/PtyWebSocketBridge.cs +++ b/src/MiniPty.Terminal/PtyWebSocketBridge.cs @@ -84,7 +84,8 @@ public async Task RunAsync(PtyStartInfo startInfo, CancellationTo try { receiveTask = ReceiveLoopAsync(terminal, teardownCts.Token); - var first = await Task.WhenAny(terminal.Completion, receiveTask).ConfigureAwait(false); + var cancellationTask = WaitForCancellationAsync(cancellationToken); + var first = await Task.WhenAny(terminal.Completion, receiveTask, cancellationTask).ConfigureAwait(false); if (first == terminal.Completion) { @@ -104,12 +105,18 @@ public async Task RunAsync(PtyStartInfo startInfo, CancellationTo _discardOutput = true; _flowControl.Disable(); teardownCts.Cancel(); + AbortWebSocket(); terminal.Kill(); PtyExitStatus killedStatus; try { - killedStatus = await terminal.Completion.ConfigureAwait(false); + killedStatus = await terminal.Completion.WaitAsync(_options.CloseTimeout, CancellationToken.None).ConfigureAwait(false); + } + catch (TimeoutException) + { + AbortWebSocket(); + killedStatus = await WaitForKilledStatusAsync(terminal, _options.CloseTimeout).ConfigureAwait(false); } catch { @@ -119,8 +126,16 @@ public async Task RunAsync(PtyStartInfo startInfo, CancellationTo } // Rethrows InvalidDataException (protocol violation) or OperationCanceledException. - await receiveTask.ConfigureAwait(false); + try + { + await receiveTask.WaitAsync(_options.CloseTimeout, CancellationToken.None).ConfigureAwait(false); + } + catch (TimeoutException) + { + AbortWebSocket(); + } await RespondToClientCloseAsync().ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); return killedStatus; } finally @@ -128,11 +143,16 @@ public async Task RunAsync(PtyStartInfo startInfo, CancellationTo _discardOutput = true; _flowControl.Disable(); teardownCts.Cancel(); + AbortWebSocket(); if (receiveTask is not null) { try { - await receiveTask.ConfigureAwait(false); + await receiveTask.WaitAsync(_options.CloseTimeout, CancellationToken.None).ConfigureAwait(false); + } + catch (TimeoutException) + { + AbortWebSocket(); } catch { @@ -380,12 +400,39 @@ private async Task AwaitReceiveShutdownAsync(Task receiveTask) } catch (TimeoutException) { - _webSocket.Abort(); + AbortWebSocket(); } catch { // Close-handshake races are benign once the exit path owns the outcome. } } + + /// + /// Breaks a send or receive blocked on transport backpressure. ManagedWebSocket may not + /// observe cancellation while waiting on the underlying stream. + /// + private void AbortWebSocket() + { + try + { + if (_webSocket.State is WebSocketState.Open or WebSocketState.CloseReceived or WebSocketState.CloseSent) + _webSocket.Abort(); + } + catch + { + // Best-effort abort; teardown continues via canceled tokens and disposal. + } + } + + private static Task WaitForCancellationAsync(CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) + return Task.CompletedTask; + + var completed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + cancellationToken.Register(static state => ((TaskCompletionSource)state!).TrySetResult(), completed); + return completed.Task; + } } } diff --git a/tests/MiniPty.Tests/PtyTerminalTests.cs b/tests/MiniPty.Tests/PtyTerminalTests.cs index 4888935..165ba7e 100644 --- a/tests/MiniPty.Tests/PtyTerminalTests.cs +++ b/tests/MiniPty.Tests/PtyTerminalTests.cs @@ -60,36 +60,46 @@ public async Task PtyTerminalSlowHandlerDoesNotDropOutput() public async Task PtyTerminalPauseStopsDeliveryAndResumeRecovers() { var delivered = 0L; - await using var terminal = PtyTerminal.Start(BulkOutputChild(), new PtyTerminalOptions + var pausedOnFirstChunk = 0; + var pauseEngaged = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + PtyTerminal terminal = null!; + terminal = PtyTerminal.Start(BulkOutputChild(), new PtyTerminalOptions { Output = (data, _) => { Interlocked.Add(ref delivered, data.Length); + // Pause on the first delivered chunk so fast runners cannot drain the full bulk + // stream before flow control engages. + if (Interlocked.CompareExchange(ref pausedOnFirstChunk, 1, 0) == 0) + { + terminal.Pause(); + pauseEngaged.TrySetResult(); + } + return ValueTask.CompletedTask; }, }); - // Wait for first delivery so the pump is known to be running. - using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); - while (Interlocked.Read(ref delivered) == 0) + await using (terminal) { - startCts.Token.ThrowIfCancellationRequested(); - await Task.Delay(10, startCts.Token); - } - - terminal.Pause(); - // Allow at most the single in-flight chunk to land, then verify no further delivery. - await Task.Delay(200); - var afterPause = Interlocked.Read(ref delivered); - await Task.Delay(300); - await Assert.That(Interlocked.Read(ref delivered)).IsEqualTo(afterPause); - - terminal.Resume(); - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); - var status = await terminal.Completion.WaitAsync(cts.Token); + using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + await pauseEngaged.Task.WaitAsync(startCts.Token); + + // Allow at most the single in-flight chunk to land, then verify no further delivery. + await Task.Delay(200); + var afterPause = Interlocked.Read(ref delivered); + await Task.Delay(300); + await Assert.That(Interlocked.Read(ref delivered)).IsEqualTo(afterPause); + await Assert.That(afterPause).IsGreaterThan(0); + await Assert.That(terminal.HasExited).IsFalse(); + + terminal.Resume(); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + var status = await terminal.Completion.WaitAsync(cts.Token); - await Assert.That(status.ExitCode).IsEqualTo(0); - await Assert.That(Interlocked.Read(ref delivered)).IsGreaterThan(afterPause); + await Assert.That(status.ExitCode).IsEqualTo(0); + await Assert.That(Interlocked.Read(ref delivered)).IsGreaterThan(afterPause); + } } [Test] diff --git a/tests/MiniPty.Tests/PtyWebSocketBridgeTests.cs b/tests/MiniPty.Tests/PtyWebSocketBridgeTests.cs index 13df0a1..a2feced 100644 --- a/tests/MiniPty.Tests/PtyWebSocketBridgeTests.cs +++ b/tests/MiniPty.Tests/PtyWebSocketBridgeTests.cs @@ -462,7 +462,11 @@ public override Task WriteAsync(byte[] buffer, int offset, int count, Cancellati protected override void Dispose(bool disposing) { if (disposing) + { _writePipe.Complete(); + _readPipe.Complete(); + } + base.Dispose(disposing); } } @@ -473,12 +477,16 @@ private sealed class BytePipe private readonly Queue _chunks = new(); private readonly SemaphoreSlim _signal = new(0); private readonly SemaphoreSlim? _space; + private readonly int _capacityChunks; private byte[]? _current; private int _currentOffset; private bool _completed; - public BytePipe(int capacityChunks) => + public BytePipe(int capacityChunks) + { + _capacityChunks = capacityChunks; _space = capacityChunks > 0 ? new SemaphoreSlim(capacityChunks) : null; + } public async ValueTask WriteAsync(ReadOnlyMemory data, CancellationToken cancellationToken) { @@ -486,7 +494,11 @@ public async ValueTask WriteAsync(ReadOnlyMemory data, CancellationToken c return; if (_space is not null) + { await _space.WaitAsync(cancellationToken).ConfigureAwait(false); + if (_completed) + return; + } Write(data.Span); } @@ -514,6 +526,11 @@ public void Complete() } _signal.Release(); + if (_space is not null) + { + for (var i = 0; i < _capacityChunks; i++) + _space.Release(); + } } public async ValueTask ReadAsync(Memory destination, CancellationToken cancellationToken) From 95e43ebf3b71791bb2062d06e2c8b96a5640158b Mon Sep 17 00:00:00 2001 From: Ikiru Yoshizaki <3856350+guitarrapc@users.noreply.github.com> Date: Mon, 13 Jul 2026 19:53:45 +0900 Subject: [PATCH 04/13] fix: review --- .github/docs/specs/terminal.md | 2 +- .../Internal/BridgeFlowControl.cs | 14 ++- src/MiniPty.Terminal/PtyWebSocketBridge.cs | 32 +++--- src/MiniPty/PtySignal.cs | 4 +- .../MiniPty.Tests/PtyWebSocketBridgeTests.cs | 105 ++++++++++++++++-- 5 files changed, 123 insertions(+), 34 deletions(-) diff --git a/.github/docs/specs/terminal.md b/.github/docs/specs/terminal.md index 559ca86..f82ce88 100644 --- a/.github/docs/specs/terminal.md +++ b/.github/docs/specs/terminal.md @@ -141,7 +141,7 @@ Embedders that need a custom transport (stdio framing, SignalR, …) use `PtyTer ## Lessons Learned - Disposing a `PtySession` immediately after canceling an active `ReadOutputAsync` races the session's internal buffer teardown (`ManualResetValueTaskSourceCore` double-signal). The facade cancels, awaits its pump to unwind, and only then disposes the session. -- A client that stops reading wedges `SendAsync` via transport backpressure, and a wedged send parks the pump beyond the reach of `Kill()`. Bridge sends must be cancelable by a bridge-lifetime teardown token (canceled on client close, protocol violation, and caller cancellation), with the exit status read from the session when the drain faulted. Verified red/green with a bounded in-memory pipe simulating a full TCP send buffer. +- A client that stops reading wedges `SendAsync` via transport backpressure, and a wedged send parks the pump beyond the reach of `Kill()`. Bridge sends use a bridge-lifetime teardown token, but some `ManagedWebSocket` stream waits do not observe cancellation promptly; caller cancellation must also abort the WebSocket transport. Client-initiated close remains graceful and must not be aborted eagerly. Verified red/green with a bounded in-memory pipe simulating a full TCP send buffer. - `Task.WaitAsync(CancellationToken)` cannot assert "does not hang" in tests: its timeout also surfaces as `OperationCanceledException`, masking a hang. Use `WaitAsync(TimeSpan)` so a hang fails as `TimeoutException`. - Close frames are send-type operations under the WebSocket one-outstanding-send rule; a `PolicyViolation` close issued from the receive loop must take the same send lock as the output pump (bounded by the close timeout). - Bridge teardown must disable flow control and release any pause **inside the same lock** that sets pauses (`BridgeFlowControl.Disable`), or an in-flight send can re-pause after the teardown resume and park the drain forever. diff --git a/src/MiniPty.Terminal/Internal/BridgeFlowControl.cs b/src/MiniPty.Terminal/Internal/BridgeFlowControl.cs index 8bfc881..719c65d 100644 --- a/src/MiniPty.Terminal/Internal/BridgeFlowControl.cs +++ b/src/MiniPty.Terminal/Internal/BridgeFlowControl.cs @@ -23,8 +23,18 @@ public BridgeFlowControl(long highWatermark, long lowWatermark) _lowWatermark = lowWatermark; } - /// Set once right after the terminal starts; the output handler needs flow control first. - public void Attach(PtyTerminal terminal) => _terminal = terminal; + /// + /// Attaches the terminal and applies a pause that was requested before attachment. + /// + public void Attach(PtyTerminal terminal) + { + lock (_lock) + { + _terminal = terminal; + if (_paused && !_disabled) + terminal.Pause(); + } + } public void OnSent(int bytes) { diff --git a/src/MiniPty.Terminal/PtyWebSocketBridge.cs b/src/MiniPty.Terminal/PtyWebSocketBridge.cs index 8e4332a..5745c55 100644 --- a/src/MiniPty.Terminal/PtyWebSocketBridge.cs +++ b/src/MiniPty.Terminal/PtyWebSocketBridge.cs @@ -84,10 +84,16 @@ public async Task RunAsync(PtyStartInfo startInfo, CancellationTo try { receiveTask = ReceiveLoopAsync(terminal, teardownCts.Token); - var cancellationTask = WaitForCancellationAsync(cancellationToken); - var first = await Task.WhenAny(terminal.Completion, receiveTask, cancellationTask).ConfigureAwait(false); - - if (first == terminal.Completion) + var cancellationSignal = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var cancellationRegistration = cancellationToken.UnsafeRegister( + static state => ((TaskCompletionSource)state!).TrySetResult(), + cancellationSignal); + var first = await Task.WhenAny( + terminal.Completion, + receiveTask, + cancellationSignal.Task).ConfigureAwait(false); + + if (first == terminal.Completion && !cancellationToken.IsCancellationRequested) { // Child exited (or the output pump faulted; the await rethrows in that case). // Completion resolves only after the final output frame was sent, so the exit @@ -105,8 +111,12 @@ public async Task RunAsync(PtyStartInfo startInfo, CancellationTo _discardOutput = true; _flowControl.Disable(); teardownCts.Cancel(); - AbortWebSocket(); + var canceledByCaller = cancellationToken.IsCancellationRequested; + if (canceledByCaller) + AbortWebSocket(); terminal.Kill(); + if (canceledByCaller) + cancellationToken.ThrowIfCancellationRequested(); PtyExitStatus killedStatus; try @@ -135,7 +145,6 @@ public async Task RunAsync(PtyStartInfo startInfo, CancellationTo AbortWebSocket(); } await RespondToClientCloseAsync().ConfigureAwait(false); - cancellationToken.ThrowIfCancellationRequested(); return killedStatus; } finally @@ -143,7 +152,6 @@ public async Task RunAsync(PtyStartInfo startInfo, CancellationTo _discardOutput = true; _flowControl.Disable(); teardownCts.Cancel(); - AbortWebSocket(); if (receiveTask is not null) { try @@ -424,15 +432,5 @@ private void AbortWebSocket() // Best-effort abort; teardown continues via canceled tokens and disposal. } } - - private static Task WaitForCancellationAsync(CancellationToken cancellationToken) - { - if (cancellationToken.IsCancellationRequested) - return Task.CompletedTask; - - var completed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - cancellationToken.Register(static state => ((TaskCompletionSource)state!).TrySetResult(), completed); - return completed.Task; - } } } diff --git a/src/MiniPty/PtySignal.cs b/src/MiniPty/PtySignal.cs index 45211eb..2ab2cbd 100644 --- a/src/MiniPty/PtySignal.cs +++ b/src/MiniPty/PtySignal.cs @@ -6,8 +6,8 @@ namespace MiniPty; /// /// Members are logical identifiers, not raw OS numbers: on Unix each member is mapped to the /// platform's native signal number internally (SIGUSR1/SIGUSR2 numbering differs between Linux -/// and macOS/FreeBSD). On Windows the signal is advisory; any value terminates the child, -/// matching node-pty semantics. +/// and macOS/FreeBSD). Only defined members are accepted. On Windows the selected signal is +/// advisory; every defined member terminates the child, matching node-pty semantics. /// public enum PtySignal { diff --git a/tests/MiniPty.Tests/PtyWebSocketBridgeTests.cs b/tests/MiniPty.Tests/PtyWebSocketBridgeTests.cs index a2feced..fede621 100644 --- a/tests/MiniPty.Tests/PtyWebSocketBridgeTests.cs +++ b/tests/MiniPty.Tests/PtyWebSocketBridgeTests.cs @@ -3,6 +3,7 @@ using System.Text; using System.Text.Json; using MiniPty.Terminal; +using MiniPty.Terminal.Internal; namespace MiniPty.Tests; @@ -118,6 +119,37 @@ public async Task BridgeFlowControlPausesWithoutAcksAndResumesOnAck() await Assert.That(client.BinaryByteCount).IsGreaterThan(stalledAt); } + [Test] + public async Task BridgeFlowControlAppliesPauseRequestedBeforeAttach() + { + var delivered = 0L; + var flowControl = new BridgeFlowControl(highWatermark: 1, lowWatermark: 0); + flowControl.OnSent(1); + + await using var terminal = PtyTerminal.Start(EchoInputThenExitChild(), new PtyTerminalOptions + { + Output = (data, _) => + { + Interlocked.Add(ref delivered, data.Length); + return ValueTask.CompletedTask; + }, + }); + flowControl.Attach(terminal); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + await terminal.WriteInputAsync("FLOW_CONTROL" + Enter, cancellationToken: cts.Token); + await Task.Delay(200, cts.Token); + + await Assert.That(Interlocked.Read(ref delivered)).IsEqualTo(0); + await Assert.That(terminal.Completion.IsCompleted).IsFalse(); + + flowControl.OnAcknowledged(1); + var status = await terminal.Completion.WaitAsync(cts.Token); + + await Assert.That(status.ExitCode).IsEqualTo(0); + await Assert.That(Interlocked.Read(ref delivered)).IsGreaterThan(0); + } + [Test] public async Task BridgeSendsExitMessageAfterFinalOutputThenClosesNormally() { @@ -152,7 +184,9 @@ public async Task BridgeClientCloseKillsChildAndReturnsStatus() await clientSocket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "bye", cts.Token); var status = await bridgeTask.WaitAsync(cts.Token); + var close = await clientSocket.ReceiveAsync(new byte[128].AsMemory(), cts.Token); + await Assert.That(close.MessageType).IsEqualTo(WebSocketMessageType.Close); if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { await Assert.That(status.Signal).IsEqualTo(9); @@ -289,6 +323,33 @@ await Assert.ThrowsAsync( async () => await PtyWebSocketBridge.RunAsync(StdinBlockingChild(), serverSocket, options)); } + [Test] + public async Task BoundedBytePipeCompletionUnblocksWaitingWriter() + { + var pipe = new BytePipe(capacityChunks: 1); + await pipe.WriteAsync(new byte[] { 1 }, CancellationToken.None); + var blockedWrite = pipe.WriteAsync(new byte[] { 2 }, CancellationToken.None).AsTask(); + + await Task.Delay(50); + await Assert.That(blockedWrite.IsCompleted).IsFalse(); + + pipe.Complete(); + await blockedWrite.WaitAsync(TimeSpan.FromSeconds(1)); + } + + [Test] + public async Task BoundedBytePipeCompletionWithAvailableCapacityDoesNotOverRelease() + { + var pipe = new BytePipe(capacityChunks: 2); + await pipe.WriteAsync(new byte[] { 1 }, CancellationToken.None); + + pipe.Complete(); + + var buffer = new byte[1]; + await Assert.That(await pipe.ReadAsync(buffer, CancellationToken.None)).IsEqualTo(1); + await Assert.That(await pipe.ReadAsync(buffer, CancellationToken.None)).IsEqualTo(0); + } + // ---- test doubles and helpers ---- /// @@ -477,16 +538,13 @@ private sealed class BytePipe private readonly Queue _chunks = new(); private readonly SemaphoreSlim _signal = new(0); private readonly SemaphoreSlim? _space; - private readonly int _capacityChunks; + private readonly CancellationTokenSource _completionCts = new(); private byte[]? _current; private int _currentOffset; private bool _completed; - public BytePipe(int capacityChunks) - { - _capacityChunks = capacityChunks; + public BytePipe(int capacityChunks) => _space = capacityChunks > 0 ? new SemaphoreSlim(capacityChunks) : null; - } public async ValueTask WriteAsync(ReadOnlyMemory data, CancellationToken cancellationToken) { @@ -495,9 +553,33 @@ public async ValueTask WriteAsync(ReadOnlyMemory data, CancellationToken c if (_space is not null) { - await _space.WaitAsync(cancellationToken).ConfigureAwait(false); - if (_completed) + using var waitCts = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, + _completionCts.Token); + try + { + await _space.WaitAsync(waitCts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when ( + _completionCts.IsCancellationRequested && + !cancellationToken.IsCancellationRequested) + { return; + } + + lock (_lock) + { + if (_completed) + { + _space.Release(); + return; + } + + _chunks.Enqueue(data.ToArray()); + } + + _signal.Release(); + return; } Write(data.Span); @@ -522,15 +604,14 @@ public void Complete() { lock (_lock) { + if (_completed) + return; + _completed = true; } + _completionCts.Cancel(); _signal.Release(); - if (_space is not null) - { - for (var i = 0; i < _capacityChunks; i++) - _space.Release(); - } } public async ValueTask ReadAsync(Memory destination, CancellationToken cancellationToken) From beb5ea708bb9764482947ecc31a4002c89815c95 Mon Sep 17 00:00:00 2001 From: Ikiru Yoshizaki <3856350+guitarrapc@users.noreply.github.com> Date: Mon, 13 Jul 2026 20:04:54 +0900 Subject: [PATCH 05/13] fix: test failure with unexpected exception handling --- src/MiniPty.Terminal/PtyWebSocketBridge.cs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/MiniPty.Terminal/PtyWebSocketBridge.cs b/src/MiniPty.Terminal/PtyWebSocketBridge.cs index 5745c55..3f25a4f 100644 --- a/src/MiniPty.Terminal/PtyWebSocketBridge.cs +++ b/src/MiniPty.Terminal/PtyWebSocketBridge.cs @@ -168,7 +168,15 @@ public async Task RunAsync(PtyStartInfo startInfo, CancellationTo } } - await terminal.DisposeAsync().ConfigureAwait(false); + try + { + await terminal.DisposeAsync().ConfigureAwait(false); + } + catch when (cancellationToken.IsCancellationRequested) + { + // Caller cancellation is the public outcome. A canceled output drain can + // surface a secondary teardown fault after the terminal was killed. + } } } From 0ba75a241fa1f9557778b8118bd04622d67c08db Mon Sep 17 00:00:00 2001 From: Ikiru Yoshizaki <3856350+guitarrapc@users.noreply.github.com> Date: Mon, 13 Jul 2026 20:19:00 +0900 Subject: [PATCH 06/13] fix: windows test failure. Clients and tests must drain any Binary or Text frames ordered before the server's close response --- .github/docs/specs/terminal.md | 1 + .../MiniPty.Tests/PtyWebSocketBridgeTests.cs | 19 ++++++++++++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/.github/docs/specs/terminal.md b/.github/docs/specs/terminal.md index f82ce88..fe616f1 100644 --- a/.github/docs/specs/terminal.md +++ b/.github/docs/specs/terminal.md @@ -144,6 +144,7 @@ Embedders that need a custom transport (stdio framing, SignalR, …) use `PtyTer - A client that stops reading wedges `SendAsync` via transport backpressure, and a wedged send parks the pump beyond the reach of `Kill()`. Bridge sends use a bridge-lifetime teardown token, but some `ManagedWebSocket` stream waits do not observe cancellation promptly; caller cancellation must also abort the WebSocket transport. Client-initiated close remains graceful and must not be aborted eagerly. Verified red/green with a bounded in-memory pipe simulating a full TCP send buffer. - `Task.WaitAsync(CancellationToken)` cannot assert "does not hang" in tests: its timeout also surfaces as `OperationCanceledException`, masking a hang. Use `WaitAsync(TimeSpan)` so a hang fails as `TimeoutException`. - Close frames are send-type operations under the WebSocket one-outstanding-send rule; a `PolicyViolation` close issued from the receive loop must take the same send lock as the output pump (bounded by the close timeout). +- A client-initiated close does not discard server frames already in transit. Clients and tests must drain any Binary or Text frames ordered before the server's close response; the first receive after `CloseOutputAsync` is not guaranteed to be Close, especially when PTY startup output races disconnect. - Bridge teardown must disable flow control and release any pause **inside the same lock** that sets pauses (`BridgeFlowControl.Disable`), or an in-flight send can re-pause after the teardown resume and park the drain forever. - A WebSocket allows one outstanding send; the bridge serializes the output pump and the exit message with a semaphore. Test clients need the same discipline (ACK sends vs test-driven input sends). - ConPTY line submission needs CR — sending `\n` echoes but never completes a `cmd.exe` `set /p` read. Cross-platform clients should send `\r` (xterm.js `onData` already does) and tests must not assert on LF-terminated input. diff --git a/tests/MiniPty.Tests/PtyWebSocketBridgeTests.cs b/tests/MiniPty.Tests/PtyWebSocketBridgeTests.cs index fede621..a734be3 100644 --- a/tests/MiniPty.Tests/PtyWebSocketBridgeTests.cs +++ b/tests/MiniPty.Tests/PtyWebSocketBridgeTests.cs @@ -183,10 +183,14 @@ public async Task BridgeClientCloseKillsChildAndReturnsStatus() await Task.Delay(300, cts.Token); await clientSocket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "bye", cts.Token); + // PTY output that the server sent before observing our close remains ordered ahead of its + // close response. Drain those frames instead of assuming the next frame is the response. + var closeTask = ReceiveCloseAsync(clientSocket, cts.Token); var status = await bridgeTask.WaitAsync(cts.Token); - var close = await clientSocket.ReceiveAsync(new byte[128].AsMemory(), cts.Token); + var close = await closeTask; await Assert.That(close.MessageType).IsEqualTo(WebSocketMessageType.Close); + await Assert.That(clientSocket.CloseStatus).IsEqualTo(WebSocketCloseStatus.NormalClosure); if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { await Assert.That(status.Signal).IsEqualTo(9); @@ -198,6 +202,19 @@ public async Task BridgeClientCloseKillsChildAndReturnsStatus() } } + private static async Task ReceiveCloseAsync( + WebSocket socket, + CancellationToken cancellationToken) + { + var buffer = new byte[1024]; + while (true) + { + var result = await socket.ReceiveAsync(buffer.AsMemory(), cancellationToken); + if (result.MessageType == WebSocketMessageType.Close) + return result; + } + } + [Test] public async Task BridgeMalformedControlMessageClosesWithPolicyViolation() { From 8589f5ddeca7a7c539b9f9a6f220d44750d49648 Mon Sep 17 00:00:00 2001 From: Ikiru Yoshizaki <3856350+guitarrapc@users.noreply.github.com> Date: Mon, 13 Jul 2026 20:24:53 +0900 Subject: [PATCH 07/13] fix: test failure on ubuntu --- .github/docs/specs/lifecycle.md | 1 + tests/MiniPty.Tests/PtyTests.cs | 17 +++++++++++------ 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/.github/docs/specs/lifecycle.md b/.github/docs/specs/lifecycle.md index 00fa916..308d501 100644 --- a/.github/docs/specs/lifecycle.md +++ b/.github/docs/specs/lifecycle.md @@ -171,6 +171,7 @@ MiniPty does not fall back to pipe redirect when PTY creation fails. - **Unix PTY master fds cannot be half-closed.** Closing the master ends both read and write, so `SendEof()` writes EOT instead. - **Canonical EOT is not kernel EOF on Unix.** One EOT on a non-empty line buffer delivers buffered bytes but does not end input; a submitted line or a second EOT may be needed. - **Output drain after child exit needs bounded waits on one-shot paths.** `OutputDrainGrace` and `OutputReaderCloseTimeout` prevent hung readers without dropping ordinary slow flushes. Persistent `ReadOutputAsync` does not use those timeouts. +- **Kill/drain tests need an observed-output synchronization point.** A fixed startup delay can expire before a loaded CI worker schedules the reader or completes `fork`/`exec`, allowing `SIGKILL` to precede the child's first write; that empty stream does not diagnose drain behavior. Observe readiness from the active output enumeration, then kill and continue that same enumeration to EOF. - **Cancel semantics differ by use case.** Waiting cancellation does not kill; one-shot completion defaults to killing when canceled. - **Do not queue `CompleteAsync` behind active output reads.** Fail fast with `InvalidOperationException` so read, wait, and completion cannot deadlock inside hidden session queues. - **Defer ConPTY transport close on one-shot transport pumps.** `CompleteAsync` and `PtyCapture.RunAsync` must wait for child exit with `closeTransportOnExit: false` while the transport pump is still reading; closing on exit truncates bulk stdout before `OutputDrainGrace` / `AwaitPumpAsync` can drain it. diff --git a/tests/MiniPty.Tests/PtyTests.cs b/tests/MiniPty.Tests/PtyTests.cs index b240075..61f29ad 100644 --- a/tests/MiniPty.Tests/PtyTests.cs +++ b/tests/MiniPty.Tests/PtyTests.cs @@ -1302,16 +1302,21 @@ public async Task PtyKillDuringReadOutputAsyncDrainsOutput() ["/c", $"echo {marker} & ping -n 20 127.0.0.1 >nul"]) : UnixShell($"printf '{marker}'; sleep 20")); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); using var output = new MemoryStream(); - var readTask = Task.Run(async () => + await using var reader = session.ReadOutputAsync(cts.Token).GetAsyncEnumerator(); + + // Synchronize on actual child output instead of a fixed delay. Under loaded CI the reader + // and fork/exec may not run within an arbitrary timeout, allowing Kill to win before printf. + while (output.GetBuffer().AsSpan(0, checked((int)output.Length)).IndexOf("lifecycle-kill-drain"u8) < 0) { - await foreach (var chunk in session.ReadOutputAsync()) - await output.WriteAsync(chunk.Data); - }); + await Assert.That(await reader.MoveNextAsync()).IsTrue(); + output.Write(reader.Current.Data.Span); + } - await Task.Delay(300); session.Kill(); - await readTask; + while (await reader.MoveNextAsync()) + output.Write(reader.Current.Data.Span); var text = Encoding.UTF8.GetString(output.GetBuffer().AsSpan(0, checked((int)output.Length))); await Assert.That(text).Contains(marker); From 8a882c556d208f71efb6b239bc51fdf668948e16 Mon Sep 17 00:00:00 2001 From: Ikiru Yoshizaki <3856350+guitarrapc@users.noreply.github.com> Date: Mon, 13 Jul 2026 20:32:30 +0900 Subject: [PATCH 08/13] fix: test wait by sleep but reader not wait on arm64, use stdin instead --- .github/docs/specs/lifecycle.md | 2 +- tests/MiniPty.Tests/PtyTests.cs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/docs/specs/lifecycle.md b/.github/docs/specs/lifecycle.md index 308d501..dfac205 100644 --- a/.github/docs/specs/lifecycle.md +++ b/.github/docs/specs/lifecycle.md @@ -171,7 +171,7 @@ MiniPty does not fall back to pipe redirect when PTY creation fails. - **Unix PTY master fds cannot be half-closed.** Closing the master ends both read and write, so `SendEof()` writes EOT instead. - **Canonical EOT is not kernel EOF on Unix.** One EOT on a non-empty line buffer delivers buffered bytes but does not end input; a submitted line or a second EOT may be needed. - **Output drain after child exit needs bounded waits on one-shot paths.** `OutputDrainGrace` and `OutputReaderCloseTimeout` prevent hung readers without dropping ordinary slow flushes. Persistent `ReadOutputAsync` does not use those timeouts. -- **Kill/drain tests need an observed-output synchronization point.** A fixed startup delay can expire before a loaded CI worker schedules the reader or completes `fork`/`exec`, allowing `SIGKILL` to precede the child's first write; that empty stream does not diagnose drain behavior. Observe readiness from the active output enumeration, then kill and continue that same enumeration to EOF. +- **Kill/drain tests need an observed-output synchronization point and a child that cannot time out naturally.** Fixed delays can expire before a loaded CI worker schedules the reader or completes `fork`/`exec`; conversely, a time-limited child can exit before a starved reader resumes, so the test never reaches `Kill()`. Have the child emit readiness and then block on stdin, observe readiness from the active output enumeration, then kill and continue that same enumeration to EOF. - **Cancel semantics differ by use case.** Waiting cancellation does not kill; one-shot completion defaults to killing when canceled. - **Do not queue `CompleteAsync` behind active output reads.** Fail fast with `InvalidOperationException` so read, wait, and completion cannot deadlock inside hidden session queues. - **Defer ConPTY transport close on one-shot transport pumps.** `CompleteAsync` and `PtyCapture.RunAsync` must wait for child exit with `closeTransportOnExit: false` while the transport pump is still reading; closing on exit truncates bulk stdout before `OutputDrainGrace` / `AwaitPumpAsync` can drain it. diff --git a/tests/MiniPty.Tests/PtyTests.cs b/tests/MiniPty.Tests/PtyTests.cs index 61f29ad..6bb8a17 100644 --- a/tests/MiniPty.Tests/PtyTests.cs +++ b/tests/MiniPty.Tests/PtyTests.cs @@ -1299,8 +1299,8 @@ public async Task PtyKillDuringReadOutputAsyncDrainsOutput() await using var session = Pty.Start(RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? Spawn(Environment.GetEnvironmentVariable("ComSpec") ?? @"C:\Windows\System32\cmd.exe", - ["/c", $"echo {marker} & ping -n 20 127.0.0.1 >nul"]) - : UnixShell($"printf '{marker}'; sleep 20")); + ["/c", $"echo {marker} & set /p DUMMY="]) + : UnixShell($"printf '{marker}'; IFS= read -r _")); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); using var output = new MemoryStream(); From 127ec2fadbfe3c18a629b6403b79e0ad554baa1e Mon Sep 17 00:00:00 2001 From: Ikiru Yoshizaki <3856350+guitarrapc@users.noreply.github.com> Date: Mon, 13 Jul 2026 21:10:24 +0900 Subject: [PATCH 09/13] fox] Ubuntu test failure --- .github/docs/specs/terminal.md | 1 + .../MiniPty.Tests/PtyWebSocketBridgeTests.cs | 27 ++++++++++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/.github/docs/specs/terminal.md b/.github/docs/specs/terminal.md index fe616f1..129520d 100644 --- a/.github/docs/specs/terminal.md +++ b/.github/docs/specs/terminal.md @@ -147,6 +147,7 @@ Embedders that need a custom transport (stdio framing, SignalR, …) use `PtyTer - A client-initiated close does not discard server frames already in transit. Clients and tests must drain any Binary or Text frames ordered before the server's close response; the first receive after `CloseOutputAsync` is not guaranteed to be Close, especially when PTY startup output races disconnect. - Bridge teardown must disable flow control and release any pause **inside the same lock** that sets pauses (`BridgeFlowControl.Disable`), or an in-flight send can re-pause after the teardown resume and park the drain forever. - A WebSocket allows one outstanding send; the bridge serializes the output pump and the exit message with a semaphore. Test clients need the same discipline (ACK sends vs test-driven input sends). +- Protocol tests that make the child exit immediately after input must first observe an explicit child-ready Binary frame. Otherwise PTY attachment, output-pump startup, control handling, input, and fast exit are all raced together, so missing output does not isolate the protocol behavior under test. An observed WebSocket frame is the synchronization point; a fixed delay is not. - ConPTY line submission needs CR — sending `\n` echoes but never completes a `cmd.exe` `set /p` read. Cross-platform clients should send `\r` (xterm.js `onData` already does) and tests must not assert on LF-terminated input. - `cmd.exe /c "set /p LINE= & echo %LINE%"` prints the literal `%LINE%` because expansion happens at parse time; interactive-input children in tests need `/v:on` with `!LINE!`. - TerminateProcess-based `Kill` is fire-and-forget: `HasExited` can lag `Completion` by a scheduler tick; tests must poll, not assert immediately. diff --git a/tests/MiniPty.Tests/PtyWebSocketBridgeTests.cs b/tests/MiniPty.Tests/PtyWebSocketBridgeTests.cs index a734be3..587d464 100644 --- a/tests/MiniPty.Tests/PtyWebSocketBridgeTests.cs +++ b/tests/MiniPty.Tests/PtyWebSocketBridgeTests.cs @@ -289,10 +289,14 @@ public async Task BridgeFragmentedControlMessageIsReassembled() var (serverSocket, clientSocket) = CreateSocketPair(); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); - var bridgeTask = PtyWebSocketBridge.RunAsync(EchoInputThenExitChild(), serverSocket, cancellationToken: cts.Token); + var bridgeTask = PtyWebSocketBridge.RunAsync(ReadyThenEchoInputChild(), serverSocket, cancellationToken: cts.Token); var client = new BridgeTestClient(clientSocket); var clientTask = client.RunToCloseAsync(ackEverything: true, cts.Token); + // Ensure the child is attached and the output pump is live before making this a fast-exit + // session. The behavior under test is fragmented control-message reassembly, not startup. + await client.WaitForBinaryTextAsync("BRIDGE_READY", cts.Token); + // Split one resize message across two text frames; a broken accumulation path would parse // half a JSON document and fail the session with PolicyViolation. var resize = Encoding.UTF8.GetBytes("{\"type\":\"resize\",\"cols\":100,\"rows\":40}"); @@ -378,6 +382,7 @@ private sealed class BridgeTestClient private readonly WebSocket _socket; private readonly StringBuilder _binaryText = new(); private readonly SemaphoreSlim _sendLock = new(1, 1); + private readonly SemaphoreSlim _binarySignal = new(0); private long _binaryByteCount; private int _messageCount; private volatile bool _ackEverything; @@ -403,6 +408,20 @@ public string BinaryText public void AckEverythingFromNowOn() => _ackEverything = true; + public async Task WaitForBinaryTextAsync(string expected, CancellationToken cancellationToken) + { + while (true) + { + lock (_binaryText) + { + if (_binaryText.ToString().Contains(expected, StringComparison.Ordinal)) + return; + } + + await _binarySignal.WaitAsync(cancellationToken); + } + } + public Task SendAckAsync(long bytes, CancellationToken cancellationToken) => SendAsync(Encoding.UTF8.GetBytes($"{{\"type\":\"ack\",\"bytes\":{bytes}}}"), WebSocketMessageType.Text, cancellationToken); @@ -458,6 +477,7 @@ public async Task RunToCloseAsync(bool ackEverything, CancellationToken cancella { _binaryText.Append(Encoding.UTF8.GetString(payload)); } + _binarySignal.Release(); if (_ackEverything && payload.Length > 0) await SendAckAsync(payload.Length, cancellationToken); @@ -682,6 +702,11 @@ private static PtyStartInfo EchoInputThenExitChild() => ? Spawn(WindowsComSpec(), ["/v:on", "/c", "set /p LINE= & echo GOT:!LINE!"]) : Spawn("sh", ["-c", "IFS= read -r line; printf 'GOT:%s\\n' \"$line\""]); + private static PtyStartInfo ReadyThenEchoInputChild() => + RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? Spawn(WindowsComSpec(), ["/v:on", "/c", "echo BRIDGE_READY & set /p LINE= & echo GOT:!LINE!"]) + : Spawn("sh", ["-c", "printf 'BRIDGE_READY\\n'; IFS= read -r line; printf 'GOT:%s\\n' \"$line\""]); + private static PtyStartInfo StdinBlockingChild() => RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? Spawn(WindowsComSpec(), ["/c", "set /p DUMMY="]) From 1d491c29ddc54deabf19b178728df93bdbba73fd Mon Sep 17 00:00:00 2001 From: Ikiru Yoshizaki <3856350+guitarrapc@users.noreply.github.com> Date: Mon, 13 Jul 2026 21:22:55 +0900 Subject: [PATCH 10/13] fix: slow host may not receive/sent as expected. --- tests/MiniPty.Tests/PtyWebSocketBridgeTests.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/MiniPty.Tests/PtyWebSocketBridgeTests.cs b/tests/MiniPty.Tests/PtyWebSocketBridgeTests.cs index 587d464..c589dd8 100644 --- a/tests/MiniPty.Tests/PtyWebSocketBridgeTests.cs +++ b/tests/MiniPty.Tests/PtyWebSocketBridgeTests.cs @@ -108,9 +108,11 @@ public async Task BridgeFlowControlPausesWithoutAcksAndResumesOnAck() await Task.Delay(300, cts.Token); await Assert.That(client.BinaryByteCount).IsEqualTo(stalledAt); - // Ack everything received so far: delivery must resume and the session must complete. + // Grant all available credit rather than acknowledging the client-side count. The server + // can have completed a SendAsync whose frame the client has not yet scheduled to receive; + // using the observed count can leave that in-flight delta above the low watermark. client.AckEverythingFromNowOn(); - await client.SendAckAsync(stalledAt, cts.Token); + await client.SendAckAsync(long.MaxValue, cts.Token); await clientTask; var status = await bridgeTask.WaitAsync(cts.Token); From e47b077b5cf42e647c764c9b92e99998c4d7ad32 Mon Sep 17 00:00:00 2001 From: Ikiru Yoshizaki <3856350+guitarrapc@users.noreply.github.com> Date: Mon, 13 Jul 2026 21:49:39 +0900 Subject: [PATCH 11/13] fix: review --- .github/workflows/build.yaml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 6be877b..2fe262f 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -80,7 +80,11 @@ jobs: test ! -f "$out/libminipty_unix.a" test -f "$out/minipty_spawn_helper" fi - "$exe" + if [[ "$sample" == WebTerminal ]]; then + "$exe" --smoke + else + "$exe" + fi done build-unix-native: From 10407460a72d0c8ba2ee8d109c0c423c9c94f9cc Mon Sep 17 00:00:00 2001 From: Ikiru Yoshizaki <3856350+guitarrapc@users.noreply.github.com> Date: Mon, 13 Jul 2026 22:55:13 +0900 Subject: [PATCH 12/13] fix: review --- .github/docs/specs/terminal.md | 3 ++- .../Internal/BridgeFlowControl.cs | 8 ++++-- src/MiniPty.Terminal/PtyBridgeOptions.cs | 5 ++-- .../MiniPty.Tests/PtyWebSocketBridgeTests.cs | 26 +++++++++++++++++++ 4 files changed, 37 insertions(+), 5 deletions(-) diff --git a/.github/docs/specs/terminal.md b/.github/docs/specs/terminal.md index 129520d..870c150 100644 --- a/.github/docs/specs/terminal.md +++ b/.github/docs/specs/terminal.md @@ -84,7 +84,7 @@ Server-side watermark over unacknowledged bytes; ACK credit was chosen over paus | Option | Default | Role | |---|---|---| | `HighWatermark` | 384 KiB | pause output delivery at/above this unACKed count (keep < 500 KB per xterm.js buffer guidance) | -| `LowWatermark` | 128 KiB (2^17) | resume at/below; matches the recommended client ACK chunk | +| `LowWatermark` | 128 KiB (2^17) | resume at/below; `0` requires all outstanding bytes to be ACKed; default matches the recommended client ACK chunk | | `ReceiveBufferSize` | 16 KiB | client input/control receive buffer | | `MaxControlMessageSize` | 4 KiB | control JSON bound | | `SendExitMessage` | `true` | emit the `exit` control message | @@ -148,6 +148,7 @@ Embedders that need a custom transport (stdio framing, SignalR, …) use `PtyTer - Bridge teardown must disable flow control and release any pause **inside the same lock** that sets pauses (`BridgeFlowControl.Disable`), or an in-flight send can re-pause after the teardown resume and park the drain forever. - A WebSocket allows one outstanding send; the bridge serializes the output pump and the exit message with a semaphore. Test clients need the same discipline (ACK sends vs test-driven input sends). - Protocol tests that make the child exit immediately after input must first observe an explicit child-ready Binary frame. Otherwise PTY attachment, output-pump startup, control handling, input, and fast exit are all raced together, so missing output does not isolate the protocol behavior under test. An observed WebSocket frame is the synchronization point; a fixed delay is not. +- Unacknowledged-byte accounting saturates at `long.MaxValue`. Although normal watermarks pause output long before overflow, `HighWatermark = long.MaxValue` is valid and a wrapped negative count would bypass flow control in an extremely long-lived session. - ConPTY line submission needs CR — sending `\n` echoes but never completes a `cmd.exe` `set /p` read. Cross-platform clients should send `\r` (xterm.js `onData` already does) and tests must not assert on LF-terminated input. - `cmd.exe /c "set /p LINE= & echo %LINE%"` prints the literal `%LINE%` because expansion happens at parse time; interactive-input children in tests need `/v:on` with `!LINE!`. - TerminateProcess-based `Kill` is fire-and-forget: `HasExited` can lag `Completion` by a scheduler tick; tests must poll, not assert immediately. diff --git a/src/MiniPty.Terminal/Internal/BridgeFlowControl.cs b/src/MiniPty.Terminal/Internal/BridgeFlowControl.cs index 719c65d..4931fce 100644 --- a/src/MiniPty.Terminal/Internal/BridgeFlowControl.cs +++ b/src/MiniPty.Terminal/Internal/BridgeFlowControl.cs @@ -40,10 +40,10 @@ public void OnSent(int bytes) { lock (_lock) { - if (_disabled) + if (_disabled || bytes <= 0) return; - _unacknowledged += bytes; + _unacknowledged = AddSaturating(_unacknowledged, bytes); if (!_paused && _unacknowledged >= _highWatermark) { _paused = true; @@ -52,6 +52,10 @@ public void OnSent(int bytes) } } + /// Adds a positive frame length without allowing the credit counter to wrap negative. + internal static long AddSaturating(long current, int bytes) => + current > long.MaxValue - bytes ? long.MaxValue : current + bytes; + /// /// Permanently stops flow control and releases any pause, used during bridge teardown so the /// post-kill drain cannot park on a pause the client will never acknowledge. Running inside diff --git a/src/MiniPty.Terminal/PtyBridgeOptions.cs b/src/MiniPty.Terminal/PtyBridgeOptions.cs index 14d7cad..c692888 100644 --- a/src/MiniPty.Terminal/PtyBridgeOptions.cs +++ b/src/MiniPty.Terminal/PtyBridgeOptions.cs @@ -20,7 +20,8 @@ public sealed record PtyBridgeOptions /// /// Gets the unacknowledged-byte count at or below which output delivery resumes. - /// Default is 128 KiB (2^17), matching the recommended client ACK chunk size. + /// Zero resumes only after every outstanding byte is acknowledged. Default is 128 KiB (2^17), + /// matching the recommended client ACK chunk size. /// public long LowWatermark { get; init; } = 131_072; @@ -49,7 +50,7 @@ public sealed record PtyBridgeOptions internal void Validate() { - ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(LowWatermark, 0); + ArgumentOutOfRangeException.ThrowIfLessThan(LowWatermark, 0); ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(HighWatermark, LowWatermark); ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(ReceiveBufferSize, 0); ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(MaxControlMessageSize, 0); diff --git a/tests/MiniPty.Tests/PtyWebSocketBridgeTests.cs b/tests/MiniPty.Tests/PtyWebSocketBridgeTests.cs index c589dd8..737ffe5 100644 --- a/tests/MiniPty.Tests/PtyWebSocketBridgeTests.cs +++ b/tests/MiniPty.Tests/PtyWebSocketBridgeTests.cs @@ -152,6 +152,14 @@ public async Task BridgeFlowControlAppliesPauseRequestedBeforeAttach() await Assert.That(Interlocked.Read(ref delivered)).IsGreaterThan(0); } + [Test] + public async Task BridgeFlowControlUnacknowledgedBytesSaturateAtLongMaxValue() + { + var saturated = BridgeFlowControl.AddSaturating(long.MaxValue - 1, 2); + + await Assert.That(saturated).IsEqualTo(long.MaxValue); + } + [Test] public async Task BridgeSendsExitMessageAfterFinalOutputThenClosesNormally() { @@ -346,6 +354,24 @@ await Assert.ThrowsAsync( async () => await PtyWebSocketBridge.RunAsync(StdinBlockingChild(), serverSocket, options)); } + [Test] + public void BridgeOptionValidationAllowsZeroLowWatermark() + { + var options = new PtyBridgeOptions { HighWatermark = 1, LowWatermark = 0 }; + + options.Validate(); + } + + [Test] + public async Task BridgeOptionValidationRejectsNegativeLowWatermark() + { + var (serverSocket, _) = CreateSocketPair(); + var options = new PtyBridgeOptions { HighWatermark = 1, LowWatermark = -1 }; + + await Assert.ThrowsAsync( + async () => await PtyWebSocketBridge.RunAsync(StdinBlockingChild(), serverSocket, options)); + } + [Test] public async Task BoundedBytePipeCompletionUnblocksWaitingWriter() { From 7880be85db452f5ee299497542bb6ac7216d1a80 Mon Sep 17 00:00:00 2001 From: Ikiru Yoshizaki <3856350+guitarrapc@users.noreply.github.com> Date: Mon, 13 Jul 2026 23:09:24 +0900 Subject: [PATCH 13/13] fix: review --- tests/MiniPty.Tests/PtyTerminalTests.cs | 29 ++++++++++++------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/tests/MiniPty.Tests/PtyTerminalTests.cs b/tests/MiniPty.Tests/PtyTerminalTests.cs index 165ba7e..9bc3326 100644 --- a/tests/MiniPty.Tests/PtyTerminalTests.cs +++ b/tests/MiniPty.Tests/PtyTerminalTests.cs @@ -11,23 +11,24 @@ public async Task PtyTerminalDeliversOutputBeforeCompletion() { var buffer = new MemoryStream(); var handlerCompletedBeforeExit = true; - PtyTerminal terminal = null!; + Task? completionTask = null; - terminal = PtyTerminal.Start(EchoMarkerChild("TERMINAL_MARKER"), new PtyTerminalOptions + var terminal = PtyTerminal.Start(EchoMarkerChild("TERMINAL_MARKER"), new PtyTerminalOptions { Output = (data, _) => { - if (terminal.Completion.IsCompleted) + if (completionTask?.IsCompleted == true) handlerCompletedBeforeExit = false; buffer.Write(data.Span); return ValueTask.CompletedTask; }, }); + completionTask = terminal.Completion; await using (terminal) { using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); - var status = await terminal.Completion.WaitAsync(cts.Token); + var status = await completionTask.WaitAsync(cts.Token); await Assert.That(status.ExitCode).IsEqualTo(0); await Assert.That(Encoding.UTF8.GetString(buffer.ToArray())).Contains("TERMINAL_MARKER"); @@ -61,29 +62,27 @@ public async Task PtyTerminalPauseStopsDeliveryAndResumeRecovers() { var delivered = 0L; var pausedOnFirstChunk = 0; - var pauseEngaged = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - PtyTerminal terminal = null!; - terminal = PtyTerminal.Start(BulkOutputChild(), new PtyTerminalOptions + var firstHandlerEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var firstHandlerRelease = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var terminal = PtyTerminal.Start(BulkOutputChild(), new PtyTerminalOptions { - Output = (data, _) => + Output = async (data, ct) => { Interlocked.Add(ref delivered, data.Length); - // Pause on the first delivered chunk so fast runners cannot drain the full bulk - // stream before flow control engages. if (Interlocked.CompareExchange(ref pausedOnFirstChunk, 1, 0) == 0) { - terminal.Pause(); - pauseEngaged.TrySetResult(); + firstHandlerEntered.TrySetResult(); + await firstHandlerRelease.Task.WaitAsync(ct); } - - return ValueTask.CompletedTask; }, }); await using (terminal) { using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); - await pauseEngaged.Task.WaitAsync(startCts.Token); + await firstHandlerEntered.Task.WaitAsync(startCts.Token); + terminal.Pause(); + firstHandlerRelease.TrySetResult(); // Allow at most the single in-flight chunk to land, then verify no further delivery. await Task.Delay(200);