diff --git a/.github/workflows/check-process-daemon.yml b/.github/workflows/check-process-daemon.yml new file mode 100644 index 000000000000..45473dc3086f --- /dev/null +++ b/.github/workflows/check-process-daemon.yml @@ -0,0 +1,221 @@ +name: Process daemon + +on: + pull_request: + paths: + - packages/process-daemon/** + - .github/workflows/check-process-daemon.yml + push: + branches: [dev] + paths: + - packages/process-daemon/** + - .github/workflows/check-process-daemon.yml + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +defaults: + run: + working-directory: packages/process-daemon + +jobs: + miri: + name: Miri (callback ownership) + runs-on: ubuntu-22.04 + timeout-minutes: 15 + env: + CARGO_TARGET_DIR: target/miri + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Install Miri + run: rustup toolchain install nightly-2026-09-02 --profile minimal --component miri --component rust-src + - run: cargo +nightly-2026-09-02 miri setup + - name: Check Stacked Borrows + run: | + cargo +nightly-2026-09-02 miri test --locked --manifest-path tests/ghostty-effects/Cargo.toml + cargo +nightly-2026-09-02 miri test --locked --manifest-path tests/transport-retained/Cargo.toml + - name: Check Tree Borrows + env: + MIRIFLAGS: -Zmiri-tree-borrows + run: | + cargo +nightly-2026-09-02 miri test --locked --manifest-path tests/ghostty-effects/Cargo.toml + cargo +nightly-2026-09-02 miri test --locked --manifest-path tests/transport-retained/Cargo.toml + + test: + name: Core (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + os: [ubuntu-22.04, macos-15] + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: mlugg/setup-zig@8d6198c65fb0feaa111df26e6b467fea8345e46f # v2.0.5 + with: + version: 0.16.0 + - name: Install Rust + run: rustup toolchain install 1.90.0 --profile minimal --component rustfmt --component clippy + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + with: + key: core-${{ matrix.os }}-zig-0.16.0 + workspaces: packages/process-daemon -> target + cache-workspace-crates: true + - run: cargo fmt --check + - run: cargo clippy --locked --all-targets --all-features -- -D warnings + - name: Test Linux + if: runner.os == 'Linux' + run: cargo test --locked + # Match the release workflow's macOS playground exclusion. + - name: Test macOS + if: runner.os == 'macOS' + run: cargo test --locked --lib --test service --test runtime --test ownership --test rows --test ghostty-effects + + windows: + name: Windows (${{ matrix.arch }}) + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: + - arch: x64 + runner: windows-2025 + target: x86_64-pc-windows-msvc + machine: "8664" + - arch: arm64 + runner: windows-11-arm + target: aarch64-pc-windows-msvc + machine: "aa64" + defaults: + run: + shell: pwsh + working-directory: packages/process-daemon + env: + CARGO_TERM_COLOR: always + CARGO_BUILD_TARGET: ${{ matrix.target }} + RUSTUP_TOOLCHAIN: 1.90.0-x86_64-pc-windows-msvc + RUST_BACKTRACE: "1" + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Enable long dependency paths + run: git config --global core.longpaths true + - uses: mlugg/setup-zig@8d6198c65fb0feaa111df26e6b467fea8345e46f # v2.0.5 + if: matrix.arch == 'x64' + with: + version: 0.16.0 + cache-key: ${{ matrix.target }} + # Zig 0.16 crashes when building Ghostty on the native ARM64 host. + # Use x64-hosted compilers on this ARM64 runner, but execute ARM64 tests natively. + - name: Install x64 Zig for ARM64 cross-compilation + if: matrix.arch == 'arm64' + run: | + $archive = Join-Path $env:RUNNER_TEMP 'zig-x64.zip' + Invoke-WebRequest 'https://ziglang.org/download/0.16.0/zig-x86_64-windows-0.16.0.zip' -OutFile $archive + if ((Get-FileHash $archive -Algorithm SHA256).Hash -ne '68659eb5f1e4eb1437a722f1dd889c5a322c9954607f5edcf337bc3684a75a7e') { throw 'Zig archive checksum mismatch' } + Expand-Archive $archive -DestinationPath $env:RUNNER_TEMP + Join-Path $env:RUNNER_TEMP 'zig-x86_64-windows-0.16.0' >> $env:GITHUB_PATH + - name: Fix Zig 0.16 ARM64 function-pointer alignment + if: matrix.arch == 'arm64' + run: | + # ARM64 function pointers require 4-byte alignment, unlike *anyopaque. + # Zig 0.16's Windows stack-trace helper omits the required alignment assertion. + $file = Join-Path $env:RUNNER_TEMP 'zig-x86_64-windows-0.16.0/lib/std/debug/SelfInfo/Windows.zig' + $source = [System.IO.File]::ReadAllText($file) + $old = '@ptrCast(try si.loadNtdllProc(@tagName(proc)))' + if (!$source.Contains($old)) { throw 'Zig alignment workaround no longer matches; re-evaluate it' } + [System.IO.File]::WriteAllText($file, $source.Replace($old, '@ptrCast(@alignCast(try si.loadNtdllProc(@tagName(proc))))')) + - name: Install Rust toolchain and target + run: rustup toolchain install $env:RUSTUP_TOOLCHAIN --force-non-host --profile minimal --component rustfmt --component clippy --target $env:CARGO_BUILD_TARGET + - name: Verify runner and compiler architecture + env: + EXPECTED_ARCH: ${{ matrix.arch }} + run: | + $arch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() + Write-Output "Windows architecture: $arch" + if ($arch -ine $env:EXPECTED_ARCH) { throw "Expected native $env:EXPECTED_ARCH runner, got $arch" } + $compiler = rustc -vV + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $compiler + if ($compiler -notcontains 'host: x86_64-pc-windows-msvc') { throw 'Unexpected Rust compiler host' } + Write-Output "Executable target: $env:CARGO_BUILD_TARGET" + zig version + # No cache-on-failure: a timed-out run once saved a partial cache that broke re-runs. + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + with: + key: windows-${{ matrix.target }}-zig-0.16.0 + workspaces: packages/process-daemon -> target + # The native Ghostty build is now an output of the workspace crate. + cache-workspace-crates: true + - name: Check formatting + run: cargo fmt --check + - name: Check Clippy + run: cargo clippy --locked --all-targets --all-features -- -D warnings + - name: Build executable + id: build + run: cargo build --locked + - name: Run tests + id: tests + timeout-minutes: 10 + run: cargo test --locked --all-targets --all-features + - name: Run standalone library tests + env: + EXPECTED_MACHINE: ${{ matrix.machine }} + run: | + $artifacts = cargo test --locked --lib --no-run --message-format=json | ForEach-Object { $_ | ConvertFrom-Json } + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $binary = ($artifacts | Where-Object { $_.reason -eq 'compiler-artifact' -and $_.profile.test -and $_.executable }).executable + # Cargo adds native library paths to PATH, which can hide accidental DLL dependencies. + # Copy only the executable so neither Cargo nor adjacent build artifacts can supply them. + $directory = Join-Path $env:RUNNER_TEMP 'pty-standalone-tests' + New-Item -ItemType Directory -Force $directory | Out-Null + $standalone = Join-Path $directory 'opencode-pty-tests.exe' + Copy-Item $binary $standalone + $bytes = [System.IO.File]::ReadAllBytes($standalone) + $header = [BitConverter]::ToInt32($bytes, 0x3c) + $machine = [BitConverter]::ToUInt16($bytes, $header + 4) + if ($machine -ne [Convert]::ToUInt16($env:EXPECTED_MACHINE, 16)) { throw 'Test executable architecture does not match the runner' } + & $standalone + - name: Diagnose failed library tests + if: failure() && steps.tests.outcome == 'failure' + timeout-minutes: 10 + run: cargo test --locked --lib --all-features -- --test-threads=1 --nocapture + - name: Diagnose failed runtime tests + if: failure() && steps.tests.outcome == 'failure' + timeout-minutes: 3 + run: cargo test --locked --test runtime -- --test-threads=1 --nocapture + - name: Diagnose failed daemon tests + if: failure() && steps.tests.outcome == 'failure' + timeout-minutes: 3 + run: cargo test --locked --test windows-daemon -- --test-threads=1 --nocapture + - name: Smoke test executable + if: ${{ !cancelled() && steps.build.outcome == 'success' }} + run: | + $metadata = cargo metadata --locked --no-deps --format-version 1 | ConvertFrom-Json + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $version = ($metadata.packages | Where-Object name -eq 'opencode-pty').version + $binary = Join-Path $metadata.target_directory "$env:CARGO_BUILD_TARGET/debug/opencode-pty.exe" + $output = & $binary --version + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + Write-Output $output + if ($output -ne "opencode-pty $version (protocol 7)") { throw "Unexpected executable version: $output" } + - name: Document current coverage + if: always() + run: | + @" + ## Windows ${{ matrix.arch }} + + Target: $env:CARGO_BUILD_TARGET + + This job builds the executable and runs all enabled tests natively, including libghostty parser, protocol, direct TerminalService ConPTY runtime, named-pipe transport, and daemon tests. + The runtime suite spawns real Rust console children and checks input/output, Unicode, cwd/environment/argv/PATH, OS resize, snapshots, replay, terminal replies, and independent terminals. + Daemon tests cover private atomic registration, ownership/handoff, stale registration/locking, partial requests, blocked subscribers, and live ConPTY operations over real named pipes. + The older service, ownership, playground, and rows integration suites remain Unix-only. + Basic tests do not establish exhaustive lifecycle coverage or Windows interactive CLI parity. + No packages or releases are published. + "@ >> $env:GITHUB_STEP_SUMMARY diff --git a/packages/process-daemon/.gitignore b/packages/process-daemon/.gitignore new file mode 100644 index 000000000000..d8be92a1f650 --- /dev/null +++ b/packages/process-daemon/.gitignore @@ -0,0 +1,2 @@ +/target +/tests/*/target diff --git a/packages/process-daemon/AGENTS.md b/packages/process-daemon/AGENTS.md new file mode 100644 index 000000000000..0fa0900e8974 --- /dev/null +++ b/packages/process-daemon/AGENTS.md @@ -0,0 +1,39 @@ +# opencode-pty + +## Objective + +Build a persistent Rust PTY service. The service owns multiple terminal child +processes, continuously drains output, parses it with libghostty-vt, retains a +bounded raw replay tail, and exposes thread-safe terminal operations. + +## Current Scope + +- Use portable-pty directly. +- Give each terminal blocking reader, actor/parser, writer, and child-wait + threads. +- Keep libghostty-vt on the actor thread that creates it. +- Bound raw output, input, and actor queues. +- Make libghostty authoritative for terminal-generated PTY responses. +- Provide an interactive local CLI for exercising multiple terminals. +- Do not add OpenCode IPC integration yet. +- Do not implement descriptor passing or live service replacement. + +## Engineering Rules + +- Never block the service control path on PTY I/O. +- Never perform a blocking write from a libghostty callback. +- Keep exactly one PTY reader and one PTY writer per terminal. +- Preserve child-exit and PTY-EOF as distinct lifecycle signals. +- Treat PID as metadata, never terminal identity. +- Bound by bytes, not only message count. +- Do not expose borrowed libghostty values outside the actor thread. +- Keep terminal shutdown idempotent and join all worker threads. + +## Verification + +```sh +cargo fmt --check +cargo clippy --all-targets --all-features -- -D warnings +cargo test +cargo run -- play +``` diff --git a/packages/process-daemon/Cargo.lock b/packages/process-daemon/Cargo.lock new file mode 100644 index 000000000000..0ee211427ffc --- /dev/null +++ b/packages/process-daemon/Cargo.lock @@ -0,0 +1,513 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror", + "winapi", +] + +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "nix" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "opencode-pty" +version = "0.3.1" +dependencies = [ + "anyhow", + "base64", + "crossbeam-channel", + "fs2", + "libc", + "nix", + "portable-pty", + "rand", + "serde", + "serde_json", + "sha2", + "shell-words", + "windows-sys", +] + +[[package]] +name = "portable-pty" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e" +dependencies = [ + "anyhow", + "bitflags 1.3.2", + "downcast-rs", + "filedescriptor", + "lazy_static", + "libc", + "log", + "nix", + "serial2", + "shared_library", + "shell-words", + "winapi", + "winreg", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serial2" +version = "0.2.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b16809bc35793b19ce4e0c53924bc0dce3937f15487997cfdaed936004180730" +dependencies = [ + "cfg-if", + "libc", + "windows-sys", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shared_library" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11" +dependencies = [ + "lazy_static", + "libc", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/packages/process-daemon/Cargo.toml b/packages/process-daemon/Cargo.toml new file mode 100644 index 000000000000..a1b01a03bc7b --- /dev/null +++ b/packages/process-daemon/Cargo.toml @@ -0,0 +1,45 @@ +[package] +name = "opencode-pty" +version = "0.3.1" +edition = "2024" +rust-version = "1.90" +description = "Persistent PTY service for OpenCode" +license = "MIT" +repository = "https://github.com/anomalyco/opencode-pty" +links = "ghostty-vt" + +[dependencies] +anyhow = "1.0" +base64 = "0.22" +crossbeam-channel = "0.5" +fs2 = "0.4" +portable-pty = "0.9.0" +rand = "0.9" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +shell-words = "1.1" + +[target.'cfg(unix)'.dependencies] +libc = "0.2" +nix = { version = "0.28", features = ["process", "term", "user"] } +sha2 = "0.10" + +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61.2", features = [ + "Win32_Foundation", + "Win32_Security", + "Win32_Security_Authorization", + "Win32_Storage_FileSystem", + "Win32_System_IO", + "Win32_System_Pipes", + "Win32_System_Threading", + "Win32_System_WindowsProgramming", +] } + +[target.'cfg(windows)'.dev-dependencies] +windows-sys = { version = "0.61.2", features = ["Win32_System_Console", "Win32_System_Threading"] } + +[profile.release] +codegen-units = 1 +lto = "thin" +strip = "symbols" diff --git a/packages/process-daemon/LICENSE b/packages/process-daemon/LICENSE new file mode 100644 index 000000000000..1c5a0ca8ba93 --- /dev/null +++ b/packages/process-daemon/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 James Long + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/process-daemon/PLAN.md b/packages/process-daemon/PLAN.md new file mode 100644 index 000000000000..3a3c75cd2eea --- /dev/null +++ b/packages/process-daemon/PLAN.md @@ -0,0 +1,27 @@ +# Implementation Plan + +## Completed + +- [x] Rename the project and process to `opencode-pty`. +- [x] Remove the descriptor-handoff POC implementation. +- [x] Add portable-pty terminal creation and process ownership. +- [x] Add isolated reader, actor/parser, writer, and child-wait threads. +- [x] Add authoritative libghostty parsing and PTY query responses. +- [x] Add bounded raw replay with absolute offsets and explicit truncation. +- [x] Add parsed screen and VT checkpoint snapshots. +- [x] Add create, list, write, resize, snapshot, replay, and terminate operations. +- [x] Add an interactive multi-terminal playground. +- [x] Add multi-terminal, lifecycle, replay, and query-response tests. +- [x] Add persistent daemon startup, election, and atomic private registration. +- [x] Add authenticated framed IPC and the multi-terminal request API. +- [x] Tie daemon lifetime to one owner and preserve terminals across explicit restart handoff. +- [x] Add raw replay-to-live subscriptions and bounded slow-subscriber handling. +- [x] Add exclusive controller and observer roles with explicit takeover. +- [x] Transport authoritative snapshots and VT checkpoints over IPC. +- [x] Add a lazy OpenCode backend proxy with independent per-server runtime identities. + +## Next + +- [ ] Harden process-tree termination on Unix and Windows. +- [ ] Vendor or harden portable-pty's Windows ConPTY backend. +- [ ] Integrate persistent terminals and ordered groups into OpenTUI. diff --git a/packages/process-daemon/README.md b/packages/process-daemon/README.md new file mode 100644 index 000000000000..2b8717336ed1 --- /dev/null +++ b/packages/process-daemon/README.md @@ -0,0 +1,388 @@ +# opencode-pty + +Moved from [anomalyco/opencode-pty](https://github.com/anomalyco/opencode-pty) +(at 443945a, v0.3.1). It lives in the OpenCode monorepo at +`packages/process-daemon`; run the commands below from that directory. + +Experimental threaded PTY service core for OpenCode. + +It uses `portable-pty` to own terminal child processes and `libghostty-vt` to +maintain authoritative terminal state and answer terminal protocol queries. +Every terminal has isolated blocking I/O and actor threads, so one terminal +cannot block the service or another terminal. + +Every daemon has one owner connection. The playground starts a daemon and holds +that connection until exit; exiting the playground stops the daemon and all its +terminals. Observer commands connect to an existing daemon without taking ownership. +The service uses a private authenticated local byte stream (Unix socket or Windows +named pipe) and an atomic registration file. + +Integrations launch `opencode-pty daemon --name NAME [--runtime-dir DIR]` (protocol 7). +The server must claim the daemon within 5 seconds by sending the authenticated +framed envelope `{"token":"...","request":{"op":"own","instance_id":"..."}}`. +The response is `{"type":"owned"}`; that connection stays open as the sole +owner. Ordinary requests and subscriptions use separate connections. +The instance ID and token come from the private registration file. + +Losing the owner connection stops the daemon and its terminals unless the owner +first sends `{"token":"...","request":{"op":"prepare_handoff"}}` on that same +connection. The response is `{"type":"handoff","ticket":"...","expires_at":123}`, +where `expires_at` is Unix milliseconds, 120 seconds from preparation. Repeated +preparation during that window returns the same ticket and deadline. A successor +claims the same instance with the ticket in its `own` request, even while the old +owner is still connected. Successful adoption consumes the ticket atomically; +only one successor can claim it. The superseded connection can no longer prepare +handoffs or shut down the daemon, and its disconnect does not affect the new owner. +Expiry stops an unowned daemon; if the old owner is still connected, expiry simply +cancels the handoff. An ordinary authenticated `shutdown` request, or one from the +current owner, stops the daemon even during handoff. No ownership or handoff state +is persisted. + +Every command requires `--name NAME`, which selects the runtime directory +`DIR/NAME`. `--runtime-dir DIR` is optional and defaults to OpenCode's state +directory, `${XDG_STATE_HOME:-~/.local/state}/opencode/pty`. Names are a single +path component of letters, digits, `.`, `_`, or `-`. The registration +(`service.json`) and lock (`service.lock`) live in that directory. They only +default to a temporary directory when no home directory exists, because macOS deletes unaccessed regular files +there after three days. The socket stays under `/tmp/opencode-pty-/` to fit +socket path limits; temporary cleaners skip sockets. A stopping daemon removes +its own files and runtime directory, and a starting daemon removes abandoned +sibling runtime directories older than ten minutes. + +### Local transport boundary + +`src/transport/` owns endpoint listening/connecting, byte-stream I/O, +disconnect monitoring, response completion, and cancellation. Authentication, +protocol framing, dispatch, subscriptions, and ownership stay shared in the +daemon; runtime-directory/registration wiring is platform-specific. The Unix +backend preserves half-close when completing subscriptions so queued final +frames are not discarded on macOS. Shutdown cancellation is a separate operation +that wakes partial requests and blocked subscription writes before joining. + +The Windows named-pipe backend keeps protocol 7 and the registration fields +`instance_id`, `pid`, `protocol`, `socket`, and `token`. Treat `socket` as an opaque +local endpoint: on Windows it is `\\.\pipe\opencode-pty-`, not a +filesystem socket. A random per-instance name, exclusive first pipe instance, +current-user access control, and rejection of remote clients protect the endpoint; +the private registration file remains the discovery and authentication source. +The pipe is full-duplex and byte-mode, with overlapped reads/writes capped at +64 KiB per kernel operation. Accept polling never waits for a client; connect +retries are bounded to five seconds. Each pending operation is cancelled and its +completion reaped before its buffers or event are freed. +The kernel-retained `OVERLAPPED` allocation uses an owned raw pointer, not a +retained borrow from a movable `Box`; it is reclaimed only after I/O completion. +The allocation helper's move/repeated-access behavior is checked under both +Miri borrow models alongside the callback ownership tests. + +Named pipes have no half-close. Protocol clients close after reading an ordinary +response or the final subscription event. Completion retains queued bytes while +waiting for that close, with a two-second grace period and daemon cancellation; +it never calls the potentially unbounded `FlushFileBuffers`. Stopping acceptance +retains a pipe instance until registration is removed, preventing namespace +squatting during cleanup. + +On Windows, `opencode-pty daemon --name NAME [--runtime-dir DIR]` uses the same +`DIR/NAME` runtime directory; the default `DIR` resolves through `XDG_STATE_HOME` +or the user profile's `.local\state\opencode\pty`, matching OpenCode's state +directory. The runtime directory must be absolute. Storage entries must not be +reparse points or owned by another user; the directory, lock, and registration +use protected current-user-only ACLs, and clients verify the registration's owner +and ACL before trusting it. The held directory/lock handles deny delete sharing, +and registration is atomically replaced under the exclusive lock. Stale +registration is replaced with a fresh instance ID, token, and pipe name; cleanup +removes registration only if its instance ID still matches, then removes the lock +file and runtime directory after releasing their handles. The abandoned-sibling +sweep is shared with Unix; it checks liveness through the registered process +object and has no endpoint file to remove. + +Registration publication uses `FileRenameInfoEx` with POSIX rename semantics, +so the runtime directory must be on a local NTFS volume (Windows 10 1709 or +later); there is no non-atomic fallback. + +The Windows Rust `TerminalClient` and interactive CLI are not ported. Integrations +can use protocol 7 directly and the minimal `daemon::PipeConnection` byte-stream +helper. Connect with `PipeConnection::connect_to(socket, pid)`, which rejects a +pipe whose server is not the registered daemon process: after a crash the stale +registration's pipe name is free for another local user to create. Integrations +in other languages must make the same `GetNamedPipeServerProcessId` check before +sending the token. Discard a connection after any read/write timeout. + +## Architecture + +```text +opencode-pty process +├── atomic private service registration +├── authenticated framed IPC +├── terminal registry +├── terminal 1 +│ ├── reader thread +│ ├── actor/libghostty thread +│ ├── writer thread +│ └── child-wait thread +└── terminal 2 + ├── reader thread + ├── actor/libghostty thread + ├── writer thread + └── child-wait thread +``` + +The actor owns: + +- `portable-pty` master control and resize +- `libghostty-vt::Terminal` +- Bounded raw replay and absolute output offsets +- Parsed screen, cursor, modes, title, and scrollback +- Terminal lifecycle and snapshot requests + +The libghostty parser is authoritative for terminal-generated responses. Its +`on_pty_write` responses are sent through the same serialized writer path as +user input without blocking inside the callback. + +## Playground + +```sh +cargo run -- play --name play +``` + +Other service commands: + +```sh +cargo run -- status --name play +cargo run -- list --name play +cargo run -- watch 1 --name play +cargo run -- stop --name play # destructive: terminates every terminal +``` + +`play` starts and owns a new daemon; it cannot adopt an already running daemon. +`list`, `status`, `watch`, and `stop` only connect to an existing service and never +start one. `quit` stops the playground's daemon and terminals. + +Commands: + +```text +new [PROGRAM ARGS...] create a terminal (default: your shell) +list list terminals and lifecycle state +use ID choose the active terminal +run COMMAND send a shell command and show parsed screen state +send TEXT send bytes without Enter +screen [ID] inspect authoritative libghostty state +replay [ID] [OFFSET] inspect bounded raw replay safely +resize COLS ROWS resize PTY and parser together +wait [MILLISECONDS] wait and show active screen +kill [ID] terminate and remove a terminal +demo prove terminal query responses end to end +help | quit +``` + +Try: + +```text +demo +new +run printf '\033[32mgreen from the shell\033[0m\n' +new /bin/sh +list +use 1 +screen +replay 1 0 +resize 72 20 +kill 2 +quit +``` + +## Reading Terminal Rows + +`TerminalService::read_rows(id, rows)` and `TerminalClient::read_rows(id, rows)` +return the last physical rows of the active terminal buffer, including its +retained scrollback. `rows: Option` defaults to the live terminal height +when omitted/`None`; zero is invalid. Counts larger than the available rows +return all available rows. Soft-wrapped rows stay separate, trailing whitespace +is trimmed, and blank rows (including trailing blank rows) are empty strings. +The alternate screen never exposes primary-screen history. + +The protocol 7 request is `{"op":"read_rows","id":1,"rows":30}`; +omitted or `null` `rows` uses the current height. Its response is +`{"type":"rows","terminal":{...},"lines":[...],"cursor_x":0,"cursor_y":0}`. +Metadata, lines, and zero-based active-screen cursor coordinates come from one +actor snapshot. Reading does not move the viewport, change selection, or take +control. Existing snapshots, checkpoints, and replay are unchanged. + +Row data is bounded to a 1 MiB budget counting JSON-escaped strings, array +punctuation, and owned-string slot overhead. An excessive result is an error, +not silently truncated. The existing 8 MiB transport-frame limit still applies +to the complete response including metadata. + +## Verification + +Install Git, Rust 1.90.0, and Zig 0.16.0. Normal builds do not need bindgen or +libclang and do not depend on a third-party Ghostty Rust crate. + +```sh +cargo fmt --check +cargo clippy --all-targets --all-features -- -D warnings +cargo test +printf 'demo\nlist\nquit\n' | cargo run -- play --name play +``` + +### Direct Ghostty bindings + +`ghostty-revision` pins the official [ghostty-org/ghostty](https://github.com/ghostty-org/ghostty) +source. `build.rs` fetches that commit, builds libghostty with Zig 0.16, and +links its static archive (`ghostty-vt-static.lib` on Windows, not the DLL +import library). It uses an isolated checkout under Cargo's build directory. +The package declares `links = "ghostty-vt"` so Cargo rejects dependency graphs +that try to link another native Ghostty owner alongside it. +Set `GHOSTTY_SOURCE_DIR` to reuse a checkout at that exact revision; modified +C headers are rejected to avoid silently mismatching the generated bindings. + +`src/ghostty/ffi.rs` contains a generated subset of the official C API, with +layout assertions for the supported 64-bit platforms. `src/ghostty/mod.rs` +owns the terminal/formatter handles and exposes only the operations this +service uses. It keeps native objects actor-local, copies callback data, +defers title queries until parsing returns, catches callback panics before +they can unwind through C, and frees native buffers with Ghostty's allocator. +No borrowed grid reference or native handle is exposed to the service. + +`src/ghostty/effects.rs` owns the callback state through `Rc`. Its retained +userdata pointer comes from `Rc::as_ptr`, and all state changes use interior +mutability through shared references. It stays alive through native teardown; +moving a terminal or draining replies never creates an exclusive borrow of the +callback allocation. + +To update the native revision, edit `ghostty-revision`, check out that revision +of official Ghostty, and regenerate the declarations from its headers: + +```sh +cargo install bindgen-cli --version 0.72.1 --locked +# Install libclang (or set LIBCLANG_PATH to its shared library directory). +script/ghostty-bindings /path/to/ghostty +cargo test --locked +``` + +Only regeneration needs bindgen/libclang. The build verifies that the +generated revision matches `ghostty-revision`; changing either alone fails +instead of silently combining different C APIs. The generated declarations +are covered by the upstream license in `src/ghostty/LICENSE`. + +The callback ownership tests also run under Miri without building Ghostty. Their +small native-owner stand-in imports the production effects module directly and +exercises constructor/container moves, repeated callback/drain cycles, buffer +ownership, panic handling, and teardown. CI checks both Stacked Borrows and Tree +Borrows; the regular native suites still verify the actual C integration. + +```sh +rustup toolchain install nightly-2026-09-02 --profile minimal --component miri --component rust-src +cargo +nightly-2026-09-02 miri test --locked --manifest-path tests/ghostty-effects/Cargo.toml +MIRIFLAGS=-Zmiri-tree-borrows cargo +nightly-2026-09-02 miri test --locked --manifest-path tests/ghostty-effects/Cargo.toml +``` + +### Windows CI + +The Windows jobs in the monorepo's +`.github/workflows/check-process-daemon.yml` build for Windows x64 and ARM64 and execute +tests natively on `windows-2025` and `windows-11-arm`, respectively. The ARM64 +job uses x64-hosted compilers targeting ARM64 because native ARM64 Zig 0.16 +crashes while building Ghostty; the tests themselves remain native ARM64. +The job also applies a guarded one-line alignment fix to Zig 0.16's Windows +stack-trace helper, which otherwise fails to compile for ARM64. +The workflow runs on pull requests and pushes to `dev` that touch +`packages/process-daemon`, independently of the release workflow. +It checks formatting, builds the executable, runs all enabled tests, and checks +`opencode-pty.exe --version`. It also copies the library test executable out of +the build tree and runs it without Cargo's DLL search paths, verifying that +libghostty is statically linked. It does not publish packages or releases. + +`tests/runtime.rs` exercises `TerminalService` directly on both Unix and Windows, +using real self-spawned Rust console children. It covers input/output, Unicode, +cwd/environment/argument and executable-path handling, OS console resize, +snapshots, bounded replay, terminal replies, and independent terminals. ConPTY +can consume application terminal queries itself; the tests also check its +cursor-inheritance query reaches our reader and Ghostty parser. + +The reusable fixture in `tests/support/terminal_fixture.rs` returns a +`CreateTerminal` request and uses a separate control/observation channel, so +daemon tests can reuse it without a shell or Rust client. Include it as +`terminal_fixture`, call `Fixture::request`, create the terminal, then call +`Fixture::connect`. `Command::Output` writes real console stdout; `Command::Read` +observes actual console stdin without echo; `Size` and `Context` inspect the +child's OS console and process context. The ignored `child` test is only its +subprocess entry point, not skipped runtime coverage. + +The original service, ownership, playground, and rows integration suites remain +Unix-only. Windows library tests exercise real named-pipe roundtrips, multiple +connections, namespace ownership, cancellation, final-frame completion, and +private atomic registration. `tests/windows-daemon.rs` exercises authenticated +ownership/handoff, locking/stale registration, partial requests, blocked +subscribers, and live ConPTY create/input/output/resize/shutdown through the real +daemon. Natural child-exit/ConPTY EOF and runtime cleanup are checked by the +direct runtime suite; the basic daemon tests do not establish complete lifecycle +coverage on their own. + +On Windows, normal root-child exit hands the ConPTY master to the existing +child-wait worker for closing. The actor and reader continue draining until the +real output-pipe EOF; only then is the final exit event published. The worker +checks the root's wait handle at 10 ms intervals. After the master is handed +off, Windows input and resize requests fail with a child-exited error; final +snapshots, rows, and replay remain readable until the terminal is removed. +Unix post-exit PTY operations retain their existing behavior. + +### Known Linux cleanup limitation + +A child that exits without consuming a large queued PTY write can leave the +Linux master write blocked after the child, actor, reader, and waiter have +already stopped. `TerminalService::terminate` or service drop can then wait +indefinitely for the writer thread. This pre-existing Unix I/O limitation is +not repaired by the Windows cleanup work. A test watchdog or the daemon's +forced-exit deadline does not prove that those worker threads were joined. + +The workflow can also be run manually against a branch: + +```sh +gh workflow run check-process-daemon.yml --repo anomalyco/opencode --ref BRANCH +gh run list --repo anomalyco/opencode --workflow check-process-daemon.yml --branch BRANCH +gh run view RUN_ID --repo anomalyco/opencode --log-failed +``` + +## Releases + +The release workflow has not moved to the monorepo yet; until it does, the +process below describes releases from anomalyco/opencode-pty. + +Pushing a `vX.Y.Z` tag matching the version in `Cargo.toml` creates an unsigned +GitHub release. The release contains x86-64 and arm64 binaries for Linux GNU, +Linux musl, macOS, and Windows (MSVC, statically linked C runtime), plus +`SHA256SUMS`, a machine-readable +`release-manifest.json`, and GitHub build-provenance attestations. Release +builds force Ghostty's Zig code generation to its baseline CPU target so +artifacts do not inherit instruction-set extensions from CI runners (Windows +builds set `OPENCODE_ZIG_CPU=baseline`, since the Unix Zig wrapper is a shell +script). Linux GNU artifacts support glibc 2.30 and newer. + +Tagged releases also publish `@opencode-ai/pty` to npm with optional, +platform-specific binary packages. Installing the npm package selects the native +binary for the current platform (`opencode-pty.exe` on Windows) and exposes +its path as `binaryPath`: + +```js +import { binaryPath } from "@opencode-ai/pty" +``` + +```sh +git tag v0.1.0 +push origin v0.1.0 +``` + +Platform signing will be added later. + +## Current Limits + +- Windows supports the daemon/protocol transport, not the Rust interactive + TerminalClient/play/watch CLI. +- Ordinary API operations use one framed JSON request per connection; + subscriptions keep the authenticated connection open for ordered live events. +- The OpenCode backend proxy and ordered group APIs are implemented, but the + OpenTUI client integration is not. +- Windows uses portable-pty's published ConPTY backend and is not hardened yet. +- Child process-tree cleanup is not complete beyond portable-pty's root killer. +- Cold-client checkpoint transport is not implemented. +- A service-process crash loses all terminals by design. diff --git a/packages/process-daemon/SPEC.md b/packages/process-daemon/SPEC.md new file mode 100644 index 000000000000..aaf8142ee111 --- /dev/null +++ b/packages/process-daemon/SPEC.md @@ -0,0 +1,146 @@ +# Threaded PTY Service Core + +Status: threaded core and persistent service API implemented + +## Objective + +Prove that one Rust service can own multiple portable PTYs while each terminal +uses isolated blocking threads, authoritative libghostty parsing, bounded raw +replay, and explicit lifecycle state. + +## Invariants + +- One reader consumes each PTY output stream. +- One writer serializes every byte written to each PTY. +- libghostty state is created, accessed, and dropped on one actor thread. +- libghostty callbacks never perform blocking PTY writes or IPC. +- Actor and writer channels are bounded. +- Raw replay is bounded and uses absolute byte offsets. +- Replay truncation is explicit. +- PTY resize and parser resize occur in one ordered actor command. +- Every resize publishes an authoritative post-resize VT checkpoint before subsequent output. +- Child exit and PTY EOF are separate events. +- One terminal exiting does not stop the service or another terminal. +- Service shutdown attempts to terminate and join every terminal runtime. +- A terminal has at most one controller; observers cannot write or resize until interaction promotes them. +- Promotion demotes the previous controller without closing its subscription. +- Promotion, canonical resize, and input are one ordered actor command. +- Subscription replay and live events cross one ordered actor boundary. +- OSC 0/2 title changes update authoritative terminal metadata and publish ordered subscription events. +- The deepest TTY-attached process in the foreground process group updates authoritative process metadata. +- Controller delivery applies backpressure; observers tolerate bounded output bursts and are disconnected when their backlog remains full. +- Adjacent output is coalesced before fanout, flushing at 8 KiB, after 1 ms, or before the next non-output event. + +## Data Flow + +```text +child PTY output + -> blocking reader thread + -> bounded actor queue + -> append bounded raw replay + -> libghostty vt_write + -> collect parser responses + -> bounded writer queue + -> blocking writer thread + -> child PTY input +``` + +User input also enters through the actor and the same writer queue. + +## Snapshot + +A snapshot is built on the actor thread and contains owned values only: + +- Terminal metadata and lifecycle +- Rows and columns +- Cursor position +- Plain parsed screen text +- VT checkpoint synthesized by libghostty formatter +- Raw replay head and tail offsets +- Current title derived from libghostty state +- Current foreground process derived from PTY process state + +No borrowed libghostty object crosses a thread boundary. + +## Row Reads + +`Request::ReadRows { id, rows: Option }` (`op: "read_rows"`) returns +`Response::Rows { terminal, lines, cursor_x, cursor_y }` (`type: "rows"`). Both +the service and Rust client expose `read_rows(id, rows)`. Omitted or null rows +defaults to the current terminal height; zero is rejected. A larger count +includes retained history, up to all available rows of the active buffer. +Counts cannot exceed `u16::MAX`. + +The actor selects the last physical rows using libghostty's total active-buffer +row count and full-screen grid coordinates, independent of the viewport. +Each row is formatted separately as plain text with soft-wrap unwrapping +disabled and trailing whitespace trimmed. Blank rows remain empty strings, +including internal and trailing blanks. Alternate-screen reads cannot include +primary-screen history. Resizes are observed in actor order, including reflow. +Rows, terminal metadata, and zero-based active-screen cursor coordinates are +captured in the same actor command. Cursor coordinates are not rebased to the +returned history rows. No viewport, selection, controller, snapshot, +checkpoint, or replay state is changed. + +Row reads have a 1 MiB budget for JSON-escaped row strings, array punctuation, +and owned-string slots (including blank rows); formatting buffers are also +bounded. Results exceeding that budget fail rather than truncate. Complete +responses remain subject to the transport's 8 MiB frame limit. Protocol 7 +supports `read_rows` alongside the existing snapshot and replay operations. + +## Failure Boundary + +Losing the owning server connection stops `opencode-pty` and its terminals. +An explicitly prepared restart handoff allows a replacement server to claim +the same daemon before a fixed deadline. If `opencode-pty` itself dies, its +terminals may die; live replacement of the daemon is not supported. + +## Persistent Service Boundary + +Every daemon requires one authenticated owner connection within five seconds +of startup. Owner loss stops the daemon unless a handoff was prepared on that +connection. Handoff tickets expire 120 seconds after preparation and are +consumed by successful replacement ownership. A valid ticket replaces even a +connected owner; the superseded connection can no longer prepare handoffs or +stop the daemon, and its disconnect does not affect the new owner. The playground holds ownership until it exits; other CLI commands +only observe or operate an existing daemon and never start one. + +Protocol v7 uses four-byte big-endian framing with bounded UTF-8 JSON control +frames and tagged raw binary output frames. It supports ping, create, list, +write, resize, atomic interaction promotion, snapshot, read_rows, replay, replay-to-live +subscriptions, title updates, controller takeover, terminate, and destructive service +shutdown, ownership acquisition, and restart handoff preparation. + +Registration is written atomically with private permissions and contains an +instance ID, PID, protocol version, socket path, and random credential. A +service lock elects one process and protects stale socket cleanup. On Unix, the +socket uses a fixed-length hash of the canonical runtime path +under a private per-user `/tmp` directory to stay below platform path limits. + +Every command requires `--name NAME`; the runtime directory is `DIR/NAME`, +where `--runtime-dir DIR` defaults to OpenCode's state directory, +`${XDG_STATE_HOME:-~/.local/state}/opencode/pty`. A name is one path component +of letters, digits, `.`, `_`, or `-`. Registration defaults to a temporary directory only when no home +directory can be found: macOS deletes regular files there that are unaccessed for three +days, even while the daemon runs. Temporary cleaners skip sockets, so the +socket stays in `/tmp`. On exit the daemon removes its registration and socket +only if they are still its own, then its empty runtime directory. At startup +it removes sibling runtime directories in `DIR` that are over ten +minutes old, contain only registration files, and have either a lock it can +acquire or, without a lock file, a valid registration whose PID no longer +exists. Directories without that evidence, including empty ones, are kept. + +On Windows the endpoint is a random per-instance named pipe instead of a +socket. The runtime directory, lock, and registration carry a protected +current-user-only ACL, and registration is atomically replaced with POSIX +rename semantics. Clients verify the registration's owner and ACL, and check +that the pipe server process matches the registered PID before sending the +token. On exit the daemon removes its registration if still its own, then the +lock file and runtime directory; the forced-exit watchdog removes only the +registration, leaving the rest to a later sweep. The sweep checks liveness +through the registered process object. + +OpenCode chooses a fresh UUID runtime directory for each server, independent of +the database. It starts the daemon only when the first terminal is created. +Only an explicit restart handoff descriptor lets a replacement server reuse +the previous daemon's runtime directory and claim its ownership. diff --git a/packages/process-daemon/build.rs b/packages/process-daemon/build.rs new file mode 100644 index 000000000000..1dbf8428f370 --- /dev/null +++ b/packages/process-daemon/build.rs @@ -0,0 +1,151 @@ +use std::env; +use std::path::PathBuf; +use std::process::Command; + +fn main() { + println!("cargo:rerun-if-changed=build.rs"); + println!("cargo:rerun-if-changed=ghostty-revision"); + println!("cargo:rerun-if-changed=src/ghostty/ffi.rs"); + println!("cargo:rerun-if-env-changed=GHOSTTY_SOURCE_DIR"); + println!("cargo:rerun-if-env-changed=OPENCODE_ZIG_CPU"); + + let revision = include_str!("ghostty-revision").trim(); + assert_eq!( + include_str!("src/ghostty/ffi.rs") + .lines() + .find_map(|line| line.strip_prefix("// Ghostty revision: ")), + Some(revision), + "regenerate the bindings with script/ghostty-bindings" + ); + let out = PathBuf::from(env::var_os("OUT_DIR").expect("OUT_DIR")); + let source = match env::var_os("GHOSTTY_SOURCE_DIR") { + Some(path) => { + let path = PathBuf::from(path); + for relative in ["src", "pkg", "include", "build.zig", "build.zig.zon"] { + println!("cargo:rerun-if-changed={}", path.join(relative).display()); + } + path + } + None => { + let path = out.join(format!("ghostty-{revision}")); + if !path.join(".checkout-complete").exists() { + if !path.join(".git").exists() { + run(Command::new("git").args(["init"]).arg(&path)); + } + run(Command::new("git").arg("-C").arg(&path).args([ + "fetch", + "--depth=1", + "https://github.com/ghostty-org/ghostty.git", + revision, + ])); + run(Command::new("git").arg("-C").arg(&path).args([ + "-c", + "core.longpaths=true", + "checkout", + "--detach", + revision, + ])); + std::fs::write(path.join(".checkout-complete"), revision) + .expect("record Ghostty checkout"); + } + path + } + }; + + // The checked-in declarations must match the native headers exactly. + // Local overrides are useful for builds, but not for silently changing ABI. + let head = Command::new("git") + .arg("-C") + .arg(&source) + .args(["rev-parse", "HEAD"]) + .output() + .expect("read Ghostty revision"); + assert!( + head.status.success(), + "GHOSTTY_SOURCE_DIR must be a Git checkout" + ); + assert_eq!( + String::from_utf8(head.stdout) + .expect("Ghostty revision UTF-8") + .trim(), + revision, + "Ghostty headers and bindings are pinned together; regenerate before changing the revision" + ); + run(Command::new("git").arg("-C").arg(&source).args([ + "-c", + "core.longpaths=true", + "diff", + "--quiet", + "HEAD", + "--", + "include/ghostty", + ])); + + let target = env::var("TARGET").expect("TARGET"); + let install = out.join("ghostty-install"); + let mut build = Command::new("zig"); + build.current_dir(&source).args([ + "build", + "-Demit-lib-vt=true", + "-Demit-xcframework=false", + "-Dapp-runtime=none", + ]); + build.arg(if env::var("DEBUG").as_deref() == Ok("true") { + "-Doptimize=Debug" + } else if matches!(env::var("OPT_LEVEL").as_deref(), Ok("s" | "z")) { + "-Doptimize=ReleaseSmall" + } else { + "-Doptimize=ReleaseFast" + }); + // Release builds pin a portable CPU. Unix releases use script/release-bin/zig; + // Windows cannot run that shell wrapper, so it sets this instead. + if let Ok(cpu) = env::var("OPENCODE_ZIG_CPU") { + build.arg(format!("-Dcpu={cpu}")); + } + build.arg("--prefix").arg(&install); + build.arg("--cache-dir").arg(out.join("zig-cache")); + if env::var("HOST").as_deref() != Ok(target.as_str()) { + let zig_target = match target.as_str() { + "x86_64-unknown-linux-gnu" => "x86_64-linux-gnu", + "aarch64-unknown-linux-gnu" => "aarch64-linux-gnu", + "x86_64-unknown-linux-musl" => "x86_64-linux-musl", + "aarch64-unknown-linux-musl" => "aarch64-linux-musl", + "x86_64-apple-darwin" => "x86_64-macos", + "aarch64-apple-darwin" => "aarch64-macos", + "x86_64-pc-windows-msvc" => "x86_64-windows-msvc", + "aarch64-pc-windows-msvc" => "aarch64-windows-msvc", + _ => panic!("unsupported Ghostty target: {target}"), + }; + build.arg(format!("-Dtarget={zig_target}")); + } + run(&mut build); + + let library = install.join("lib"); + let windows = target.contains("windows"); + assert!( + library + .join(if windows { + "ghostty-vt-static.lib" + } else { + "libghostty-vt.a" + }) + .is_file() + ); + println!("cargo:rustc-link-search=native={}", library.display()); + // ghostty-vt.lib is the Windows DLL import library, not the static archive. + println!( + "cargo:rustc-link-lib={}", + if windows { + "static:+verbatim=ghostty-vt-static.lib" + } else { + "static=ghostty-vt" + } + ); +} + +fn run(command: &mut Command) { + let status = command + .status() + .unwrap_or_else(|error| panic!("{command:?}: {error}")); + assert!(status.success(), "{command:?} failed: {status}"); +} diff --git a/packages/process-daemon/ghostty-revision b/packages/process-daemon/ghostty-revision new file mode 100644 index 000000000000..584b0447e27d --- /dev/null +++ b/packages/process-daemon/ghostty-revision @@ -0,0 +1 @@ +ab0b9da9e88fcb4b0533a1854e84628f663930af diff --git a/packages/process-daemon/npm/bin/opencode-pty.js b/packages/process-daemon/npm/bin/opencode-pty.js new file mode 100644 index 000000000000..f78a894d7533 --- /dev/null +++ b/packages/process-daemon/npm/bin/opencode-pty.js @@ -0,0 +1,14 @@ +#!/usr/bin/env node + +import { spawnSync } from "node:child_process" +import { binaryPath } from "../index.js" + +if (!binaryPath) { + console.error(`opencode-pty is unavailable for ${process.platform}-${process.arch}`) + process.exit(1) +} + +const result = spawnSync(binaryPath, process.argv.slice(2), { stdio: "inherit" }) +if (result.error) throw result.error +if (result.signal) process.kill(process.pid, result.signal) +process.exit(result.status ?? 1) diff --git a/packages/process-daemon/npm/index.d.ts b/packages/process-daemon/npm/index.d.ts new file mode 100644 index 000000000000..3246d7dabf7e --- /dev/null +++ b/packages/process-daemon/npm/index.d.ts @@ -0,0 +1 @@ +export declare const binaryPath: string | undefined diff --git a/packages/process-daemon/npm/index.js b/packages/process-daemon/npm/index.js new file mode 100644 index 000000000000..5e018fd3c902 --- /dev/null +++ b/packages/process-daemon/npm/index.js @@ -0,0 +1,17 @@ +import { createRequire } from "node:module" +import path from "node:path" + +const require = createRequire(import.meta.url) +const supported = process.platform === "darwin" || process.platform === "linux" || process.platform === "win32" +const arch = process.arch === "arm64" || process.arch === "x64" ? process.arch : undefined +const libc = process.platform === "linux" ? (process.report.getReport().header.glibcVersionRuntime ? "gnu" : "musl") : "" +const suffix = [process.platform, arch, libc].filter(Boolean).join("-") + +export const binaryPath = + supported && arch + ? path.join( + path.dirname(require.resolve(`@opencode-ai/pty-${suffix}/package.json`)), + "bin", + process.platform === "win32" ? "opencode-pty.exe" : "opencode-pty", + ) + : undefined diff --git a/packages/process-daemon/npm/package.json b/packages/process-daemon/npm/package.json new file mode 100644 index 000000000000..387904b57dc2 --- /dev/null +++ b/packages/process-daemon/npm/package.json @@ -0,0 +1,39 @@ +{ + "name": "@opencode-ai/pty", + "version": "0.3.1", + "description": "Persistent PTY service for OpenCode", + "type": "module", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/anomalyco/opencode-pty.git" + }, + "exports": { + ".": { + "types": "./index.d.ts", + "default": "./index.js" + }, + "./package.json": "./package.json" + }, + "bin": { + "opencode-pty": "./bin/opencode-pty.js" + }, + "files": [ + "bin", + "index.d.ts", + "index.js" + ], + "optionalDependencies": { + "@opencode-ai/pty-darwin-arm64": "0.3.1", + "@opencode-ai/pty-darwin-x64": "0.3.1", + "@opencode-ai/pty-linux-arm64-gnu": "0.3.1", + "@opencode-ai/pty-linux-arm64-musl": "0.3.1", + "@opencode-ai/pty-linux-x64-gnu": "0.3.1", + "@opencode-ai/pty-linux-x64-musl": "0.3.1", + "@opencode-ai/pty-win32-arm64": "0.3.1", + "@opencode-ai/pty-win32-x64": "0.3.1" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/process-daemon/rust-toolchain.toml b/packages/process-daemon/rust-toolchain.toml new file mode 100644 index 000000000000..c3891f04890f --- /dev/null +++ b/packages/process-daemon/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "1.90.0" +profile = "minimal" diff --git a/packages/process-daemon/script/ghostty-bindings b/packages/process-daemon/script/ghostty-bindings new file mode 100755 index 000000000000..82fecdfa93b8 --- /dev/null +++ b/packages/process-daemon/script/ghostty-bindings @@ -0,0 +1,26 @@ +#!/bin/sh +set -eu + +root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +source=${1:?usage: script/ghostty-bindings /path/to/pinned/ghostty} +revision=$(cat "$root/ghostty-revision") +test "$(git -C "$source" rev-parse HEAD)" = "$revision" +test "$(bindgen --version)" = "bindgen 0.72.1" + +# Only regeneration needs bindgen/libclang. Normal builds use this checked-in +# subset of the official C header, not a third-party Ghostty Rust crate. +bindgen "$source/include/ghostty/vt.h" \ + --allowlist-function 'ghostty_terminal_(new|free|resize|set|vt_write|get|grid_ref|scroll_viewport|point_from_grid_ref|selection_format_buf)' \ + --allowlist-function 'ghostty_formatter_(terminal_new|format_buf|format_alloc|free)' \ + --allowlist-function 'ghostty_free' \ + --allowlist-type 'GhosttyString' \ + --allowlist-type 'GhosttyTerminal(WritePty|TitleChanged)Fn' \ + --no-prepend-enum-name --no-doc-comments --with-derive-default \ + --rust-target 1.85 --rust-edition 2024 \ + --raw-line "// Ghostty revision: $revision" \ + --raw-line '// Generated from the official C API; regenerate with script/ghostty-bindings.' \ + --raw-line '#![allow(dead_code, non_camel_case_types, non_snake_case, non_upper_case_globals, clippy::all)]' \ + --output "$root/src/ghostty/ffi.rs" \ + -- -std=c17 -I"$source/include" + +rustfmt --edition 2024 "$root/src/ghostty/ffi.rs" diff --git a/packages/process-daemon/script/npm-packages.mjs b/packages/process-daemon/script/npm-packages.mjs new file mode 100644 index 000000000000..ab2afaa43596 --- /dev/null +++ b/packages/process-daemon/script/npm-packages.mjs @@ -0,0 +1,69 @@ +#!/usr/bin/env node + +import { execFileSync } from "node:child_process" +import { copyFile, cp, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { fileURLToPath } from "node:url" + +const root = fileURLToPath(new URL("..", import.meta.url)) +const dist = path.resolve(root, process.argv[2] ?? "dist") +const output = path.join(dist, "npm") +const manifest = JSON.parse(await readFile(path.join(root, "npm/package.json"), "utf8")) +const targets = [ + { target: "aarch64-apple-darwin", platform: "darwin", arch: "arm64" }, + { target: "x86_64-apple-darwin", platform: "darwin", arch: "x64" }, + { target: "aarch64-unknown-linux-gnu", platform: "linux", arch: "arm64", libc: "glibc", suffix: "gnu" }, + { target: "aarch64-unknown-linux-musl", platform: "linux", arch: "arm64", libc: "musl", suffix: "musl" }, + { target: "x86_64-unknown-linux-gnu", platform: "linux", arch: "x64", libc: "glibc", suffix: "gnu" }, + { target: "x86_64-unknown-linux-musl", platform: "linux", arch: "x64", libc: "musl", suffix: "musl" }, + { target: "aarch64-pc-windows-msvc", platform: "win32", arch: "arm64" }, + { target: "x86_64-pc-windows-msvc", platform: "win32", arch: "x64" }, +] + +await rm(output, { recursive: true, force: true }) +await mkdir(output, { recursive: true }) + +for (const target of targets) { + const suffix = [target.platform, target.arch, target.suffix].filter(Boolean).join("-") + const name = `@opencode-ai/pty-${suffix}` + const directory = path.join(output, `opencode-pty-${suffix}`) + const archive = path.join(dist, `opencode-pty-${manifest.version}-${target.target}.tar.gz`) + const binary = target.platform === "win32" ? "opencode-pty.exe" : "opencode-pty" + const temporary = await mkdtemp(path.join(os.tmpdir(), "opencode-pty-npm-")) + try { + execFileSync("tar", ["-xzf", archive, "-C", temporary]) + const executable = path.join(temporary, `opencode-pty-${manifest.version}-${target.target}`, binary) + await mkdir(path.join(directory, "bin"), { recursive: true }) + await copyFile(executable, path.join(directory, "bin", binary)) + await copyFile(path.join(root, "LICENSE"), path.join(directory, "LICENSE")) + await writeFile( + path.join(directory, "package.json"), + JSON.stringify( + { + name, + version: manifest.version, + description: `OpenCode persistent PTY service for ${suffix}`, + license: "MIT", + repository: manifest.repository, + os: [target.platform], + cpu: [target.arch], + ...(target.libc ? { libc: [target.libc] } : {}), + exports: { "./package.json": "./package.json", [`./bin/${binary}`]: `./bin/${binary}` }, + files: ["bin"], + publishConfig: { access: "public" }, + }, + null, + 2, + ) + "\n", + ) + } finally { + await rm(temporary, { recursive: true, force: true }) + } + console.log(name) +} + +const dispatcher = path.join(output, "opencode-pty") +await cp(path.join(root, "npm"), dispatcher, { recursive: true }) +await copyFile(path.join(root, "LICENSE"), path.join(dispatcher, "LICENSE")) +console.log(manifest.name) diff --git a/packages/process-daemon/script/release-bin/zig b/packages/process-daemon/script/release-bin/zig new file mode 100755 index 000000000000..448b3fc39ace --- /dev/null +++ b/packages/process-daemon/script/release-bin/zig @@ -0,0 +1,13 @@ +#!/bin/sh +set -eu + +if [ -z "${OPENCODE_REAL_ZIG:-}" ]; then + echo "OPENCODE_REAL_ZIG is required" >&2 + exit 1 +fi + +if [ "${1:-}" = "build" ]; then + exec "$OPENCODE_REAL_ZIG" "$@" -Dcpu=baseline +fi + +exec "$OPENCODE_REAL_ZIG" "$@" diff --git a/packages/process-daemon/src/client.rs b/packages/process-daemon/src/client.rs new file mode 100644 index 000000000000..83879c9f6586 --- /dev/null +++ b/packages/process-daemon/src/client.rs @@ -0,0 +1,810 @@ +use std::env; +use std::io::{self, BufRead, Read, Write}; +use std::path::{Path, PathBuf}; +use std::thread; +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result, anyhow, bail}; +use base64::Engine; + +use crate::daemon::{Registration, read_registration, runtime_dir}; +#[cfg(unix)] +use crate::protocol::{ + AttachmentRole, Envelope, SubscriptionEvent, read_frame, read_subscription_event, write_frame, +}; +use crate::protocol::{PROTOCOL_VERSION, Request, Response}; +use crate::service::{CreateTerminal, TerminalId, TerminalInfo, TerminalRows}; +#[cfg(unix)] +use crate::transport::Connection; + +const START_TIMEOUT: Duration = Duration::from_secs(5); + +pub struct TerminalClient { + registration: Registration, + #[cfg(unix)] + owner: Option<(Connection, std::process::Child)>, +} + +#[derive(Debug)] +pub struct RemoteSnapshot { + pub terminal: TerminalInfo, + pub text: String, + pub checkpoint: Vec, + pub cursor_x: u16, + pub cursor_y: u16, +} + +#[derive(Debug)] +pub struct RemoteReplay { + pub requested_offset: u64, + pub available_offset: u64, + pub end_offset: u64, + pub truncated: bool, + pub bytes: Vec, +} + +#[cfg(unix)] +pub struct TerminalSubscription { + stream: Connection, + pub terminal: TerminalInfo, + pub role: AttachmentRole, + pub generation: u64, + pub replay: RemoteReplay, +} + +#[cfg(unix)] +impl TerminalSubscription { + pub fn next_event(&mut self) -> Result { + read_subscription_event(&mut self.stream) + } +} + +impl TerminalClient { + #[cfg(unix)] + pub fn start(directory: &Path) -> Result { + use std::os::unix::process::CommandExt; + use std::process::{Command, Stdio}; + + let invalid = || anyhow!("invalid runtime directory {}", directory.display()); + let name = directory + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(invalid)?; + let root = directory.parent().ok_or_else(invalid)?; + let mut command = Command::new(env::current_exe()?); + command + .arg("daemon") + .arg("--name") + .arg(name) + .arg("--runtime-dir") + .arg(root) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + // SAFETY: setsid has no memory-safety preconditions. It detaches the + // daemon from the playground's terminal before exec. + unsafe { + command.pre_exec(|| nix::unistd::setsid().map(|_| ()).map_err(io::Error::other)); + } + let mut child = command.spawn().context("failed to spawn opencode-pty")?; + let deadline = Instant::now() + START_TIMEOUT; + while Instant::now() < deadline { + if let Some(status) = child.try_wait()? { + bail!("opencode-pty daemon exited before ownership acquisition: {status}"); + } + if let Ok(registration) = read_registration(directory) + && registration.pid == child.id() + { + let mut stream = Connection::connect(®istration.socket)?; + stream.set_read_timeout(Some(START_TIMEOUT))?; + stream.set_write_timeout(Some(START_TIMEOUT))?; + write_frame( + &mut stream, + &Envelope { + token: registration.token.clone(), + request: Request::Own { + instance_id: registration.instance_id.clone(), + ticket: None, + }, + }, + )?; + return match read_frame(&mut stream)? { + Response::Owned => Ok(Self { + registration, + owner: Some((stream, child)), + }), + response => unexpected(response), + }; + } + thread::sleep(Duration::from_millis(50)); + } + bail!("opencode-pty did not become ready") + } + + #[cfg(not(unix))] + pub fn start(_directory: &Path) -> Result { + bail!("opencode-pty client transport is not implemented on this platform") + } + + pub fn discover(directory: &Path) -> Result { + let registration = read_registration(directory)?; + if registration.protocol != PROTOCOL_VERSION { + bail!( + "opencode-pty protocol mismatch: service={}, client={PROTOCOL_VERSION}", + registration.protocol + ); + } + let client = Self { + registration, + #[cfg(unix)] + owner: None, + }; + match client.request(Request::Ping)? { + Response::Pong { + instance_id, + pid, + protocol, + } if instance_id == client.registration.instance_id + && pid == client.registration.pid + && protocol == PROTOCOL_VERSION => + { + Ok(client) + } + _ => bail!("opencode-pty registration did not match the running service"), + } + } + + pub fn create(&self, request: CreateTerminal) -> Result { + match self.request(Request::Create { + program: request.program, + args: request.args, + cwd: request.cwd, + title: request.title, + group_id: request.group_id, + env: request.env, + cols: request.cols, + rows: request.rows, + })? { + Response::Created { terminal } => Ok(terminal), + response => unexpected(response), + } + } + + pub fn list(&self) -> Result> { + match self.request(Request::List)? { + Response::Terminals { terminals } => Ok(terminals), + response => unexpected(response), + } + } + + pub fn write(&self, id: TerminalId, data: Vec) -> Result<()> { + self.write_for(id, None, data) + } + + pub fn write_for( + &self, + id: TerminalId, + attachment_id: Option, + data: Vec, + ) -> Result<()> { + self.expect_ok(Request::Write { + id, + attachment_id, + data_base64: base64::engine::general_purpose::STANDARD.encode(data), + }) + } + + pub fn resize(&self, id: TerminalId, cols: u16, rows: u16) -> Result<()> { + self.resize_for(id, None, cols, rows) + } + + pub fn resize_for( + &self, + id: TerminalId, + attachment_id: Option, + cols: u16, + rows: u16, + ) -> Result<()> { + self.expect_ok(Request::Resize { + id, + attachment_id, + cols, + rows, + }) + } + + pub fn control( + &self, + id: TerminalId, + attachment_id: String, + cols: u16, + rows: u16, + ) -> Result<()> { + self.expect_ok(Request::Control { + id, + attachment_id, + cols, + rows, + }) + } + + pub fn input( + &self, + id: TerminalId, + attachment_id: String, + cols: u16, + rows: u16, + data: Vec, + ) -> Result<()> { + self.expect_ok(Request::Input { + id, + attachment_id, + cols, + rows, + data_base64: base64::engine::general_purpose::STANDARD.encode(data), + }) + } + + pub fn snapshot(&self, id: TerminalId) -> Result { + match self.request(Request::Snapshot { id })? { + Response::Snapshot { + terminal, + text, + checkpoint_base64, + cursor_x, + cursor_y, + } => Ok(RemoteSnapshot { + terminal, + text, + checkpoint: base64::engine::general_purpose::STANDARD + .decode(checkpoint_base64) + .context("invalid checkpoint base64")?, + cursor_x, + cursor_y, + }), + response => unexpected(response), + } + } + + pub fn read_rows(&self, id: TerminalId, rows: Option) -> Result { + match self.request(Request::ReadRows { id, rows })? { + Response::Rows { + terminal, + lines, + cursor_x, + cursor_y, + } => Ok(TerminalRows { + terminal, + lines, + cursor_x, + cursor_y, + }), + response => unexpected(response), + } + } + + pub fn replay(&self, id: TerminalId, offset: u64) -> Result { + match self.request(Request::Replay { id, offset })? { + Response::Replay { + requested_offset, + available_offset, + end_offset, + truncated, + data_base64, + } => Ok(RemoteReplay { + requested_offset, + available_offset, + end_offset, + truncated, + bytes: base64::engine::general_purpose::STANDARD + .decode(data_base64) + .context("invalid replay base64")?, + }), + response => unexpected(response), + } + } + + pub fn terminate(&self, id: TerminalId) -> Result<()> { + self.expect_ok(Request::Terminate { id }) + } + + #[cfg(unix)] + pub fn subscribe( + &self, + id: TerminalId, + offset: u64, + attachment_id: String, + role: AttachmentRole, + takeover: bool, + ) -> Result { + let mut stream = Connection::connect(&self.registration.socket)?; + write_frame( + &mut stream, + &Envelope { + token: self.registration.token.clone(), + request: Request::Subscribe { + id, + offset, + attachment_id, + role, + takeover, + }, + }, + )?; + match read_frame(&mut stream)? { + Response::Attached { + terminal, + role, + generation, + requested_offset, + available_offset, + end_offset, + truncated, + replay_base64, + } => Ok(TerminalSubscription { + stream, + terminal, + role, + generation, + replay: RemoteReplay { + requested_offset, + available_offset, + end_offset, + truncated, + bytes: base64::engine::general_purpose::STANDARD + .decode(replay_base64) + .context("invalid subscription replay base64")?, + }, + }), + Response::Error { message } => bail!(message), + response => unexpected(response), + } + } + + pub fn shutdown(&self) -> Result<()> { + self.expect_ok(Request::Shutdown) + } + + fn expect_ok(&self, request: Request) -> Result<()> { + match self.request(request)? { + Response::Ok => Ok(()), + response => unexpected(response), + } + } + + #[cfg(unix)] + fn request(&self, request: Request) -> Result { + let mut stream = Connection::connect(&self.registration.socket).with_context(|| { + format!( + "failed to connect to {}", + self.registration.socket.display() + ) + })?; + write_frame( + &mut stream, + &Envelope { + token: self.registration.token.clone(), + request, + }, + )?; + match read_frame(&mut stream)? { + Response::Error { message } => bail!(message), + response => Ok(response), + } + } + + #[cfg(not(unix))] + fn request(&self, _request: Request) -> Result { + bail!("opencode-pty client transport is not implemented on this platform") + } +} + +fn unexpected(response: Response) -> Result { + bail!("unexpected opencode-pty response: {response:?}") +} + +#[cfg(unix)] +impl Drop for TerminalClient { + fn drop(&mut self) { + if let Some((stream, mut child)) = self.owner.take() { + drop(stream); + let _ = child.wait(); + } + } +} + +pub fn run_cli() -> Result<()> { + let mut args = env::args().skip(1); + let command = args.next(); + let mut name = None; + let mut root = None; + let mut positional = Vec::new(); + while let Some(arg) = args.next() { + match arg.as_str() { + "--name" => name = Some(args.next().ok_or_else(|| anyhow!("--name needs a value"))?), + "--runtime-dir" => { + root = Some(PathBuf::from( + args.next() + .ok_or_else(|| anyhow!("--runtime-dir needs a value"))?, + )) + } + _ => positional.push(arg), + } + } + let directory = || -> Result { + let name = name + .as_deref() + .ok_or_else(|| anyhow!("--name is required; use `opencode-pty help`"))?; + runtime_dir(root.as_deref(), name) + }; + let no_arguments = |usage: &str| { + if positional.is_empty() { + Ok(()) + } else { + Err(anyhow!("usage: opencode-pty {usage}")) + } + }; + match command.as_deref() { + Some("daemon") => { + no_arguments("daemon --name NAME [--runtime-dir DIR]")?; + crate::daemon::run(&directory()?) + } + Some("fixture") => run_fixture(), + Some("play") => play(&directory()?), + Some("status") => status(&directory()?), + Some("stop") => stop(&directory()?), + Some("list") => print_terminals(&TerminalClient::discover(&directory()?)?), + Some("watch") => { + let id = positional + .first() + .ok_or_else(|| anyhow!("usage: opencode-pty watch TERMINAL_ID --name NAME"))? + .parse() + .context("expected terminal ID")?; + watch(&directory()?, id) + } + Some("version" | "--version" | "-V") => { + println!( + "opencode-pty {} (protocol {})", + env!("CARGO_PKG_VERSION"), + crate::protocol::PROTOCOL_VERSION + ); + Ok(()) + } + None | Some("help" | "--help" | "-h") => { + print_usage(); + Ok(()) + } + Some(command) => bail!("unknown command {command:?}; use `opencode-pty help`"), + } +} + +fn status(directory: &Path) -> Result<()> { + let client = TerminalClient::discover(directory)?; + println!( + "opencode-pty running: pid={} instance={} terminals={}", + client.registration.pid, + client.registration.instance_id, + client.list()?.len() + ); + Ok(()) +} + +fn stop(directory: &Path) -> Result<()> { + let client = TerminalClient::discover(directory)?; + let pid = client.registration.pid; + client.shutdown()?; + let deadline = Instant::now() + START_TIMEOUT; + while Instant::now() < deadline { + if TerminalClient::discover(directory).is_err() { + println!("stopped opencode-pty pid={pid} (all terminals exited)"); + return Ok(()); + } + thread::sleep(Duration::from_millis(25)); + } + bail!("opencode-pty pid={pid} did not stop within {START_TIMEOUT:?}"); +} + +#[cfg(unix)] +fn watch(directory: &Path, id: TerminalId) -> Result<()> { + let client = TerminalClient::discover(directory)?; + let attachment_id = format!( + "watch-{}-{:016x}", + std::process::id(), + rand::random::() + ); + let mut subscription = + client.subscribe(id, 0, attachment_id, AttachmentRole::Observer, false)?; + io::stdout().write_all(&subscription.replay.bytes)?; + io::stdout().flush()?; + loop { + match subscription.next_event()? { + SubscriptionEvent::Output { bytes, .. } => { + io::stdout().write_all(&bytes)?; + io::stdout().flush()?; + } + SubscriptionEvent::Response(response) => match *response { + Response::Exited { exit_code, .. } => { + eprintln!("\nterminal exited: {exit_code:?}"); + return Ok(()); + } + Response::Resized { cols, rows, .. } => { + eprintln!("\nterminal resized: {cols}x{rows}") + } + Response::ControllerChanged { .. } + | Response::TitleChanged { .. } + | Response::ForegroundProcessChanged { .. } => {} + Response::Error { message } => bail!(message), + response => bail!("unexpected stream response: {response:?}"), + }, + } + } +} + +#[cfg(not(unix))] +fn watch(_directory: &Path, _id: TerminalId) -> Result<()> { + bail!("streaming transport is not implemented on this platform") +} + +fn play(directory: &Path) -> Result<()> { + let client = TerminalClient::start(directory)?; + let mut active = client.list()?.first().map(|terminal| terminal.id); + println!("\n opencode-pty playground"); + println!( + " owning service pid={} · `quit` stops all terminals\n", + client.registration.pid + ); + print_help(); + + let stdin = io::stdin(); + loop { + print!( + "\npty{}> ", + active.map(|id| format!(":{id}")).unwrap_or_default() + ); + io::stdout().flush()?; + let mut line = String::new(); + if stdin.lock().read_line(&mut line)? == 0 { + println!(); + break; + } + let line = line.trim_end(); + if line.is_empty() { + continue; + } + let (command, rest) = line.split_once(char::is_whitespace).unwrap_or((line, "")); + match command { + "new" => { + let words = shell_words::split(rest)?; + let request = if words.is_empty() { + CreateTerminal::shell()? + } else { + CreateTerminal { + title: words[0].clone(), + group_id: format!("playground:{}", env::current_dir()?.display()), + env: std::collections::HashMap::new(), + program: words[0].clone(), + args: words[1..].to_vec(), + cwd: env::current_dir()?, + cols: 100, + rows: 30, + } + }; + let info = client.create(request)?; + active = Some(info.id); + println!("created terminal {} (pid {:?})", info.id, info.pid); + } + "list" => print_terminals(&client)?, + "use" => { + let id = parse_id(rest)?; + client.snapshot(id)?; + active = Some(id); + println!("active terminal is now {id}"); + } + "send" => client.write(require_active(active)?, rest.as_bytes().to_vec())?, + "run" => { + let id = require_active(active)?; + let mut bytes = rest.as_bytes().to_vec(); + bytes.push(b'\r'); + client.write(id, bytes)?; + thread::sleep(Duration::from_millis(80)); + print_snapshot(&client.snapshot(id)?); + } + "screen" => print_snapshot(&client.snapshot(optional_id(rest, active)?)?), + "replay" => { + let mut words = rest.split_whitespace(); + let id = words + .next() + .map(str::parse) + .transpose()? + .or(active) + .ok_or_else(|| anyhow!("no active terminal"))?; + let offset = words.next().map(str::parse).transpose()?.unwrap_or(0); + print_replay(&client.replay(id, offset)?); + } + "resize" => { + let mut words = rest.split_whitespace(); + let cols = words + .next() + .ok_or_else(|| anyhow!("usage: resize COLS ROWS"))? + .parse()?; + let rows = words + .next() + .ok_or_else(|| anyhow!("usage: resize COLS ROWS"))? + .parse()?; + client.resize(require_active(active)?, cols, rows)?; + println!("resized to {cols}x{rows}"); + } + "wait" => { + let millis = if rest.trim().is_empty() { + 250 + } else { + rest.trim().parse()? + }; + thread::sleep(Duration::from_millis(millis)); + if let Some(id) = active { + print_snapshot(&client.snapshot(id)?); + } + } + "kill" => { + let id = optional_id(rest, active)?; + client.terminate(id)?; + if active == Some(id) { + active = client.list()?.first().map(|terminal| terminal.id); + } + println!("terminated terminal {id}"); + } + "demo" => { + let executable = env::current_exe()?; + let info = client.create(CreateTerminal { + title: "query-fixture".to_string(), + group_id: format!("playground:{}", env::current_dir()?.display()), + env: std::collections::HashMap::new(), + program: executable.to_string_lossy().into_owned(), + args: vec!["fixture".to_string()], + cwd: env::current_dir()?, + cols: 100, + rows: 30, + })?; + active = Some(info.id); + thread::sleep(Duration::from_millis(200)); + print_snapshot(&client.snapshot(info.id)?); + } + "help" => print_help(), + "quit" | "exit" => break, + _ => println!("unknown command {command:?}; type `help`"), + } + } + Ok(()) +} + +fn run_fixture() -> Result<()> { + set_stdin_raw()?; + let pid = std::process::id(); + print!("\x1b[1;35mQUERY_FIXTURE pid={pid}\x1b[0m\r\n\x1b[5n"); + io::stdout().flush()?; + let mut response = [0_u8; 4]; + io::stdin().read_exact(&mut response)?; + if response == *b"\x1b[0n" { + println!("\rQUERY_RESPONSE_OK bytes=ESC[0n\r"); + } else { + println!("\rQUERY_RESPONSE_BAD bytes={:?}\r", response.escape_ascii()); + } + println!("FIXTURE_READY type anything; Ctrl-D exits\r"); + io::stdout().flush()?; + let mut buffer = [0_u8; 1024]; + loop { + let length = io::stdin().read(&mut buffer)?; + if length == 0 { + break; + } + io::stdout().write_all(b"echo: ")?; + io::stdout().write_all(&buffer[..length])?; + io::stdout().flush()?; + } + Ok(()) +} + +#[cfg(unix)] +fn set_stdin_raw() -> Result<()> { + use nix::sys::termios::{SetArg, cfmakeraw, tcgetattr, tcsetattr}; + let stdin = io::stdin(); + let mut state = tcgetattr(&stdin)?; + cfmakeraw(&mut state); + tcsetattr(&stdin, SetArg::TCSANOW, &state)?; + Ok(()) +} + +#[cfg(not(unix))] +fn set_stdin_raw() -> Result<()> { + Ok(()) +} + +fn print_usage() { + println!( + "usage: opencode-pty [play|status|list|watch ID|stop|daemon] --name NAME [--runtime-dir DIR]" + ); + println!(" opencode-pty --version"); + println!( + " NAME selects DIR/NAME; DIR defaults to ${{XDG_STATE_HOME:-~/.local/state}}/opencode/pty" + ); +} + +fn print_help() { + println!(" new [PROGRAM ARGS...] create a terminal (default: your shell)"); + println!(" list list persistent terminals"); + println!(" use ID choose the active terminal"); + println!(" run COMMAND send a command and show parsed screen state"); + println!(" send TEXT send bytes without Enter"); + println!(" screen [ID] inspect authoritative libghostty state"); + println!(" replay [ID] [OFFSET] inspect bounded raw replay safely"); + println!(" resize COLS ROWS resize active PTY and parser together"); + println!(" wait [MILLISECONDS] wait and show active screen"); + println!(" kill [ID] terminate and remove a terminal"); + println!(" demo run parser/query-response demo"); + println!(" help | quit quit stops the service and all terminals"); +} + +fn print_terminals(client: &TerminalClient) -> Result<()> { + let terminals = client.list()?; + if terminals.is_empty() { + println!("no terminals; try `new`"); + return Ok(()); + } + for terminal in terminals { + println!( + "{} pid={:?} {}x{} {:?} raw={}..{} {}", + terminal.id, + terminal.pid, + terminal.cols, + terminal.rows, + terminal.lifecycle, + terminal.output_head, + terminal.output_tail, + terminal.title, + ); + } + Ok(()) +} + +fn print_snapshot(snapshot: &RemoteSnapshot) { + println!( + "\n╭─ terminal {} · {}x{} · cursor {},{} · {:?}", + snapshot.terminal.id, + snapshot.terminal.cols, + snapshot.terminal.rows, + snapshot.cursor_x, + snapshot.cursor_y, + snapshot.terminal.lifecycle, + ); + for line in snapshot.text.lines() { + println!("│ {line}"); + } + println!("╰─ checkpoint {} bytes", snapshot.checkpoint.len()); +} + +fn print_replay(replay: &RemoteReplay) { + println!( + "raw {}..{} (requested {}, truncated={})", + replay.available_offset, replay.end_offset, replay.requested_offset, replay.truncated + ); + println!( + "{}", + String::from_utf8_lossy(&replay.bytes).escape_default() + ); +} + +fn parse_id(value: &str) -> Result { + value.trim().parse().context("expected terminal ID") +} + +fn require_active(active: Option) -> Result { + active.ok_or_else(|| anyhow!("no active terminal; use `new` or `use ID`")) +} + +fn optional_id(value: &str, active: Option) -> Result { + if value.trim().is_empty() { + require_active(active) + } else { + parse_id(value) + } +} diff --git a/packages/process-daemon/src/daemon.rs b/packages/process-daemon/src/daemon.rs new file mode 100644 index 000000000000..c3d38a30bf42 --- /dev/null +++ b/packages/process-daemon/src/daemon.rs @@ -0,0 +1,129 @@ +use std::path::{Path, PathBuf}; + +use anyhow::{Result, bail}; +use serde::{Deserialize, Serialize}; + +pub const REGISTRATION_FILE: &str = "service.json"; +pub const LOCK_FILE: &str = "service.lock"; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Registration { + pub instance_id: String, + pub pid: u32, + pub protocol: u32, + /// Opaque local transport endpoint, not necessarily a filesystem entry. + pub socket: PathBuf, + pub token: String, +} + +/// Shared parent of runtime directories, inside OpenCode's state directory. +/// Registration files must not live in temporary directories, which macOS purges +/// while daemons run. +pub fn default_runtime_root() -> PathBuf { + resolve_runtime_root( + std::env::var_os("XDG_STATE_HOME").map(PathBuf::from), + std::env::home_dir(), + ) +} + +fn resolve_runtime_root(state: Option, home: Option) -> PathBuf { + state + .filter(|path| path.is_absolute()) + .or_else(|| home.map(|home| home.join(".local").join("state"))) + .unwrap_or_else(std::env::temp_dir) + .join("opencode") + .join("pty") +} + +/// Resolves `/`, where the name is a single plain path component. +pub fn runtime_dir(root: Option<&Path>, name: &str) -> Result { + let valid = !name.is_empty() + && name != "." + && name != ".." + && name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')); + if !valid { + bail!("invalid runtime name {name:?}; use letters, digits, '.', '_', or '-'"); + } + Ok(root + .map(Path::to_path_buf) + .unwrap_or_else(default_runtime_root) + .join(name)) +} + +pub fn registration_path(directory: &Path) -> PathBuf { + directory.join(REGISTRATION_FILE) +} + +pub fn read_registration(directory: &Path) -> Result { + // Windows also verifies the file's owner and private ACL before trusting it. + #[cfg(windows)] + return platform::read_registration(directory); + #[cfg(not(windows))] + { + use anyhow::Context; + let data = std::fs::read(registration_path(directory)) + .context("opencode-pty registration is unavailable")?; + serde_json::from_slice(&data).context("invalid opencode-pty registration") + } +} + +#[cfg(unix)] +#[path = "daemon/unix.rs"] +mod platform; +#[cfg(windows)] +#[path = "daemon/windows.rs"] +mod platform; +#[cfg(any(unix, windows))] +mod server; +#[cfg(any(unix, windows))] +mod sweep; + +#[cfg(any(unix, windows))] +pub use server::run; + +/// Minimal Windows byte-stream client for integrations using protocol framing. +/// This does not start a daemon or implement the interactive TerminalClient CLI. +#[cfg(windows)] +pub use crate::transport::windows::Connection as PipeConnection; + +#[cfg(not(any(unix, windows)))] +pub fn run(_directory: &Path) -> Result<()> { + anyhow::bail!("persistent opencode-pty transport is not implemented on this platform") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + #[cfg(unix)] + fn runtime_root_avoids_temporary_directories() { + let home = Some(PathBuf::from("/home/user")); + assert_eq!( + resolve_runtime_root(Some("/state".into()), home.clone()), + PathBuf::from("/state/opencode/pty") + ); + assert_eq!( + resolve_runtime_root(Some("relative".into()), home.clone()), + PathBuf::from("/home/user/.local/state/opencode/pty") + ); + assert_eq!( + resolve_runtime_root(None, home), + PathBuf::from("/home/user/.local/state/opencode/pty") + ); + } + + #[test] + fn runtime_names_are_single_components() { + let root = Path::new("/state"); + assert_eq!( + runtime_dir(Some(root), "ee7511b8-7db0.x_1").unwrap(), + root.join("ee7511b8-7db0.x_1") + ); + for name in ["", ".", "..", "a/b", "../x", "with space"] { + assert!(runtime_dir(Some(root), name).is_err(), "{name:?}"); + } + } +} diff --git a/packages/process-daemon/src/daemon/server.rs b/packages/process-daemon/src/daemon/server.rs new file mode 100644 index 000000000000..b3603add9d3e --- /dev/null +++ b/packages/process-daemon/src/daemon/server.rs @@ -0,0 +1,461 @@ +use std::path::Path; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use anyhow::{Context, Result, anyhow}; +use base64::Engine; + +use super::{Registration, platform}; +use crate::ownership::Ownership; +use crate::protocol::{Envelope, Request, Response, read_frame, write_frame, write_output_frame}; +use crate::service::{CreateTerminal, StreamEvent, TerminalService}; +use crate::transport::{Cancellation, Connection}; + +pub fn run(directory: &Path) -> Result<()> { + let (runtime, mut listener) = platform::Runtime::bind(directory)?; + let registration = runtime.registration.clone(); + let registration = ®istration; + let ownership = Arc::new(Mutex::new(Ownership::new(Instant::now()))); + + let service = Arc::new(TerminalService::default()); + let shutdown = Arc::new(AtomicBool::new(false)); + let mut handlers = Vec::<(Cancellation, thread::JoinHandle<()>)>::new(); + let result = (|| -> Result<()> { + while !shutdown.load(Ordering::Acquire) { + if ownership + .lock() + .map_err(|_| anyhow!("ownership lock poisoned"))? + .tick(Instant::now()) + { + shutdown.store(true, Ordering::Release); + break; + } + for (_, handler) in handlers.extract_if(.., |(_, handler)| handler.is_finished()) { + let _ = handler.join(); + } + match listener.accept() { + Ok(Some(stream)) => { + let control = stream.cancellation()?; + let service = Arc::clone(&service); + let shutdown = Arc::clone(&shutdown); + let ownership = Arc::clone(&ownership); + let registration = registration.clone(); + let handle = thread::spawn(move || { + if let Err(error) = handle_connection( + stream, + &service, + ®istration, + &shutdown, + &ownership, + ) { + eprintln!("opencode-pty request failed: {error:#}"); + } + }); + handlers.push((control, handle)); + } + Ok(None) => { + thread::sleep(Duration::from_millis(10)); + } + Err(error) => return Err(error.into()), + } + } + Ok(()) + })(); + + shutdown.store(true, Ordering::Release); + listener.stop(); + // Unblock partial requests, owner reads, and backpressured subscriptions + // before joining. PTY workers still use their existing termination path. + for (cancellation, _) in &handlers { + cancellation.cancel(); + } + let (cleanup_tx, cleanup_rx) = crossbeam_channel::bounded::<()>(1); + let cleanup = runtime.cleanup.clone(); + let watchdog = thread::spawn(move || { + if cleanup_rx.recv_timeout(Duration::from_secs(5)).is_err() { + eprintln!("opencode-pty cleanup timed out; forcing exit"); + let _ = cleanup.run(); + std::process::exit(1); + } + }); + service.shutdown(); + for (_, handler) in handlers { + let _ = handler.join(); + } + drop(service); + let cleanup = runtime.finish(); + drop(listener); + let _ = cleanup_tx.send(()); + let _ = watchdog.join(); + result.and(cleanup) +} + +fn handle_connection( + mut stream: Connection, + service: &TerminalService, + registration: &Registration, + shutdown: &AtomicBool, + ownership: &Mutex, +) -> Result<()> { + let envelope: Envelope = read_frame(&mut stream)?; + if envelope.token != registration.token { + return send_response( + &mut stream, + &Response::Error { + message: "authentication failed".to_string(), + }, + ); + } + if let Request::Own { + instance_id, + ticket, + } = envelope.request + { + let claim = if instance_id != registration.instance_id { + Err(anyhow!("daemon instance_id mismatch")) + } else if shutdown.load(Ordering::Acquire) { + Err(anyhow!("daemon is stopping")) + } else { + ownership + .lock() + .map_err(|_| anyhow!("ownership lock poisoned"))? + .claim(ticket.as_deref(), Instant::now()) + }; + let generation = match claim { + Ok(generation) => generation, + Err(error) => { + return send_response( + &mut stream, + &Response::Error { + message: error.to_string(), + }, + ); + } + }; + let result = owner_connection(&mut stream, registration, shutdown, ownership, generation); + ownership + .lock() + .map_err(|_| anyhow!("ownership lock poisoned"))? + .disconnect(generation, Instant::now()); + return result; + } + if let Request::Subscribe { + id, + offset, + attachment_id, + role, + takeover, + } = envelope.request + { + return stream_subscription( + &mut stream, + service, + shutdown, + SubscriptionRequest { + id, + offset, + attachment_id, + role, + takeover, + }, + ); + } + let stopping = matches!(envelope.request, Request::Shutdown); + let response = + dispatch(envelope.request, service, registration).unwrap_or_else(|error| Response::Error { + message: format!("{error:#}"), + }); + let result = send_response(&mut stream, &response); + if stopping { + shutdown.store(true, Ordering::Release); + } + result +} + +fn owner_connection( + stream: &mut Connection, + registration: &Registration, + shutdown: &AtomicBool, + ownership: &Mutex, + generation: u64, +) -> Result<()> { + write_frame(&mut *stream, &Response::Owned)?; + while !shutdown.load(Ordering::Acquire) { + let envelope: Envelope = read_frame(&mut *stream)?; + if !ownership + .lock() + .map_err(|_| anyhow!("ownership lock poisoned"))? + .is_owner(generation) + { + return send_response( + stream, + &Response::Error { + message: "owner connection has been superseded".to_string(), + }, + ); + } + let stopping = + envelope.token == registration.token && matches!(envelope.request, Request::Shutdown); + let response = if envelope.token != registration.token { + Response::Error { + message: "authentication failed".to_string(), + } + } else { + match envelope.request { + Request::PrepareHandoff => { + let handoff = ownership + .lock() + .map_err(|_| anyhow!("ownership lock poisoned"))? + .prepare( + generation, + Instant::now(), + SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis() as u64, + )?; + Response::Handoff { + ticket: handoff.ticket, + expires_at: handoff.expires_at, + } + } + Request::Shutdown => Response::Ok, + _ => Response::Error { + message: "owner connection only accepts prepare_handoff or shutdown" + .to_string(), + }, + } + }; + if stopping { + let result = send_response(stream, &response); + // A takeover may have occurred while writing the response. + if ownership + .lock() + .map_err(|_| anyhow!("ownership lock poisoned"))? + .is_owner(generation) + { + shutdown.store(true, Ordering::Release); + } + return result; + } + write_frame(&mut *stream, &response)?; + } + Ok(()) +} + +struct SubscriptionRequest { + id: crate::service::TerminalId, + offset: u64, + attachment_id: String, + role: crate::protocol::AttachmentRole, + takeover: bool, +} + +fn stream_subscription( + stream: &mut Connection, + service: &TerminalService, + shutdown: &AtomicBool, + request: SubscriptionRequest, +) -> Result<()> { + let attachment = service.attach( + request.id, + request.offset, + request.attachment_id, + request.role, + request.takeover, + )?; + write_frame( + &mut *stream, + &Response::Attached { + terminal: attachment.terminal.clone(), + role: attachment.role, + generation: attachment.generation, + requested_offset: attachment.replay.requested_offset, + available_offset: attachment.replay.available_offset, + end_offset: attachment.replay.end_offset, + truncated: attachment.replay.truncated, + replay_base64: base64::engine::general_purpose::STANDARD + .encode(&attachment.replay.bytes), + }, + )?; + let monitor = stream.monitor_disconnect()?; + let result = (|| loop { + if shutdown.load(Ordering::Acquire) { + break Ok(()); + } + let event = crossbeam_channel::select! { + recv(monitor.disconnected()) -> _ => break Ok(()), + recv(attachment.events) -> event => match event { + Ok(event) => event, + Err(_) => break Ok(()), + }, + default(Duration::from_millis(100)) => continue, + }; + let response = match event { + StreamEvent::Output { start, end, bytes } => { + write_output_frame(&mut *stream, start, end, &bytes)?; + continue; + } + StreamEvent::Resized { + cols, + rows, + generation, + checkpoint, + } => Response::Resized { + cols, + rows, + generation, + checkpoint_base64: base64::engine::general_purpose::STANDARD.encode(checkpoint), + }, + StreamEvent::Exited { + exit_code, + final_offset, + } => Response::Exited { + exit_code, + final_offset, + }, + StreamEvent::ControllerChanged { + attachment_id, + generation, + } => Response::ControllerChanged { + attachment_id, + generation, + }, + StreamEvent::TitleChanged { title } => Response::TitleChanged { title }, + StreamEvent::ForegroundProcessChanged { process } => { + Response::ForegroundProcessChanged { process } + } + }; + write_frame(&mut *stream, &response)?; + if matches!(response, Response::Exited { .. }) { + break Ok(()); + } + })(); + monitor.finish(); + result +} + +fn send_response(stream: &mut Connection, response: &Response) -> Result<()> { + write_frame(&mut *stream, response)?; + stream.finish_response()?; + Ok(()) +} + +fn dispatch( + request: Request, + service: &TerminalService, + registration: &Registration, +) -> Result { + Ok(match request { + Request::Ping => Response::Pong { + instance_id: registration.instance_id.clone(), + pid: registration.pid, + protocol: registration.protocol, + }, + Request::Create { + program, + args, + cwd, + title, + group_id, + env, + cols, + rows, + } => Response::Created { + terminal: service.create(CreateTerminal { + program, + args, + cwd, + title, + group_id, + env, + cols, + rows, + })?, + }, + Request::List => Response::Terminals { + terminals: service.list()?, + }, + Request::Write { + id, + attachment_id, + data_base64, + } => { + let bytes = base64::engine::general_purpose::STANDARD + .decode(data_base64) + .context("invalid input base64")?; + service.write_for(id, attachment_id, bytes)?; + Response::Ok + } + Request::Resize { + id, + attachment_id, + cols, + rows, + } => { + service.resize_for(id, attachment_id, cols, rows)?; + Response::Ok + } + Request::Control { + id, + attachment_id, + cols, + rows, + } => { + service.control(id, attachment_id, cols, rows)?; + Response::Ok + } + Request::Input { + id, + attachment_id, + cols, + rows, + data_base64, + } => { + let bytes = base64::engine::general_purpose::STANDARD + .decode(data_base64) + .context("invalid input base64")?; + service.input(id, attachment_id, cols, rows, bytes)?; + Response::Ok + } + Request::Snapshot { id } => { + let snapshot = service.snapshot(id)?; + Response::Snapshot { + terminal: snapshot.info, + text: snapshot.text, + checkpoint_base64: base64::engine::general_purpose::STANDARD + .encode(snapshot.checkpoint), + cursor_x: snapshot.cursor_x, + cursor_y: snapshot.cursor_y, + } + } + Request::ReadRows { id, rows } => { + let rows = service.read_rows(id, rows)?; + Response::Rows { + terminal: rows.terminal, + lines: rows.lines, + cursor_x: rows.cursor_x, + cursor_y: rows.cursor_y, + } + } + Request::Replay { id, offset } => { + let replay = service.replay(id, offset)?; + Response::Replay { + requested_offset: replay.requested_offset, + available_offset: replay.available_offset, + end_offset: replay.end_offset, + truncated: replay.truncated, + data_base64: base64::engine::general_purpose::STANDARD.encode(replay.bytes), + } + } + Request::Subscribe { .. } => unreachable!("subscriptions are handled before dispatch"), + Request::Terminate { id } => { + service.terminate(id)?; + Response::Ok + } + Request::Shutdown => Response::Ok, + Request::Own { .. } => unreachable!("ownership is handled before dispatch"), + Request::PrepareHandoff => Response::Error { + message: "handoff requires the owner connection".to_string(), + }, + }) +} diff --git a/packages/process-daemon/src/daemon/sweep.rs b/packages/process-daemon/src/daemon/sweep.rs new file mode 100644 index 000000000000..653c6ab266a3 --- /dev/null +++ b/packages/process-daemon/src/daemon/sweep.rs @@ -0,0 +1,222 @@ +use std::fs::{self, OpenOptions}; +use std::path::Path; +use std::time::{Duration, SystemTime}; + +use anyhow::Result; +use fs2::FileExt; + +use super::{LOCK_FILE, REGISTRATION_FILE, Registration, platform, registration_path}; + +pub(super) const STALE_RUNTIME_AGE: Duration = Duration::from_secs(10 * 60); + +/// Removes sibling runtime directories left by daemons that crashed. +pub(super) fn stale_runtimes(directory: &Path) { + let Some(parent) = directory.parent() else { + return; + }; + let Ok(entries) = fs::read_dir(parent) else { + return; + }; + for entry in entries.flatten() { + let candidate = entry.path(); + if same_path(&candidate, directory) { + continue; + } + if let Err(error) = runtime(&candidate, SystemTime::now()) { + eprintln!( + "opencode-pty could not remove stale runtime {}: {error:#}", + candidate.display() + ); + } + } +} + +fn same_path(left: &Path, right: &Path) -> bool { + matches!( + (fs::canonicalize(left), fs::canonicalize(right)), + (Ok(left), Ok(right)) if left == right + ) +} + +pub(super) fn runtime(directory: &Path, now: SystemTime) -> Result<()> { + // Symlinks and Windows junctions are never followed or removed. + let metadata = fs::symlink_metadata(directory)?; + if !metadata.is_dir() || metadata.file_type().is_symlink() { + return Ok(()); + } + let age = now.duration_since(metadata.modified()?).unwrap_or_default(); + if age < STALE_RUNTIME_AGE { + return Ok(()); + } + let mut files = Vec::new(); + for entry in fs::read_dir(directory)? { + let entry = entry?; + let name = entry.file_name(); + let name = name.to_string_lossy(); + let known = name == LOCK_FILE + || name == REGISTRATION_FILE + || (name.starts_with("service.") && name.ends_with(".tmp")); + if !known || !entry.file_type()?.is_file() { + // Not a runtime directory this daemon created. + return Ok(()); + } + files.push(entry.path()); + } + + // Only a lock file or a parseable registration proves a daemon created this + // directory; anything else, including an empty directory, is left alone. + let lock = match OpenOptions::new() + .read(true) + .write(true) + .open(directory.join(LOCK_FILE)) + { + Ok(lock) => { + if lock.try_lock_exclusive().is_err() { + return Ok(()); + } + Some(lock) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + let Some(registration) = fs::read(registration_path(directory)) + .ok() + .and_then(|data| serde_json::from_slice::(&data).ok()) + else { + return Ok(()); + }; + if platform::process_exists(registration.pid) { + return Ok(()); + } + None + } + Err(error) => return Err(error.into()), + }; + + platform::remove_stale_endpoint(&fs::canonicalize(directory)?)?; + // Remove the lock last so a racing daemon cannot claim a half-removed directory. + files.sort_by_key(|file| file.ends_with(LOCK_FILE)); + for file in files { + fs::remove_file(file)?; + } + // Windows deletes an open file only when its last handle closes. + drop(lock); + fs::remove_dir(directory)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::*; + use crate::protocol::PROTOCOL_VERSION; + + fn random_id() -> String { + format!("{:032x}", rand::random::()) + } + + struct Root(PathBuf); + + impl Root { + fn new() -> Self { + let root = + std::env::temp_dir().join(format!("opencode-pty-sweep-test-{}", random_id())); + fs::create_dir_all(&root).unwrap(); + Self(root) + } + + fn runtime(&self, files: &[&str], pid: Option) -> PathBuf { + let directory = self.0.join(random_id()); + fs::create_dir(&directory).unwrap(); + for file in files { + fs::write(directory.join(file), b"").unwrap(); + } + if let Some(pid) = pid { + let registration = Registration { + instance_id: random_id(), + pid, + protocol: PROTOCOL_VERSION, + socket: PathBuf::from("/nonexistent.sock"), + token: random_id(), + }; + fs::write( + directory.join(REGISTRATION_FILE), + serde_json::to_vec(®istration).unwrap(), + ) + .unwrap(); + } + directory + } + } + + impl Drop for Root { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + fn later() -> SystemTime { + SystemTime::now() + STALE_RUNTIME_AGE + Duration::from_secs(1) + } + + fn dead_pid() -> u32 { + #[cfg(unix)] + let mut child = std::process::Command::new("/usr/bin/true").spawn().unwrap(); + #[cfg(windows)] + let mut child = std::process::Command::new("cmd.exe") + .args(["/C", "exit 0"]) + .spawn() + .unwrap(); + let pid = child.id(); + child.wait().unwrap(); + pid + } + + #[test] + fn sweep_removes_only_abandoned_old_runtimes() { + let root = Root::new(); + let abandoned = root.runtime(&[LOCK_FILE, REGISTRATION_FILE], None); + let young = root.runtime(&[LOCK_FILE], None); + let foreign = root.runtime(&[LOCK_FILE, "notes.txt"], None); + let locked = root.runtime(&[LOCK_FILE], None); + let held = fs::File::open(locked.join(LOCK_FILE)).unwrap(); + held.try_lock_exclusive().unwrap(); + + runtime(&abandoned, later()).unwrap(); + runtime(&young, SystemTime::now()).unwrap(); + runtime(&foreign, later()).unwrap(); + runtime(&locked, later()).unwrap(); + + assert!(!abandoned.exists()); + assert!(young.join(LOCK_FILE).exists()); + assert!(foreign.join("notes.txt").exists()); + assert!(locked.join(LOCK_FILE).exists()); + drop(held); + } + + #[test] + fn sweep_without_lock_file_checks_registered_pid() { + let root = Root::new(); + let live = root.runtime(&[], Some(std::process::id())); + let dead = root.runtime(&[], Some(dead_pid())); + + runtime(&live, later()).unwrap(); + runtime(&dead, later()).unwrap(); + + assert!(live.join(REGISTRATION_FILE).exists()); + assert!(!dead.exists()); + } + + #[test] + fn sweep_keeps_directories_without_daemon_evidence() { + let root = Root::new(); + let empty = root.runtime(&[], None); + let foreign = root.runtime(&[], None); + fs::write(foreign.join(REGISTRATION_FILE), br#"{"not":"ours"}"#).unwrap(); + let temporary = root.runtime(&["service.important.tmp"], None); + + for directory in [&empty, &foreign, &temporary] { + runtime(directory, later()).unwrap(); + assert!(directory.exists(), "{}", directory.display()); + } + } +} diff --git a/packages/process-daemon/src/daemon/unix.rs b/packages/process-daemon/src/daemon/unix.rs new file mode 100644 index 000000000000..30e2a0c5cbf4 --- /dev/null +++ b/packages/process-daemon/src/daemon/unix.rs @@ -0,0 +1,261 @@ +use std::fs::{self, File, OpenOptions}; +use std::io::Write; +use std::os::unix::ffi::OsStrExt; +use std::os::unix::fs::{FileTypeExt, MetadataExt, OpenOptionsExt, PermissionsExt}; +use std::path::{Path, PathBuf}; +use std::thread::{self, JoinHandle}; + +use anyhow::{Context, Result, anyhow}; +use fs2::FileExt; +use sha2::{Digest, Sha256}; + +use super::{LOCK_FILE, Registration, read_registration, registration_path}; +use crate::protocol::PROTOCOL_VERSION; +use crate::transport::Listener; + +pub(super) struct Runtime { + pub registration: Registration, + pub cleanup: Cleanup, + sweeper: JoinHandle<()>, + // Held until the shared server has joined its handlers and removed registration. + lock: File, +} + +impl Runtime { + pub fn bind(directory: &Path) -> Result<(Self, Listener)> { + let directory = directory.to_path_buf(); + fs::create_dir_all(&directory)?; + fs::set_permissions(&directory, fs::Permissions::from_mode(0o700))?; + let lock = OpenOptions::new() + .create(true) + .truncate(false) + .read(true) + .write(true) + .open(directory.join(LOCK_FILE))?; + lock.try_lock_exclusive() + .context("another opencode-pty process already owns the service lock")?; + + let socket_path = socket_path(&directory)?; + if socket_path.exists() { + fs::remove_file(&socket_path)?; + } + let listener = Listener::bind(&socket_path)?; + fs::set_permissions(&socket_path, fs::Permissions::from_mode(0o600))?; + let socket = SocketFile::bound(socket_path)?; + let registration = Registration { + instance_id: random_id(), + pid: std::process::id(), + protocol: PROTOCOL_VERSION, + socket: socket.path.clone(), + token: random_id(), + }; + write_registration(&directory, ®istration)?; + let sweeper = { + let directory = directory.clone(); + thread::spawn(move || super::sweep::stale_runtimes(&directory)) + }; + Ok(( + Self { + cleanup: Cleanup { + directory, + registration: registration.clone(), + socket, + }, + registration, + sweeper, + lock, + }, + listener, + )) + } + + /// Removes this daemon's runtime state after the server has joined its handlers. + pub fn finish(self) -> Result<()> { + let _ = self.sweeper.join(); + let result = self.cleanup.run(); + drop(self.lock); + result + } +} + +/// Removes only state that still belongs to this daemon instance. +#[derive(Clone)] +pub(super) struct Cleanup { + directory: PathBuf, + registration: Registration, + socket: SocketFile, +} + +impl Cleanup { + pub fn run(&self) -> Result<()> { + let result = remove_if_current(&self.directory, &self.registration); + self.socket.remove_if_current(); + // Remove the lock file before releasing it: a daemon that already opened it + // fails to lock it, and a later one creates a new lock file. + let _ = fs::remove_file(self.directory.join(LOCK_FILE)); + let _ = fs::remove_dir(&self.directory); + result + } +} + +/// The bound socket's inode, so cleanup never unlinks a successor's socket. +#[derive(Clone)] +struct SocketFile { + path: PathBuf, + device: u64, + inode: u64, +} + +impl SocketFile { + fn bound(path: PathBuf) -> Result { + let metadata = fs::symlink_metadata(&path)?; + Ok(Self { + path, + device: metadata.dev(), + inode: metadata.ino(), + }) + } + + fn remove_if_current(&self) { + if fs::symlink_metadata(&self.path) + .is_ok_and(|metadata| metadata.dev() == self.device && metadata.ino() == self.inode) + { + let _ = fs::remove_file(&self.path); + } + } +} + +pub(super) fn process_exists(pid: u32) -> bool { + let Ok(pid) = i32::try_from(pid) else { + return false; + }; + // SAFETY: signal zero only checks whether the PID exists. + let exists = unsafe { libc::kill(pid, 0) } == 0; + exists || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM) +} + +/// Removes the socket of an abandoned runtime found by the shared sweep. +pub(super) fn remove_stale_endpoint(canonical_directory: &Path) -> Result<()> { + let socket = socket_file(&socket_root(), canonical_directory); + if fs::symlink_metadata(&socket).is_ok_and(|metadata| metadata.file_type().is_socket()) { + fs::remove_file(&socket)?; + } + Ok(()) +} + +fn socket_path(directory: &Path) -> Result { + let directory = + fs::canonicalize(directory).context("failed to resolve PTY runtime directory")?; + let root = socket_root(); + ensure_private_directory(&root)?; + Ok(socket_file(&root, &directory)) +} + +// Sockets stay in /tmp so their paths fit sun_path; temp cleaners skip sockets. +fn socket_root() -> PathBuf { + PathBuf::from("/tmp").join(format!( + "opencode-pty-{}", + nix::unistd::Uid::effective().as_raw() + )) +} + +fn socket_file(root: &Path, canonical_directory: &Path) -> PathBuf { + let digest = Sha256::digest(canonical_directory.as_os_str().as_bytes()); + let name = digest[..16] + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + root.join(format!("{name}.sock")) +} + +fn ensure_private_directory(directory: &Path) -> Result<()> { + fs::create_dir_all(directory)?; + let metadata = fs::symlink_metadata(directory)?; + if !metadata.is_dir() || metadata.file_type().is_symlink() { + return Err(anyhow!( + "PTY socket directory is not a real directory: {}", + directory.display() + )); + } + let uid = nix::unistd::Uid::effective().as_raw(); + if metadata.uid() != uid { + return Err(anyhow!( + "PTY socket directory {} is owned by uid {}, expected {uid}", + directory.display(), + metadata.uid() + )); + } + fs::set_permissions(directory, fs::Permissions::from_mode(0o700))?; + Ok(()) +} + +fn write_registration(directory: &Path, registration: &Registration) -> Result<()> { + let temporary = directory.join(format!("service.{}.tmp", registration.instance_id)); + let data = serde_json::to_vec_pretty(registration)?; + let mut file = OpenOptions::new() + .create_new(true) + .write(true) + .mode(0o600) + .open(&temporary)?; + file.write_all(&data)?; + file.sync_all()?; + fs::rename(&temporary, registration_path(directory))?; + Ok(()) +} + +fn remove_if_current(directory: &Path, registration: &Registration) -> Result<()> { + if read_registration(directory) + .is_ok_and(|current| current.instance_id == registration.instance_id) + { + fs::remove_file(registration_path(directory))?; + } + Ok(()) +} + +fn random_id() -> String { + format!("{:032x}", rand::random::()) +} + +#[cfg(test)] +mod tests { + use std::os::unix::net::UnixListener; + + use super::*; + + #[test] + fn socket_paths_are_short_and_runtime_specific() { + let base = std::env::temp_dir().join(format!("opencode-pty-socket-test-{}", random_id())); + let first = base.join("a".repeat(120)).join("database-a"); + let second = base.join("b".repeat(120)).join("database-b"); + fs::create_dir_all(&first).unwrap(); + fs::create_dir_all(&second).unwrap(); + + let first_socket = socket_path(&first).unwrap(); + let second_socket = socket_path(&second).unwrap(); + + assert_ne!(first_socket, second_socket); + assert!(first_socket.as_os_str().as_bytes().len() < 104); + assert_eq!(first_socket.parent(), second_socket.parent()); + + fs::remove_dir_all(base).unwrap(); + } + + #[test] + fn sweep_removes_abandoned_socket() { + let root = std::env::temp_dir().join(format!("opencode-pty-sweep-test-{}", random_id())); + let abandoned = root.join(random_id()); + fs::create_dir_all(&abandoned).unwrap(); + fs::write(abandoned.join(LOCK_FILE), b"").unwrap(); + let socket = socket_path(&abandoned).unwrap(); + drop(UnixListener::bind(&socket).unwrap()); + + let later = std::time::SystemTime::now() + + super::super::sweep::STALE_RUNTIME_AGE + + std::time::Duration::from_secs(1); + super::super::sweep::runtime(&abandoned, later).unwrap(); + + assert!(!abandoned.exists()); + assert!(!socket.exists()); + fs::remove_dir_all(root).unwrap(); + } +} diff --git a/packages/process-daemon/src/daemon/windows.rs b/packages/process-daemon/src/daemon/windows.rs new file mode 100644 index 000000000000..7bce7d9ccf19 --- /dev/null +++ b/packages/process-daemon/src/daemon/windows.rs @@ -0,0 +1,403 @@ +use std::ffi::OsString; +use std::fs::{self, File}; +use std::io::{Read, Write}; +use std::os::windows::ffi::{OsStrExt, OsStringExt}; +use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}; +use std::path::{Path, PathBuf}; +use std::ptr::null_mut; +use std::thread::{self, JoinHandle}; + +use anyhow::{Context, Result, bail}; +use fs2::FileExt; +use windows_sys::Win32::Foundation::{ + ERROR_ACCESS_DENIED, ERROR_ALREADY_EXISTS, GENERIC_READ, GENERIC_WRITE, WAIT_OBJECT_0, +}; +use windows_sys::Win32::Storage::FileSystem::{ + BY_HANDLE_FILE_INFORMATION, CREATE_NEW, CreateDirectoryW, CreateFileW, DELETE, + FILE_ATTRIBUTE_DIRECTORY, FILE_ATTRIBUTE_REPARSE_POINT, FILE_FLAG_BACKUP_SEMANTICS, + FILE_FLAG_OPEN_REPARSE_POINT, FILE_READ_ATTRIBUTES, FILE_RENAME_INFO, FILE_RENAME_INFO_0, + FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, FILE_TRAVERSE, FileRenameInfoEx, + GetFileInformationByHandle, GetFinalPathNameByHandleW, OPEN_ALWAYS, OPEN_EXISTING, + READ_CONTROL, SetFileInformationByHandle, WRITE_DAC, +}; +use windows_sys::Win32::System::Threading::{ + OpenProcess, PROCESS_SYNCHRONIZE, WaitForSingleObject, +}; +use windows_sys::Win32::System::WindowsProgramming::{ + FILE_RENAME_FLAG_POSIX_SEMANTICS, FILE_RENAME_FLAG_REPLACE_IF_EXISTS, +}; + +use super::{LOCK_FILE, REGISTRATION_FILE, Registration, registration_path, sweep}; +use crate::protocol::PROTOCOL_VERSION; +use crate::transport::Listener; +use crate::transport::windows::{owned_handle, security::PrivateSecurity}; + +pub(super) fn read_registration(directory: &Path) -> Result { + let security = PrivateSecurity::new()?; + let mut file = open( + ®istration_path(directory), + GENERIC_READ | READ_CONTROL, + OPEN_EXISTING, + FILE_SHARE_READ | FILE_SHARE_DELETE, + &security, + ) + .context("opencode-pty registration is unavailable")?; + check_kind(&file, false)?; + security.check_private(file.as_raw_handle())?; + let mut data = Vec::new(); + // Registration is tiny; do not allocate an unbounded stale/corrupt file. + Read::by_ref(&mut file) + .take(16 * 1024 + 1) + .read_to_end(&mut data)?; + if data.len() > 16 * 1024 { + bail!("PTY registration is too large"); + } + serde_json::from_slice(&data).context("invalid opencode-pty registration") +} + +pub(super) struct Runtime { + pub registration: Registration, + pub cleanup: Cleanup, + sweeper: JoinHandle<()>, + // Neither handle shares delete access, so the private directory and lock + // cannot be replaced while the daemon runs. + lock: File, + directory: File, +} + +impl Runtime { + pub fn bind(directory: &Path) -> Result<(Self, Listener)> { + if !directory.is_absolute() { + bail!("PTY runtime directory must be an absolute path"); + } + let security = PrivateSecurity::new()?; + if let Some(parent) = directory.parent() { + fs::create_dir_all(parent)?; + } + let wide = wide_path(directory)?; + // SAFETY: wide path and private descriptor are valid through creation. + if unsafe { CreateDirectoryW(wide.as_ptr(), &security.attributes()) } == 0 { + let error = std::io::Error::last_os_error(); + if error.raw_os_error() != Some(ERROR_ALREADY_EXISTS as i32) { + return Err(error.into()); + } + } + let directory_handle = open( + directory, + FILE_READ_ATTRIBUTES | FILE_TRAVERSE | READ_CONTROL | WRITE_DAC, + OPEN_EXISTING, + FILE_SHARE_READ | FILE_SHARE_WRITE, + &security, + )?; + check_kind(&directory_handle, true)?; + security.make_private(directory_handle.as_raw_handle())?; + // All later paths come from the held handle, never the caller's path. + let directory = held_directory_path(&directory_handle)?; + let lock = open( + &directory.join(LOCK_FILE), + GENERIC_READ | GENERIC_WRITE | READ_CONTROL | WRITE_DAC, + OPEN_ALWAYS, + FILE_SHARE_READ | FILE_SHARE_WRITE, + &security, + )?; + check_kind(&lock, false)?; + security.make_private(lock.as_raw_handle())?; + lock.try_lock_exclusive() + .context("another opencode-pty process already owns the service lock")?; + + let instance_id = format!("{:032x}", rand::random::()); + let registration = Registration { + socket: PathBuf::from(format!(r"\\.\pipe\opencode-pty-{instance_id}")), + instance_id, + pid: std::process::id(), + protocol: PROTOCOL_VERSION, + token: format!("{:032x}", rand::random::()), + }; + let listener = Listener::bind(®istration.socket)?; + write_registration(&directory, &directory_handle, ®istration, &security)?; + let sweeper = { + let directory = directory.clone(); + thread::spawn(move || sweep::stale_runtimes(&directory)) + }; + Ok(( + Self { + cleanup: Cleanup { + directory, + instance_id: registration.instance_id.clone(), + }, + registration, + sweeper, + lock, + directory: directory_handle, + }, + listener, + )) + } + + /// Removes this daemon's runtime state after the server has joined its + /// handlers. The caller keeps the pipe namespace until this returns. + pub fn finish(self) -> Result<()> { + let _ = self.sweeper.join(); + let result = self.cleanup.run(); + let directory = self.cleanup.directory; + // The protected handles deny deletion; release them first. A daemon that + // races in and locks a new lock file simply keeps the directory. + drop(self.lock); + drop(self.directory); + let _ = fs::remove_file(directory.join(LOCK_FILE)); + let _ = fs::remove_dir(&directory); + result + } +} + +/// Removes the registration only while it still belongs to this instance. +#[derive(Clone)] +pub(super) struct Cleanup { + directory: PathBuf, + instance_id: String, +} + +impl Cleanup { + pub fn run(&self) -> Result<()> { + if read_registration(&self.directory) + .is_ok_and(|current| current.instance_id == self.instance_id) + { + fs::remove_file(registration_path(&self.directory))?; + } + // A named pipe is not a filesystem entry. Its listener remains held until + // this registration cleanup has completed, then all handles are closed. + Ok(()) + } +} + +pub(super) fn process_exists(pid: u32) -> bool { + // SAFETY: a successful handle is adopted immediately; it is only waited on. + let handle = unsafe { OpenProcess(PROCESS_SYNCHRONIZE, 0, pid) }; + if handle.is_null() { + // Another user's live process cannot be opened; treat it as live. + return std::io::Error::last_os_error().raw_os_error() == Some(ERROR_ACCESS_DENIED as i32); + } + // SAFETY: OpenProcess returned a new, uniquely owned handle. + let handle = unsafe { OwnedHandle::from_raw_handle(handle) }; + // A failed wait is treated as live, so the sweep never deletes on doubt. + // SAFETY: the owned process handle stays valid through this zero-timeout wait. + unsafe { WaitForSingleObject(handle.as_raw_handle(), 0) != WAIT_OBJECT_0 } +} + +/// Named pipes vanish with their server process; there is no endpoint to remove. +pub(super) fn remove_stale_endpoint(_canonical_directory: &Path) -> Result<()> { + Ok(()) +} + +fn write_registration( + directory: &Path, + directory_handle: &File, + registration: &Registration, + security: &PrivateSecurity, +) -> Result<()> { + let temporary = directory.join(format!("service.{}.tmp", registration.instance_id)); + let result = (|| -> Result<()> { + let mut file = open( + &temporary, + GENERIC_WRITE | READ_CONTROL | DELETE, + CREATE_NEW, + 0, + security, + )?; + file.write_all(&serde_json::to_vec_pretty(registration)?)?; + file.sync_all()?; + publish(&file, directory_handle) + })(); + if result.is_err() { + let _ = fs::remove_file(temporary); + } + result +} + +fn publish(file: &File, directory: &File) -> Result<()> { + // The Win32 wrapper rejects a non-null RootDirectory, unlike the native + // NtSetInformationFile interface. Resolve the full destination from the held + // directory handle, never from the unvalidated caller path. + let name = held_directory_path(directory)? + .join(REGISTRATION_FILE) + .as_os_str() + .encode_wide() + .collect::>(); + let name_bytes = u32::try_from(size_of_val(name.as_slice()))?; + let bytes = size_of::() + .checked_add(name_bytes as usize) + .context("registration rename buffer overflow")?; + let buffer_bytes = u32::try_from(bytes)?; + // FILE_RENAME_INFO has a variable-length trailing UTF-16 name. usize gives + // this buffer the struct's alignment on both supported 64-bit architectures. + const { + assert!(align_of::() <= align_of::()); + } + let mut buffer = vec![0_usize; bytes.div_ceil(size_of::())]; + let allocation = buffer.as_mut_ptr().cast::(); + let info = allocation.cast::(); + // SAFETY: the zeroed, aligned allocation covers the header and complete name. + // Use raw pointers into the whole allocation, not a borrow of FileName[1] + // whose extent is smaller than the variable-sized trailing data. + unsafe { + (&raw mut (*info).Anonymous).write(FILE_RENAME_INFO_0 { + Flags: FILE_RENAME_FLAG_REPLACE_IF_EXISTS | FILE_RENAME_FLAG_POSIX_SEMANTICS, + }); + (&raw mut (*info).RootDirectory).write(null_mut()); + (&raw mut (*info).FileNameLength).write(name_bytes); + let destination = allocation + .add(std::mem::offset_of!(FILE_RENAME_INFO, FileName)) + .cast::(); + std::ptr::copy_nonoverlapping(name.as_ptr(), destination, name.len()); + // Unlike ordinary MoveFileEx replacement, POSIX semantics preserve old + // reader handles while new opens see the new file, with no missing-name + // interval. Source and destination directory stay held throughout; the + // source is never reopened and no inherited ACL/metadata merge occurs. + if SetFileInformationByHandle( + file.as_raw_handle(), + FileRenameInfoEx, + info.cast(), + buffer_bytes, + ) == 0 + { + return Err(std::io::Error::last_os_error()) + .context("failed to atomically publish PTY registration"); + } + } + Ok(()) +} + +fn held_directory_path(directory: &File) -> Result { + // SAFETY: query the normalized DOS path length for a live directory handle. + let length = unsafe { GetFinalPathNameByHandleW(directory.as_raw_handle(), null_mut(), 0, 0) }; + if length == 0 { + return Err(std::io::Error::last_os_error().into()); + } + let mut path = vec![0_u16; length as usize]; + // SAFETY: the buffer has the requested length and the directory stays held. + let written = unsafe { + GetFinalPathNameByHandleW(directory.as_raw_handle(), path.as_mut_ptr(), length, 0) + }; + if written == 0 { + return Err(std::io::Error::last_os_error().into()); + } + if written >= length { + bail!("PTY runtime directory path changed during publication"); + } + Ok(PathBuf::from(OsString::from_wide( + &path[..written as usize], + ))) +} + +fn open( + path: &Path, + access: u32, + creation: u32, + sharing: u32, + security: &PrivateSecurity, +) -> Result { + let path = wide_path(path)?; + // SAFETY: all pointers are valid; OPEN_REPARSE_POINT lets check_kind reject + // links rather than inspecting a different target after following them. + let handle = unsafe { + CreateFileW( + path.as_ptr(), + access, + sharing, + &security.attributes(), + creation, + FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS, + null_mut(), + ) + }; + Ok(File::from(owned_handle(handle)?)) +} + +fn check_kind(file: &File, directory: bool) -> Result<()> { + let mut info = BY_HANDLE_FILE_INFORMATION::default(); + // SAFETY: file owns a live handle; info is valid writable output. + if unsafe { GetFileInformationByHandle(file.as_raw_handle(), &mut info) } == 0 { + return Err(std::io::Error::last_os_error().into()); + } + if info.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + bail!("PTY storage must not be a reparse point"); + } + if (info.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY != 0) != directory { + bail!("unexpected PTY storage file type"); + } + Ok(()) +} + +fn wide_path(path: &Path) -> Result> { + let mut wide = path.as_os_str().encode_wide().collect::>(); + if wide.contains(&0) { + bail!("PTY storage path contains NUL"); + } + wide.push(0); + Ok(wide) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn registration_is_private_and_atomically_replaceable_with_a_reader_open() { + let directory = + std::env::temp_dir().join(format!("pty-registration-{:032x}", rand::random::())); + fs::create_dir(&directory).unwrap(); + let security = PrivateSecurity::new().unwrap(); + let directory_handle = open( + &directory, + FILE_READ_ATTRIBUTES | FILE_TRAVERSE, + OPEN_EXISTING, + FILE_SHARE_READ | FILE_SHARE_WRITE, + &security, + ) + .unwrap(); + let mut registration = Registration { + instance_id: "first".into(), + pid: 1, + protocol: PROTOCOL_VERSION, + socket: PathBuf::from(r"\\.\pipe\opencode-pty-first"), + token: "secret".into(), + }; + write_registration(&directory, &directory_handle, ®istration, &security).unwrap(); + let path = directory.join(REGISTRATION_FILE); + let mut old_reader = open( + &path, + GENERIC_READ | READ_CONTROL, + OPEN_EXISTING, + FILE_SHARE_READ | FILE_SHARE_DELETE, + &security, + ) + .unwrap(); + security.check_private(old_reader.as_raw_handle()).unwrap(); + registration.instance_id = "second".into(); + write_registration(&directory, &directory_handle, ®istration, &security).unwrap(); + assert_eq!(read_registration(&directory).unwrap().instance_id, "second"); + let mut old = String::new(); + old_reader.read_to_string(&mut old).unwrap(); + assert_eq!( + serde_json::from_str::(&old) + .unwrap() + .instance_id, + "first" + ); + drop(old_reader); + drop(directory_handle); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn process_liveness_uses_the_process_object() { + assert!(process_exists(std::process::id())); + let mut child = std::process::Command::new("cmd.exe") + .args(["/C", "exit 0"]) + .spawn() + .unwrap(); + let pid = child.id(); + child.wait().unwrap(); + assert!(!process_exists(pid)); + } +} diff --git a/packages/process-daemon/src/ghostty/LICENSE b/packages/process-daemon/src/ghostty/LICENSE new file mode 100644 index 000000000000..0a07a66cd1ad --- /dev/null +++ b/packages/process-daemon/src/ghostty/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Mitchell Hashimoto, Ghostty contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/process-daemon/src/ghostty/effects.rs b/packages/process-daemon/src/ghostty/effects.rs new file mode 100644 index 000000000000..d4a9062a71cd --- /dev/null +++ b/packages/process-daemon/src/ghostty/effects.rs @@ -0,0 +1,90 @@ +//! Callback state shared with Ghostty through retained userdata. Keep this module +//! independent of native calls so its ownership rules can also be checked by Miri. + +use std::any::Any; +use std::cell::{Cell, RefCell}; +use std::collections::VecDeque; +use std::ffi::c_void; +use std::panic::{AssertUnwindSafe, catch_unwind, resume_unwind}; +use std::rc::Rc; + +use super::ffi; + +#[derive(Default)] +struct State { + writes: RefCell>>, + title_changed: Cell, + panic: Cell>>, +} + +#[derive(Default)] +pub(super) struct Effects { + // Rc::as_ptr permits retained aliases across owner moves. A Box and mutable + // reborrows of its pointee would invalidate Ghostty's stored userdata pointer. + // Never use Rc::get_mut/make_mut: all pointee mutation must use interior mutability. + state: Rc, +} + +impl Effects { + pub(super) fn userdata(&self) -> *const c_void { + Rc::as_ptr(&self.state).cast() + } + + pub(super) fn take_writes(&self) -> VecDeque> { + self.state.writes.take() + } + + pub(super) fn take_title_changed(&self) -> bool { + self.state.title_changed.replace(false) + } + + pub(super) fn resume_panic(&self) { + // Any Rust panic is resumed only after returning across the C boundary. + if let Some(payload) = self.state.panic.take() { + resume_unwind(payload); + } + } + + pub(super) unsafe extern "C" fn write_pty( + _: ffi::GhosttyTerminal, + userdata: *mut c_void, + data: *const u8, + len: usize, + ) { + // SAFETY: userdata comes from Rc::as_ptr, and Terminal keeps the owning + // Effects alive until after native teardown. Only shared borrows are made. + let state = unsafe { &*userdata.cast::() }; + if let Some(payload) = state.panic.take() { + state.panic.set(Some(payload)); + return; + } + let result = catch_unwind(AssertUnwindSafe(|| { + if len == 0 { + return; + } + assert!(!data.is_null() && len <= isize::MAX as usize); + // SAFETY: Ghostty guarantees data is valid during this callback. + // Retain only copied bytes, never the borrowed native buffer. + state + .writes + .borrow_mut() + .push_back(unsafe { std::slice::from_raw_parts(data, len) }.to_vec()); + })); + if let Err(payload) = result { + state.panic.set(Some(payload)); + } + } + + pub(super) unsafe extern "C" fn title_changed(_: ffi::GhosttyTerminal, userdata: *mut c_void) { + // SAFETY: the same retained shared allocation as write_pty. + // Cell::set cannot panic or reenter Ghostty. + unsafe { &*userdata.cast::() } + .title_changed + .set(true); + } +} + +// The registration API accepts void pointers; still check signatures against +// the generated C typedefs at compile time. +const _: ffi::GhosttyTerminalWritePtyFn = Some(Effects::write_pty); +const _: ffi::GhosttyTerminalTitleChangedFn = Some(Effects::title_changed); diff --git a/packages/process-daemon/src/ghostty/ffi.rs b/packages/process-daemon/src/ghostty/ffi.rs new file mode 100644 index 000000000000..31e79d3fc858 --- /dev/null +++ b/packages/process-daemon/src/ghostty/ffi.rs @@ -0,0 +1,655 @@ +/* automatically generated by rust-bindgen 0.72.1 */ + +// Ghostty revision: ab0b9da9e88fcb4b0533a1854e84628f663930af +// Generated from the official C API; regenerate with script/ghostty-bindings. +#![allow( + dead_code, + non_camel_case_types, + non_snake_case, + non_upper_case_globals, + clippy::all +)] + +pub const GHOSTTY_SUCCESS: GhosttyResult = 0; +pub const GHOSTTY_OUT_OF_MEMORY: GhosttyResult = -1; +pub const GHOSTTY_INVALID_VALUE: GhosttyResult = -2; +pub const GHOSTTY_OUT_OF_SPACE: GhosttyResult = -3; +pub const GHOSTTY_NO_VALUE: GhosttyResult = -4; +pub const GHOSTTY_RESULT_MAX_VALUE: GhosttyResult = 2147483647; +pub type GhosttyResult = ::std::os::raw::c_int; +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct GhosttyTerminalImpl { + _unused: [u8; 0], +} +pub type GhosttyTerminal = *mut GhosttyTerminalImpl; +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct GhosttyFormatterImpl { + _unused: [u8; 0], +} +pub type GhosttyFormatter = *mut GhosttyFormatterImpl; +pub const GHOSTTY_FORMATTER_FORMAT_PLAIN: GhosttyFormatterFormat = 0; +pub const GHOSTTY_FORMATTER_FORMAT_VT: GhosttyFormatterFormat = 1; +pub const GHOSTTY_FORMATTER_FORMAT_HTML: GhosttyFormatterFormat = 2; +pub const GHOSTTY_FORMATTER_FORMAT_MAX_VALUE: GhosttyFormatterFormat = 2147483647; +pub type GhosttyFormatterFormat = ::std::os::raw::c_uint; +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct GhosttyString { + pub ptr: *const u8, + pub len: usize, +} +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of GhosttyString"][::std::mem::size_of::() - 16usize]; + ["Alignment of GhosttyString"][::std::mem::align_of::() - 8usize]; + ["Offset of field: GhosttyString::ptr"][::std::mem::offset_of!(GhosttyString, ptr) - 0usize]; + ["Offset of field: GhosttyString::len"][::std::mem::offset_of!(GhosttyString, len) - 8usize]; +}; +impl Default for GhosttyString { + fn default() -> Self { + let mut s = ::std::mem::MaybeUninit::::uninit(); + unsafe { + ::std::ptr::write_bytes(s.as_mut_ptr(), 0, 1); + s.assume_init() + } + } +} +#[repr(C)] +#[derive(Debug, Default, Copy, Clone)] +pub struct GhosttyAllocatorVtable { + pub alloc: ::std::option::Option< + unsafe extern "C" fn( + ctx: *mut ::std::os::raw::c_void, + len: usize, + alignment: u8, + ret_addr: usize, + ) -> *mut ::std::os::raw::c_void, + >, + pub resize: ::std::option::Option< + unsafe extern "C" fn( + ctx: *mut ::std::os::raw::c_void, + memory: *mut ::std::os::raw::c_void, + memory_len: usize, + alignment: u8, + new_len: usize, + ret_addr: usize, + ) -> bool, + >, + pub remap: ::std::option::Option< + unsafe extern "C" fn( + ctx: *mut ::std::os::raw::c_void, + memory: *mut ::std::os::raw::c_void, + memory_len: usize, + alignment: u8, + new_len: usize, + ret_addr: usize, + ) -> *mut ::std::os::raw::c_void, + >, + pub free: ::std::option::Option< + unsafe extern "C" fn( + ctx: *mut ::std::os::raw::c_void, + memory: *mut ::std::os::raw::c_void, + memory_len: usize, + alignment: u8, + ret_addr: usize, + ), + >, +} +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of GhosttyAllocatorVtable"][::std::mem::size_of::() - 32usize]; + ["Alignment of GhosttyAllocatorVtable"] + [::std::mem::align_of::() - 8usize]; + ["Offset of field: GhosttyAllocatorVtable::alloc"] + [::std::mem::offset_of!(GhosttyAllocatorVtable, alloc) - 0usize]; + ["Offset of field: GhosttyAllocatorVtable::resize"] + [::std::mem::offset_of!(GhosttyAllocatorVtable, resize) - 8usize]; + ["Offset of field: GhosttyAllocatorVtable::remap"] + [::std::mem::offset_of!(GhosttyAllocatorVtable, remap) - 16usize]; + ["Offset of field: GhosttyAllocatorVtable::free"] + [::std::mem::offset_of!(GhosttyAllocatorVtable, free) - 24usize]; +}; +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct GhosttyAllocator { + pub ctx: *mut ::std::os::raw::c_void, + pub vtable: *const GhosttyAllocatorVtable, +} +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of GhosttyAllocator"][::std::mem::size_of::() - 16usize]; + ["Alignment of GhosttyAllocator"][::std::mem::align_of::() - 8usize]; + ["Offset of field: GhosttyAllocator::ctx"] + [::std::mem::offset_of!(GhosttyAllocator, ctx) - 0usize]; + ["Offset of field: GhosttyAllocator::vtable"] + [::std::mem::offset_of!(GhosttyAllocator, vtable) - 8usize]; +}; +impl Default for GhosttyAllocator { + fn default() -> Self { + let mut s = ::std::mem::MaybeUninit::::uninit(); + unsafe { + ::std::ptr::write_bytes(s.as_mut_ptr(), 0, 1); + s.assume_init() + } + } +} +unsafe extern "C" { + pub fn ghostty_free(allocator: *const GhosttyAllocator, ptr: *mut u8, len: usize); +} +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct GhosttyGridRef { + pub size: usize, + pub node: *mut ::std::os::raw::c_void, + pub x: u16, + pub y: u16, +} +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of GhosttyGridRef"][::std::mem::size_of::() - 24usize]; + ["Alignment of GhosttyGridRef"][::std::mem::align_of::() - 8usize]; + ["Offset of field: GhosttyGridRef::size"] + [::std::mem::offset_of!(GhosttyGridRef, size) - 0usize]; + ["Offset of field: GhosttyGridRef::node"] + [::std::mem::offset_of!(GhosttyGridRef, node) - 8usize]; + ["Offset of field: GhosttyGridRef::x"][::std::mem::offset_of!(GhosttyGridRef, x) - 16usize]; + ["Offset of field: GhosttyGridRef::y"][::std::mem::offset_of!(GhosttyGridRef, y) - 18usize]; +}; +impl Default for GhosttyGridRef { + fn default() -> Self { + let mut s = ::std::mem::MaybeUninit::::uninit(); + unsafe { + ::std::ptr::write_bytes(s.as_mut_ptr(), 0, 1); + s.assume_init() + } + } +} +#[repr(C)] +#[derive(Debug, Default, Copy, Clone)] +pub struct GhosttyPointCoordinate { + pub x: u16, + pub y: u32, +} +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of GhosttyPointCoordinate"][::std::mem::size_of::() - 8usize]; + ["Alignment of GhosttyPointCoordinate"] + [::std::mem::align_of::() - 4usize]; + ["Offset of field: GhosttyPointCoordinate::x"] + [::std::mem::offset_of!(GhosttyPointCoordinate, x) - 0usize]; + ["Offset of field: GhosttyPointCoordinate::y"] + [::std::mem::offset_of!(GhosttyPointCoordinate, y) - 4usize]; +}; +pub const GHOSTTY_POINT_TAG_ACTIVE: GhosttyPointTag = 0; +pub const GHOSTTY_POINT_TAG_VIEWPORT: GhosttyPointTag = 1; +pub const GHOSTTY_POINT_TAG_SCREEN: GhosttyPointTag = 2; +pub const GHOSTTY_POINT_TAG_HISTORY: GhosttyPointTag = 3; +pub const GHOSTTY_POINT_TAG_MAX_VALUE: GhosttyPointTag = 2147483647; +pub type GhosttyPointTag = ::std::os::raw::c_uint; +#[repr(C)] +#[derive(Copy, Clone)] +pub union GhosttyPointValue { + pub coordinate: GhosttyPointCoordinate, + pub _padding: [u64; 2usize], +} +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of GhosttyPointValue"][::std::mem::size_of::() - 16usize]; + ["Alignment of GhosttyPointValue"][::std::mem::align_of::() - 8usize]; + ["Offset of field: GhosttyPointValue::coordinate"] + [::std::mem::offset_of!(GhosttyPointValue, coordinate) - 0usize]; + ["Offset of field: GhosttyPointValue::_padding"] + [::std::mem::offset_of!(GhosttyPointValue, _padding) - 0usize]; +}; +impl Default for GhosttyPointValue { + fn default() -> Self { + let mut s = ::std::mem::MaybeUninit::::uninit(); + unsafe { + ::std::ptr::write_bytes(s.as_mut_ptr(), 0, 1); + s.assume_init() + } + } +} +#[repr(C)] +#[derive(Copy, Clone)] +pub struct GhosttyPoint { + pub tag: GhosttyPointTag, + pub value: GhosttyPointValue, +} +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of GhosttyPoint"][::std::mem::size_of::() - 24usize]; + ["Alignment of GhosttyPoint"][::std::mem::align_of::() - 8usize]; + ["Offset of field: GhosttyPoint::tag"][::std::mem::offset_of!(GhosttyPoint, tag) - 0usize]; + ["Offset of field: GhosttyPoint::value"][::std::mem::offset_of!(GhosttyPoint, value) - 8usize]; +}; +impl Default for GhosttyPoint { + fn default() -> Self { + let mut s = ::std::mem::MaybeUninit::::uninit(); + unsafe { + ::std::ptr::write_bytes(s.as_mut_ptr(), 0, 1); + s.assume_init() + } + } +} +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct GhosttySelection { + pub size: usize, + pub start: GhosttyGridRef, + pub end: GhosttyGridRef, + pub rectangle: bool, +} +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of GhosttySelection"][::std::mem::size_of::() - 64usize]; + ["Alignment of GhosttySelection"][::std::mem::align_of::() - 8usize]; + ["Offset of field: GhosttySelection::size"] + [::std::mem::offset_of!(GhosttySelection, size) - 0usize]; + ["Offset of field: GhosttySelection::start"] + [::std::mem::offset_of!(GhosttySelection, start) - 8usize]; + ["Offset of field: GhosttySelection::end"] + [::std::mem::offset_of!(GhosttySelection, end) - 32usize]; + ["Offset of field: GhosttySelection::rectangle"] + [::std::mem::offset_of!(GhosttySelection, rectangle) - 56usize]; +}; +impl Default for GhosttySelection { + fn default() -> Self { + let mut s = ::std::mem::MaybeUninit::::uninit(); + unsafe { + ::std::ptr::write_bytes(s.as_mut_ptr(), 0, 1); + s.assume_init() + } + } +} +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct GhosttyTerminalSelectionFormatOptions { + pub size: usize, + pub emit: GhosttyFormatterFormat, + pub unwrap: bool, + pub trim: bool, + pub selection: *const GhosttySelection, +} +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of GhosttyTerminalSelectionFormatOptions"] + [::std::mem::size_of::() - 24usize]; + ["Alignment of GhosttyTerminalSelectionFormatOptions"] + [::std::mem::align_of::() - 8usize]; + ["Offset of field: GhosttyTerminalSelectionFormatOptions::size"] + [::std::mem::offset_of!(GhosttyTerminalSelectionFormatOptions, size) - 0usize]; + ["Offset of field: GhosttyTerminalSelectionFormatOptions::emit"] + [::std::mem::offset_of!(GhosttyTerminalSelectionFormatOptions, emit) - 8usize]; + ["Offset of field: GhosttyTerminalSelectionFormatOptions::unwrap"] + [::std::mem::offset_of!(GhosttyTerminalSelectionFormatOptions, unwrap) - 12usize]; + ["Offset of field: GhosttyTerminalSelectionFormatOptions::trim"] + [::std::mem::offset_of!(GhosttyTerminalSelectionFormatOptions, trim) - 13usize]; + ["Offset of field: GhosttyTerminalSelectionFormatOptions::selection"] + [::std::mem::offset_of!(GhosttyTerminalSelectionFormatOptions, selection) - 16usize]; +}; +impl Default for GhosttyTerminalSelectionFormatOptions { + fn default() -> Self { + let mut s = ::std::mem::MaybeUninit::::uninit(); + unsafe { + ::std::ptr::write_bytes(s.as_mut_ptr(), 0, 1); + s.assume_init() + } + } +} +unsafe extern "C" { + pub fn ghostty_terminal_selection_format_buf( + terminal: GhosttyTerminal, + options: GhosttyTerminalSelectionFormatOptions, + buf: *mut u8, + buf_len: usize, + out_written: *mut usize, + ) -> GhosttyResult; +} +#[repr(C)] +#[derive(Debug, Default, Copy, Clone)] +pub struct GhosttyTerminalOptions { + pub cols: u16, + pub rows: u16, + pub max_scrollback: usize, +} +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of GhosttyTerminalOptions"][::std::mem::size_of::() - 16usize]; + ["Alignment of GhosttyTerminalOptions"] + [::std::mem::align_of::() - 8usize]; + ["Offset of field: GhosttyTerminalOptions::cols"] + [::std::mem::offset_of!(GhosttyTerminalOptions, cols) - 0usize]; + ["Offset of field: GhosttyTerminalOptions::rows"] + [::std::mem::offset_of!(GhosttyTerminalOptions, rows) - 2usize]; + ["Offset of field: GhosttyTerminalOptions::max_scrollback"] + [::std::mem::offset_of!(GhosttyTerminalOptions, max_scrollback) - 8usize]; +}; +pub const GHOSTTY_SCROLL_VIEWPORT_TOP: GhosttyTerminalScrollViewportTag = 0; +pub const GHOSTTY_SCROLL_VIEWPORT_BOTTOM: GhosttyTerminalScrollViewportTag = 1; +pub const GHOSTTY_SCROLL_VIEWPORT_DELTA: GhosttyTerminalScrollViewportTag = 2; +pub const GHOSTTY_SCROLL_VIEWPORT_ROW: GhosttyTerminalScrollViewportTag = 3; +pub const GHOSTTY_SCROLL_VIEWPORT_MAX_VALUE: GhosttyTerminalScrollViewportTag = 2147483647; +pub type GhosttyTerminalScrollViewportTag = ::std::os::raw::c_uint; +#[repr(C)] +#[derive(Copy, Clone)] +pub union GhosttyTerminalScrollViewportValue { + pub delta: isize, + pub row: usize, + pub _padding: [u64; 2usize], +} +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of GhosttyTerminalScrollViewportValue"] + [::std::mem::size_of::() - 16usize]; + ["Alignment of GhosttyTerminalScrollViewportValue"] + [::std::mem::align_of::() - 8usize]; + ["Offset of field: GhosttyTerminalScrollViewportValue::delta"] + [::std::mem::offset_of!(GhosttyTerminalScrollViewportValue, delta) - 0usize]; + ["Offset of field: GhosttyTerminalScrollViewportValue::row"] + [::std::mem::offset_of!(GhosttyTerminalScrollViewportValue, row) - 0usize]; + ["Offset of field: GhosttyTerminalScrollViewportValue::_padding"] + [::std::mem::offset_of!(GhosttyTerminalScrollViewportValue, _padding) - 0usize]; +}; +impl Default for GhosttyTerminalScrollViewportValue { + fn default() -> Self { + let mut s = ::std::mem::MaybeUninit::::uninit(); + unsafe { + ::std::ptr::write_bytes(s.as_mut_ptr(), 0, 1); + s.assume_init() + } + } +} +#[repr(C)] +#[derive(Copy, Clone)] +pub struct GhosttyTerminalScrollViewport { + pub tag: GhosttyTerminalScrollViewportTag, + pub value: GhosttyTerminalScrollViewportValue, +} +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of GhosttyTerminalScrollViewport"] + [::std::mem::size_of::() - 24usize]; + ["Alignment of GhosttyTerminalScrollViewport"] + [::std::mem::align_of::() - 8usize]; + ["Offset of field: GhosttyTerminalScrollViewport::tag"] + [::std::mem::offset_of!(GhosttyTerminalScrollViewport, tag) - 0usize]; + ["Offset of field: GhosttyTerminalScrollViewport::value"] + [::std::mem::offset_of!(GhosttyTerminalScrollViewport, value) - 8usize]; +}; +impl Default for GhosttyTerminalScrollViewport { + fn default() -> Self { + let mut s = ::std::mem::MaybeUninit::::uninit(); + unsafe { + ::std::ptr::write_bytes(s.as_mut_ptr(), 0, 1); + s.assume_init() + } + } +} +pub type GhosttyTerminalTitleChangedFn = ::std::option::Option< + unsafe extern "C" fn(terminal: GhosttyTerminal, userdata: *mut ::std::os::raw::c_void), +>; +pub type GhosttyTerminalWritePtyFn = ::std::option::Option< + unsafe extern "C" fn( + terminal: GhosttyTerminal, + userdata: *mut ::std::os::raw::c_void, + data: *const u8, + len: usize, + ), +>; +pub const GHOSTTY_TERMINAL_OPT_USERDATA: GhosttyTerminalOption = 0; +pub const GHOSTTY_TERMINAL_OPT_WRITE_PTY: GhosttyTerminalOption = 1; +pub const GHOSTTY_TERMINAL_OPT_BELL: GhosttyTerminalOption = 2; +pub const GHOSTTY_TERMINAL_OPT_ENQUIRY: GhosttyTerminalOption = 3; +pub const GHOSTTY_TERMINAL_OPT_XTVERSION: GhosttyTerminalOption = 4; +pub const GHOSTTY_TERMINAL_OPT_TITLE_CHANGED: GhosttyTerminalOption = 5; +pub const GHOSTTY_TERMINAL_OPT_SIZE: GhosttyTerminalOption = 6; +pub const GHOSTTY_TERMINAL_OPT_COLOR_SCHEME: GhosttyTerminalOption = 7; +pub const GHOSTTY_TERMINAL_OPT_DEVICE_ATTRIBUTES: GhosttyTerminalOption = 8; +pub const GHOSTTY_TERMINAL_OPT_TITLE: GhosttyTerminalOption = 9; +pub const GHOSTTY_TERMINAL_OPT_PWD: GhosttyTerminalOption = 10; +pub const GHOSTTY_TERMINAL_OPT_COLOR_FOREGROUND: GhosttyTerminalOption = 11; +pub const GHOSTTY_TERMINAL_OPT_COLOR_BACKGROUND: GhosttyTerminalOption = 12; +pub const GHOSTTY_TERMINAL_OPT_COLOR_CURSOR: GhosttyTerminalOption = 13; +pub const GHOSTTY_TERMINAL_OPT_COLOR_PALETTE: GhosttyTerminalOption = 14; +pub const GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_STORAGE_LIMIT: GhosttyTerminalOption = 15; +pub const GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_MEDIUM_FILE: GhosttyTerminalOption = 16; +pub const GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_MEDIUM_TEMP_FILE: GhosttyTerminalOption = 17; +pub const GHOSTTY_TERMINAL_OPT_KITTY_IMAGE_MEDIUM_SHARED_MEM: GhosttyTerminalOption = 18; +pub const GHOSTTY_TERMINAL_OPT_APC_MAX_BYTES: GhosttyTerminalOption = 19; +pub const GHOSTTY_TERMINAL_OPT_APC_MAX_BYTES_KITTY: GhosttyTerminalOption = 20; +pub const GHOSTTY_TERMINAL_OPT_SELECTION: GhosttyTerminalOption = 21; +pub const GHOSTTY_TERMINAL_OPT_DEFAULT_CURSOR_STYLE: GhosttyTerminalOption = 22; +pub const GHOSTTY_TERMINAL_OPT_DEFAULT_CURSOR_BLINK: GhosttyTerminalOption = 23; +pub const GHOSTTY_TERMINAL_OPT_GLYPH_PROTOCOL: GhosttyTerminalOption = 24; +pub const GHOSTTY_TERMINAL_OPT_PWD_CHANGED: GhosttyTerminalOption = 25; +pub const GHOSTTY_TERMINAL_OPT_CLIPBOARD_WRITE: GhosttyTerminalOption = 26; +pub const GHOSTTY_TERMINAL_OPT_MAX_VALUE: GhosttyTerminalOption = 2147483647; +pub type GhosttyTerminalOption = ::std::os::raw::c_uint; +pub const GHOSTTY_TERMINAL_DATA_INVALID: GhosttyTerminalData = 0; +pub const GHOSTTY_TERMINAL_DATA_COLS: GhosttyTerminalData = 1; +pub const GHOSTTY_TERMINAL_DATA_ROWS: GhosttyTerminalData = 2; +pub const GHOSTTY_TERMINAL_DATA_CURSOR_X: GhosttyTerminalData = 3; +pub const GHOSTTY_TERMINAL_DATA_CURSOR_Y: GhosttyTerminalData = 4; +pub const GHOSTTY_TERMINAL_DATA_CURSOR_PENDING_WRAP: GhosttyTerminalData = 5; +pub const GHOSTTY_TERMINAL_DATA_ACTIVE_SCREEN: GhosttyTerminalData = 6; +pub const GHOSTTY_TERMINAL_DATA_CURSOR_VISIBLE: GhosttyTerminalData = 7; +pub const GHOSTTY_TERMINAL_DATA_KITTY_KEYBOARD_FLAGS: GhosttyTerminalData = 8; +pub const GHOSTTY_TERMINAL_DATA_SCROLLBAR: GhosttyTerminalData = 9; +pub const GHOSTTY_TERMINAL_DATA_CURSOR_STYLE: GhosttyTerminalData = 10; +pub const GHOSTTY_TERMINAL_DATA_MOUSE_TRACKING: GhosttyTerminalData = 11; +pub const GHOSTTY_TERMINAL_DATA_TITLE: GhosttyTerminalData = 12; +pub const GHOSTTY_TERMINAL_DATA_PWD: GhosttyTerminalData = 13; +pub const GHOSTTY_TERMINAL_DATA_TOTAL_ROWS: GhosttyTerminalData = 14; +pub const GHOSTTY_TERMINAL_DATA_SCROLLBACK_ROWS: GhosttyTerminalData = 15; +pub const GHOSTTY_TERMINAL_DATA_WIDTH_PX: GhosttyTerminalData = 16; +pub const GHOSTTY_TERMINAL_DATA_HEIGHT_PX: GhosttyTerminalData = 17; +pub const GHOSTTY_TERMINAL_DATA_COLOR_FOREGROUND: GhosttyTerminalData = 18; +pub const GHOSTTY_TERMINAL_DATA_COLOR_BACKGROUND: GhosttyTerminalData = 19; +pub const GHOSTTY_TERMINAL_DATA_COLOR_CURSOR: GhosttyTerminalData = 20; +pub const GHOSTTY_TERMINAL_DATA_COLOR_PALETTE: GhosttyTerminalData = 21; +pub const GHOSTTY_TERMINAL_DATA_COLOR_FOREGROUND_DEFAULT: GhosttyTerminalData = 22; +pub const GHOSTTY_TERMINAL_DATA_COLOR_BACKGROUND_DEFAULT: GhosttyTerminalData = 23; +pub const GHOSTTY_TERMINAL_DATA_COLOR_CURSOR_DEFAULT: GhosttyTerminalData = 24; +pub const GHOSTTY_TERMINAL_DATA_COLOR_PALETTE_DEFAULT: GhosttyTerminalData = 25; +pub const GHOSTTY_TERMINAL_DATA_KITTY_IMAGE_STORAGE_LIMIT: GhosttyTerminalData = 26; +pub const GHOSTTY_TERMINAL_DATA_KITTY_IMAGE_MEDIUM_FILE: GhosttyTerminalData = 27; +pub const GHOSTTY_TERMINAL_DATA_KITTY_IMAGE_MEDIUM_TEMP_FILE: GhosttyTerminalData = 28; +pub const GHOSTTY_TERMINAL_DATA_KITTY_IMAGE_MEDIUM_SHARED_MEM: GhosttyTerminalData = 29; +pub const GHOSTTY_TERMINAL_DATA_KITTY_GRAPHICS: GhosttyTerminalData = 30; +pub const GHOSTTY_TERMINAL_DATA_SELECTION: GhosttyTerminalData = 31; +pub const GHOSTTY_TERMINAL_DATA_VIEWPORT_ACTIVE: GhosttyTerminalData = 32; +pub const GHOSTTY_TERMINAL_DATA_VT_PROCESSING_ERROR: GhosttyTerminalData = 33; +pub const GHOSTTY_TERMINAL_DATA_MAX_VALUE: GhosttyTerminalData = 2147483647; +pub type GhosttyTerminalData = ::std::os::raw::c_uint; +unsafe extern "C" { + pub fn ghostty_terminal_new( + allocator: *const GhosttyAllocator, + terminal: *mut GhosttyTerminal, + options: GhosttyTerminalOptions, + ) -> GhosttyResult; +} +unsafe extern "C" { + pub fn ghostty_terminal_free(terminal: GhosttyTerminal); +} +unsafe extern "C" { + pub fn ghostty_terminal_resize( + terminal: GhosttyTerminal, + cols: u16, + rows: u16, + cell_width_px: u32, + cell_height_px: u32, + ) -> GhosttyResult; +} +unsafe extern "C" { + pub fn ghostty_terminal_set( + terminal: GhosttyTerminal, + option: GhosttyTerminalOption, + value: *const ::std::os::raw::c_void, + ) -> GhosttyResult; +} +unsafe extern "C" { + pub fn ghostty_terminal_vt_write(terminal: GhosttyTerminal, data: *const u8, len: usize); +} +unsafe extern "C" { + pub fn ghostty_terminal_scroll_viewport( + terminal: GhosttyTerminal, + behavior: GhosttyTerminalScrollViewport, + ); +} +unsafe extern "C" { + pub fn ghostty_terminal_get( + terminal: GhosttyTerminal, + data: GhosttyTerminalData, + out: *mut ::std::os::raw::c_void, + ) -> GhosttyResult; +} +unsafe extern "C" { + pub fn ghostty_terminal_grid_ref( + terminal: GhosttyTerminal, + point: GhosttyPoint, + out_ref: *mut GhosttyGridRef, + ) -> GhosttyResult; +} +unsafe extern "C" { + pub fn ghostty_terminal_point_from_grid_ref( + terminal: GhosttyTerminal, + ref_: *const GhosttyGridRef, + tag: GhosttyPointTag, + out: *mut GhosttyPointCoordinate, + ) -> GhosttyResult; +} +#[repr(C)] +#[derive(Debug, Default, Copy, Clone)] +pub struct GhosttyFormatterScreenExtra { + pub size: usize, + pub cursor: bool, + pub style: bool, + pub hyperlink: bool, + pub protection: bool, + pub kitty_keyboard: bool, + pub charsets: bool, +} +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of GhosttyFormatterScreenExtra"] + [::std::mem::size_of::() - 16usize]; + ["Alignment of GhosttyFormatterScreenExtra"] + [::std::mem::align_of::() - 8usize]; + ["Offset of field: GhosttyFormatterScreenExtra::size"] + [::std::mem::offset_of!(GhosttyFormatterScreenExtra, size) - 0usize]; + ["Offset of field: GhosttyFormatterScreenExtra::cursor"] + [::std::mem::offset_of!(GhosttyFormatterScreenExtra, cursor) - 8usize]; + ["Offset of field: GhosttyFormatterScreenExtra::style"] + [::std::mem::offset_of!(GhosttyFormatterScreenExtra, style) - 9usize]; + ["Offset of field: GhosttyFormatterScreenExtra::hyperlink"] + [::std::mem::offset_of!(GhosttyFormatterScreenExtra, hyperlink) - 10usize]; + ["Offset of field: GhosttyFormatterScreenExtra::protection"] + [::std::mem::offset_of!(GhosttyFormatterScreenExtra, protection) - 11usize]; + ["Offset of field: GhosttyFormatterScreenExtra::kitty_keyboard"] + [::std::mem::offset_of!(GhosttyFormatterScreenExtra, kitty_keyboard) - 12usize]; + ["Offset of field: GhosttyFormatterScreenExtra::charsets"] + [::std::mem::offset_of!(GhosttyFormatterScreenExtra, charsets) - 13usize]; +}; +#[repr(C)] +#[derive(Debug, Default, Copy, Clone)] +pub struct GhosttyFormatterTerminalExtra { + pub size: usize, + pub palette: bool, + pub modes: bool, + pub scrolling_region: bool, + pub tabstops: bool, + pub pwd: bool, + pub keyboard: bool, + pub screen: GhosttyFormatterScreenExtra, +} +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of GhosttyFormatterTerminalExtra"] + [::std::mem::size_of::() - 32usize]; + ["Alignment of GhosttyFormatterTerminalExtra"] + [::std::mem::align_of::() - 8usize]; + ["Offset of field: GhosttyFormatterTerminalExtra::size"] + [::std::mem::offset_of!(GhosttyFormatterTerminalExtra, size) - 0usize]; + ["Offset of field: GhosttyFormatterTerminalExtra::palette"] + [::std::mem::offset_of!(GhosttyFormatterTerminalExtra, palette) - 8usize]; + ["Offset of field: GhosttyFormatterTerminalExtra::modes"] + [::std::mem::offset_of!(GhosttyFormatterTerminalExtra, modes) - 9usize]; + ["Offset of field: GhosttyFormatterTerminalExtra::scrolling_region"] + [::std::mem::offset_of!(GhosttyFormatterTerminalExtra, scrolling_region) - 10usize]; + ["Offset of field: GhosttyFormatterTerminalExtra::tabstops"] + [::std::mem::offset_of!(GhosttyFormatterTerminalExtra, tabstops) - 11usize]; + ["Offset of field: GhosttyFormatterTerminalExtra::pwd"] + [::std::mem::offset_of!(GhosttyFormatterTerminalExtra, pwd) - 12usize]; + ["Offset of field: GhosttyFormatterTerminalExtra::keyboard"] + [::std::mem::offset_of!(GhosttyFormatterTerminalExtra, keyboard) - 13usize]; + ["Offset of field: GhosttyFormatterTerminalExtra::screen"] + [::std::mem::offset_of!(GhosttyFormatterTerminalExtra, screen) - 16usize]; +}; +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct GhosttyFormatterTerminalOptions { + pub size: usize, + pub emit: GhosttyFormatterFormat, + pub unwrap: bool, + pub trim: bool, + pub extra: GhosttyFormatterTerminalExtra, + pub selection: *const GhosttySelection, +} +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of GhosttyFormatterTerminalOptions"] + [::std::mem::size_of::() - 56usize]; + ["Alignment of GhosttyFormatterTerminalOptions"] + [::std::mem::align_of::() - 8usize]; + ["Offset of field: GhosttyFormatterTerminalOptions::size"] + [::std::mem::offset_of!(GhosttyFormatterTerminalOptions, size) - 0usize]; + ["Offset of field: GhosttyFormatterTerminalOptions::emit"] + [::std::mem::offset_of!(GhosttyFormatterTerminalOptions, emit) - 8usize]; + ["Offset of field: GhosttyFormatterTerminalOptions::unwrap"] + [::std::mem::offset_of!(GhosttyFormatterTerminalOptions, unwrap) - 12usize]; + ["Offset of field: GhosttyFormatterTerminalOptions::trim"] + [::std::mem::offset_of!(GhosttyFormatterTerminalOptions, trim) - 13usize]; + ["Offset of field: GhosttyFormatterTerminalOptions::extra"] + [::std::mem::offset_of!(GhosttyFormatterTerminalOptions, extra) - 16usize]; + ["Offset of field: GhosttyFormatterTerminalOptions::selection"] + [::std::mem::offset_of!(GhosttyFormatterTerminalOptions, selection) - 48usize]; +}; +impl Default for GhosttyFormatterTerminalOptions { + fn default() -> Self { + let mut s = ::std::mem::MaybeUninit::::uninit(); + unsafe { + ::std::ptr::write_bytes(s.as_mut_ptr(), 0, 1); + s.assume_init() + } + } +} +unsafe extern "C" { + pub fn ghostty_formatter_terminal_new( + allocator: *const GhosttyAllocator, + formatter: *mut GhosttyFormatter, + terminal: GhosttyTerminal, + options: GhosttyFormatterTerminalOptions, + ) -> GhosttyResult; +} +unsafe extern "C" { + pub fn ghostty_formatter_format_buf( + formatter: GhosttyFormatter, + buf: *mut u8, + buf_len: usize, + out_written: *mut usize, + ) -> GhosttyResult; +} +unsafe extern "C" { + pub fn ghostty_formatter_format_alloc( + formatter: GhosttyFormatter, + allocator: *const GhosttyAllocator, + out_ptr: *mut *mut u8, + out_len: *mut usize, + ) -> GhosttyResult; +} +unsafe extern "C" { + pub fn ghostty_formatter_free(formatter: GhosttyFormatter); +} diff --git a/packages/process-daemon/src/ghostty/mod.rs b/packages/process-daemon/src/ghostty/mod.rs new file mode 100644 index 000000000000..7845b2889757 --- /dev/null +++ b/packages/process-daemon/src/ghostty/mod.rs @@ -0,0 +1,416 @@ +//! The service's small, actor-local adapter to the official libghostty C API. +//! Native handles and borrowed grid references never leave this module. + +mod effects; +mod ffi; + +use std::collections::VecDeque; +use std::ffi::c_void; +use std::marker::PhantomData; +use std::mem::MaybeUninit; +use std::ptr::{self, NonNull}; +use std::rc::Rc; + +use anyhow::{Context, Result, bail, ensure}; + +use effects::Effects; + +#[derive(Clone, Copy)] +pub(crate) struct TerminalOptions { + pub cols: u16, + pub rows: u16, + pub max_scrollback: usize, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) enum Format { + Plain, + Vt, +} + +pub(crate) struct Terminal { + raw: NonNull, + // Shared callback state stays alive until after ghostty_terminal_free. + effects: Effects, + // Creation, access, callbacks, and destruction all belong to one actor thread. + _actor: PhantomData>, +} + +impl Terminal { + pub fn new(options: TerminalOptions) -> Result { + ensure!( + options.cols > 0 && options.rows > 0, + "terminal dimensions must be positive" + ); + let mut raw = ptr::null_mut(); + // SAFETY: options match the pinned C header; NULL selects Ghostty's allocator. + check(unsafe { + ffi::ghostty_terminal_new( + ptr::null(), + &mut raw, + ffi::GhosttyTerminalOptions { + cols: options.cols, + rows: options.rows, + max_scrollback: options.max_scrollback, + }, + ) + })?; + let terminal = Self { + raw: NonNull::new(raw).context("Ghostty returned a null terminal")?, + effects: Effects::default(), + _actor: PhantomData, + }; + + // SAFETY: userdata is an Rc-derived shared pointer retained until after terminal_free. + // These callbacks only copy responses/mark a title change; they never reenter Ghostty. + unsafe { + check(ffi::ghostty_terminal_set( + raw, + ffi::GHOSTTY_TERMINAL_OPT_USERDATA, + terminal.effects.userdata(), + ))?; + check(ffi::ghostty_terminal_set( + raw, + ffi::GHOSTTY_TERMINAL_OPT_WRITE_PTY, + Effects::write_pty as *const c_void, + ))?; + check(ffi::ghostty_terminal_set( + raw, + ffi::GHOSTTY_TERMINAL_OPT_TITLE_CHANGED, + Effects::title_changed as *const c_void, + ))?; + } + Ok(terminal) + } + + pub fn vt_write(&mut self, bytes: &[u8]) { + // SAFETY: the byte slice lives through this synchronous call, and &mut self + // excludes other native operations. No callback can access this Rust wrapper. + unsafe { ffi::ghostty_terminal_vt_write(self.raw.as_ptr(), bytes.as_ptr(), bytes.len()) }; + self.effects.resume_panic(); + } + + pub fn take_writes(&mut self) -> VecDeque> { + self.effects.take_writes() + } + + pub fn take_title(&mut self) -> Option { + if !self.effects.take_title_changed() { + return None; + } + self.title().ok() + } + + fn title(&self) -> Result { + // SAFETY: TITLE writes a GhosttyString. Copy its borrowed data before mutation. + let value: ffi::GhosttyString = unsafe { self.get(ffi::GHOSTTY_TERMINAL_DATA_TITLE)? }; + if value.len == 0 { + return Ok(String::new()); + } + ensure!(!value.ptr.is_null(), "Ghostty returned a null title"); + ensure!( + value.len <= isize::MAX as usize, + "Ghostty title is too large" + ); + // SAFETY: Ghostty owns these bytes until the next mutating call, excluded by &self. + Ok( + std::str::from_utf8(unsafe { std::slice::from_raw_parts(value.ptr, value.len) })? + .to_owned(), + ) + } + + pub fn resize(&mut self, cols: u16, rows: u16) -> Result<()> { + ensure!(cols > 0 && rows > 0, "terminal dimensions must be positive"); + // SAFETY: a live actor-local handle with valid dimensions and no borrowed grid refs. + let result = unsafe { ffi::ghostty_terminal_resize(self.raw.as_ptr(), cols, rows, 0, 0) }; + // Resize can emit in-band size reports through the same PTY callback. + self.effects.resume_panic(); + check(result) + } + + pub fn cols(&self) -> Result { + // SAFETY: COLS writes uint16_t. + unsafe { self.get(ffi::GHOSTTY_TERMINAL_DATA_COLS) } + } + + pub fn rows(&self) -> Result { + // SAFETY: ROWS writes uint16_t. + unsafe { self.get(ffi::GHOSTTY_TERMINAL_DATA_ROWS) } + } + + pub fn cursor_x(&self) -> Result { + // SAFETY: CURSOR_X writes uint16_t. + unsafe { self.get(ffi::GHOSTTY_TERMINAL_DATA_CURSOR_X) } + } + + pub fn cursor_y(&self) -> Result { + // SAFETY: CURSOR_Y writes uint16_t. + unsafe { self.get(ffi::GHOSTTY_TERMINAL_DATA_CURSOR_Y) } + } + + pub fn total_rows(&self) -> Result { + // SAFETY: TOTAL_ROWS writes size_t. + unsafe { self.get(ffi::GHOSTTY_TERMINAL_DATA_TOTAL_ROWS) } + } + + /// Caller must match T to the selected C output type. Never exposed outside this module. + unsafe fn get(&self, data: ffi::GhosttyTerminalData) -> Result { + let mut value = MaybeUninit::::uninit(); + // SAFETY: guaranteed by the typed getter calling this function. + check(unsafe { + ffi::ghostty_terminal_get(self.raw.as_ptr(), data, value.as_mut_ptr().cast()) + })?; + // SAFETY: a successful get initialized the selected output type. + Ok(unsafe { value.assume_init() }) + } + + pub fn format(&self, format: Format) -> Result> { + let formatter = Formatter::new(self, format, None)?; + let mut ptr = ptr::null_mut(); + let mut len = 0; + // SAFETY: formatter is live, out pointers are valid, allocator matches Allocation::drop. + check(unsafe { + ffi::ghostty_formatter_format_alloc( + formatter.raw.as_ptr(), + ptr::null(), + &mut ptr, + &mut len, + ) + })?; + ensure!( + len == 0 || !ptr.is_null(), + "Ghostty returned a null formatted buffer" + ); + ensure!( + len <= isize::MAX as usize, + "Ghostty formatted buffer is too large" + ); + let bytes = Allocation { ptr, len }; + if bytes.len == 0 { + return Ok(Vec::new()); + } + // SAFETY: Ghostty allocated len bytes; copy before the allocation guard frees them. + Ok(unsafe { std::slice::from_raw_parts(bytes.ptr, bytes.len) }.to_vec()) + } + + pub fn format_row(&self, y: u32, buffer: &mut [u8]) -> Result { + let selection = self.selection((0, y), (self.cols()? - 1, y))?; + let formatter = Formatter::new(self, Format::Plain, Some(&selection))?; + let mut written = 0; + // SAFETY: terminal and selection outlive formatter; the buffer is valid for its length. + check(unsafe { + ffi::ghostty_formatter_format_buf( + formatter.raw.as_ptr(), + buffer.as_mut_ptr(), + buffer.len(), + &mut written, + ) + })?; + ensure!( + written <= buffer.len(), + "Ghostty wrote beyond the row buffer" + ); + Ok(written) + } + + fn grid_ref(&self, tag: ffi::GhosttyPointTag, x: u16, y: u32) -> Result { + let mut value = ffi::GhosttyGridRef { + size: size_of::(), + ..Default::default() + }; + // SAFETY: all tagged unions and sized structs match the pinned header. References + // remain private and are used only while &self excludes terminal mutations. + check(unsafe { + ffi::ghostty_terminal_grid_ref( + self.raw.as_ptr(), + ffi::GhosttyPoint { + tag, + value: ffi::GhosttyPointValue { + coordinate: ffi::GhosttyPointCoordinate { x, y }, + }, + }, + &mut value, + ) + })?; + Ok(value) + } + + fn selection(&self, start: (u16, u32), end: (u16, u32)) -> Result { + Ok(ffi::GhosttySelection { + size: size_of::(), + start: self.grid_ref(ffi::GHOSTTY_POINT_TAG_SCREEN, start.0, start.1)?, + end: self.grid_ref(ffi::GHOSTTY_POINT_TAG_SCREEN, end.0, end.1)?, + rectangle: false, + }) + } + + #[cfg(test)] + pub fn scroll_to_top(&mut self) { + // SAFETY: TOP ignores the zeroed value union. + unsafe { + ffi::ghostty_terminal_scroll_viewport( + self.raw.as_ptr(), + ffi::GhosttyTerminalScrollViewport { + tag: ffi::GHOSTTY_SCROLL_VIEWPORT_TOP, + value: Default::default(), + }, + ) + }; + } + + #[cfg(test)] + pub fn viewport_row(&self) -> Result { + let origin = self.grid_ref(ffi::GHOSTTY_POINT_TAG_VIEWPORT, 0, 0)?; + let mut point = ffi::GhosttyPointCoordinate::default(); + // SAFETY: origin was just read from this terminal, which has not been mutated. + check(unsafe { + ffi::ghostty_terminal_point_from_grid_ref( + self.raw.as_ptr(), + &origin, + ffi::GHOSTTY_POINT_TAG_SCREEN, + &mut point, + ) + })?; + Ok(point.y) + } + + #[cfg(test)] + pub fn set_selection(&mut self, start: (u16, u32), end: (u16, u32)) -> Result<()> { + let selection = self.selection(start, end)?; + // SAFETY: set copies the selection into terminal-owned state during the call. + check(unsafe { + ffi::ghostty_terminal_set( + self.raw.as_ptr(), + ffi::GHOSTTY_TERMINAL_OPT_SELECTION, + ptr::from_ref(&selection).cast(), + ) + }) + } + + #[cfg(test)] + pub fn selection_text(&self, buffer: &mut [u8]) -> Result> { + let mut written = 0; + // SAFETY: NULL selection reads the terminal-owned selection; out buffer is valid. + let result = unsafe { + ffi::ghostty_terminal_selection_format_buf( + self.raw.as_ptr(), + ffi::GhosttyTerminalSelectionFormatOptions { + size: size_of::(), + emit: ffi::GHOSTTY_FORMATTER_FORMAT_PLAIN, + unwrap: true, + trim: true, + selection: ptr::null(), + }, + buffer.as_mut_ptr(), + buffer.len(), + &mut written, + ) + }; + if result == ffi::GHOSTTY_NO_VALUE { + return Ok(None); + } + check(result)?; + ensure!( + written <= buffer.len(), + "Ghostty wrote beyond the selection buffer" + ); + Ok(Some(written)) + } +} + +impl Drop for Terminal { + fn drop(&mut self) { + // SAFETY: this is the sole owner, and effects remain alive until this call finishes. + unsafe { ffi::ghostty_terminal_free(self.raw.as_ptr()) }; + } +} + +struct Formatter<'a> { + raw: NonNull, + _borrow: PhantomData<(&'a Terminal, &'a ffi::GhosttySelection)>, +} + +impl<'a> Formatter<'a> { + fn new( + terminal: &'a Terminal, + format: Format, + selection: Option<&'a ffi::GhosttySelection>, + ) -> Result { + let extras = selection.is_none(); + let options = ffi::GhosttyFormatterTerminalOptions { + size: size_of::(), + emit: match format { + Format::Plain => ffi::GHOSTTY_FORMATTER_FORMAT_PLAIN, + Format::Vt => ffi::GHOSTTY_FORMATTER_FORMAT_VT, + }, + unwrap: false, + trim: true, + extra: ffi::GhosttyFormatterTerminalExtra { + size: size_of::(), + palette: false, + modes: extras, + scrolling_region: extras, + tabstops: extras, + pwd: extras, + keyboard: extras, + screen: ffi::GhosttyFormatterScreenExtra { + size: size_of::(), + cursor: false, + style: extras, + hyperlink: extras, + protection: extras, + kitty_keyboard: extras, + charsets: extras, + }, + }, + selection: selection.map_or(ptr::null(), ptr::from_ref), + }; + let mut raw = ptr::null_mut(); + // SAFETY: sized fields are initialized, and both borrowed values outlive the formatter. + check(unsafe { + ffi::ghostty_formatter_terminal_new( + ptr::null(), + &mut raw, + terminal.raw.as_ptr(), + options, + ) + })?; + Ok(Self { + raw: NonNull::new(raw).context("Ghostty returned a null formatter")?, + _borrow: PhantomData, + }) + } +} + +impl Drop for Formatter<'_> { + fn drop(&mut self) { + // SAFETY: this is the sole owner; the terminal and selection are still borrowed. + unsafe { ffi::ghostty_formatter_free(self.raw.as_ptr()) }; + } +} + +struct Allocation { + ptr: *mut u8, + len: usize, +} + +impl Drop for Allocation { + fn drop(&mut self) { + // SAFETY: allocated by Ghostty's default allocator, not Rust's allocator. + unsafe { ffi::ghostty_free(ptr::null(), self.ptr, self.len) }; + } +} + +fn check(result: ffi::GhosttyResult) -> Result<()> { + match result { + ffi::GHOSTTY_SUCCESS => Ok(()), + ffi::GHOSTTY_OUT_OF_MEMORY => bail!("Ghostty allocation failed"), + ffi::GHOSTTY_INVALID_VALUE => bail!("Ghostty rejected an invalid value"), + ffi::GHOSTTY_OUT_OF_SPACE => bail!("Ghostty output buffer is too small"), + ffi::GHOSTTY_NO_VALUE => bail!("Ghostty value is unavailable"), + code => bail!("Ghostty failed with result {code}"), + } +} + +#[cfg(test)] +mod tests; diff --git a/packages/process-daemon/src/ghostty/tests.rs b/packages/process-daemon/src/ghostty/tests.rs new file mode 100644 index 000000000000..af7c0c9527ee --- /dev/null +++ b/packages/process-daemon/src/ghostty/tests.rs @@ -0,0 +1,98 @@ +use super::*; +use std::panic::{AssertUnwindSafe, catch_unwind}; + +fn terminal() -> Terminal { + Terminal::new(TerminalOptions { + cols: 20, + rows: 5, + max_scrollback: 1024, + }) + .unwrap() +} + +#[test] +fn callbacks_survive_moves_and_return_owned_data() { + let mut terminals = vec![terminal()]; + let mut terminal = terminals.pop().unwrap(); + terminal.vt_write(b"\x1b[5n\x1b]2;first\x07"); + assert_eq!( + terminal.take_writes(), + VecDeque::from([b"\x1b[0n".to_vec()]) + ); + let title = terminal.take_title().unwrap(); + terminal.vt_write(b"\x1b[5n\x1b]2;second\x07"); + assert_eq!(title, "first"); + assert_eq!(terminal.take_title().as_deref(), Some("second")); + assert_eq!( + terminal.take_writes(), + VecDeque::from([b"\x1b[0n".to_vec()]) + ); + assert!(terminal.take_title().is_none()); + assert!(terminal.take_writes().is_empty()); +} + +#[test] +fn empty_native_values_and_small_buffers_are_safe() { + assert!( + Terminal::new(TerminalOptions { + cols: 0, + rows: 1, + max_scrollback: 0 + }) + .is_err() + ); + let mut terminal = terminal(); + assert_eq!(terminal.title().unwrap(), ""); + assert!(terminal.format(Format::Plain).unwrap().is_empty()); + assert_eq!(terminal.format_row(0, &mut []).unwrap(), 0); + assert_eq!(terminal.selection_text(&mut []).unwrap(), None); + terminal.vt_write("a\u{301}界".as_bytes()); + assert!(terminal.format_row(0, &mut [0; 1]).is_err()); + let mut buffer = [0; 32]; + let len = terminal.format_row(0, &mut buffer).unwrap(); + assert_eq!(&buffer[..len], "a\u{301}界".as_bytes()); + assert!(terminal.format_row(1000, &mut buffer).is_err()); + assert!(terminal.resize(0, 5).is_err()); +} + +#[test] +fn formatted_bytes_outlive_the_native_terminal() { + let bytes = { + let mut terminal = terminal(); + terminal.vt_write("hello 界".as_bytes()); + terminal.format(Format::Plain).unwrap() + }; + assert_eq!(bytes, "hello 界".as_bytes()); +} + +#[test] +fn callback_panics_are_resumed_outside_the_c_boundary() { + let mut terminal = terminal(); + for resize in [false, true] { + // Deliberately provoke an assertion panic inside the callback. It must return + // normally to C; either mutating wrapper resumes the panic later in Rust. + unsafe { + Effects::write_pty( + terminal.raw.as_ptr(), + terminal.effects.userdata().cast_mut(), + ptr::null(), + 1, + ) + }; + assert!( + catch_unwind(AssertUnwindSafe(|| { + if resize { + terminal.resize(30, 10).unwrap(); + } else { + terminal.vt_write(b""); + } + })) + .is_err() + ); + } + terminal.vt_write(b"\x1b[5n"); + assert_eq!( + terminal.take_writes(), + VecDeque::from([b"\x1b[0n".to_vec()]) + ); +} diff --git a/packages/process-daemon/src/lib.rs b/packages/process-daemon/src/lib.rs new file mode 100644 index 000000000000..7ab76b4d210d --- /dev/null +++ b/packages/process-daemon/src/lib.rs @@ -0,0 +1,11 @@ +pub mod client; +pub mod daemon; +mod ghostty; +#[cfg(all(target_os = "linux", target_env = "musl"))] +mod musl; +#[cfg(any(unix, windows))] +mod ownership; +pub mod protocol; +pub mod service; +#[cfg(any(unix, windows))] +mod transport; diff --git a/packages/process-daemon/src/main.rs b/packages/process-daemon/src/main.rs new file mode 100644 index 000000000000..fd3e7b60a6fc --- /dev/null +++ b/packages/process-daemon/src/main.rs @@ -0,0 +1,6 @@ +fn main() { + if let Err(error) = opencode_pty::client::run_cli() { + eprintln!("error: {error:#}"); + std::process::exit(1); + } +} diff --git a/packages/process-daemon/src/musl.rs b/packages/process-daemon/src/musl.rs new file mode 100644 index 000000000000..c928aa898c9d --- /dev/null +++ b/packages/process-daemon/src/musl.rs @@ -0,0 +1,59 @@ +use std::ffi::{c_char, c_int, c_uint, c_void}; + +/// Zig 0.16 assumes musl provides `statx` (added in musl 1.2.5), so libghostty +/// references it, but Rust's self-contained musl is older. Forward to the raw +/// syscall exactly as musl's own wrapper does. +/// +/// # Safety +/// +/// Same contract as `statx(2)`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn statx( + dirfd: c_int, + path: *const c_char, + flags: c_int, + mask: c_uint, + buffer: *mut c_void, +) -> c_int { + // SAFETY: the caller upholds statx(2)'s pointer requirements. + unsafe { libc::syscall(libc::SYS_statx, dirfd, path, flags, mask, buffer) as c_int } +} + +#[cfg(test)] +mod tests { + use super::*; + + // From linux/stat.h; the libc crate does not expose it for musl. + const STATX_BASIC_STATS: c_uint = 0x7ff; + + #[test] + fn statx_matches_the_syscall_contract() { + let mut buffer = [0u8; 256]; + // SAFETY: both paths are NUL-terminated and the buffer exceeds struct statx. + unsafe { + let root = statx( + libc::AT_FDCWD, + c"/".as_ptr(), + 0, + STATX_BASIC_STATS, + buffer.as_mut_ptr().cast(), + ); + assert_eq!(root, 0); + let missing = c"/opencode-pty-missing".as_ptr(); + assert_eq!( + statx( + libc::AT_FDCWD, + missing, + 0, + STATX_BASIC_STATS, + buffer.as_mut_ptr().cast() + ), + -1 + ); + } + assert_eq!( + std::io::Error::last_os_error().raw_os_error(), + Some(libc::ENOENT) + ); + } +} diff --git a/packages/process-daemon/src/ownership.rs b/packages/process-daemon/src/ownership.rs new file mode 100644 index 000000000000..ef42a3c6a4f4 --- /dev/null +++ b/packages/process-daemon/src/ownership.rs @@ -0,0 +1,198 @@ +use std::time::{Duration, Instant}; + +use anyhow::{Result, bail}; + +const ACQUIRE_TIMEOUT: Duration = Duration::from_secs(5); +const HANDOFF_TIMEOUT: Duration = Duration::from_secs(120); + +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct Handoff { + pub ticket: String, + pub expires_at: u64, + deadline: Instant, +} + +pub(crate) struct Ownership { + state: State, + generation: u64, +} + +enum State { + Starting(Instant), + Owned(Option), + Waiting(Handoff), + Stopped, +} + +impl Ownership { + pub fn new(now: Instant) -> Self { + Self { + state: State::Starting(now + ACQUIRE_TIMEOUT), + generation: 0, + } + } + + pub fn tick(&mut self, now: Instant) -> bool { + match &self.state { + State::Starting(deadline) if now >= *deadline => self.state = State::Stopped, + State::Waiting(handoff) if now >= handoff.deadline => self.state = State::Stopped, + State::Owned(Some(handoff)) if now >= handoff.deadline => { + self.state = State::Owned(None) + } + _ => {} + } + matches!(self.state, State::Stopped) + } + + pub fn claim(&mut self, ticket: Option<&str>, now: Instant) -> Result { + self.tick(now); + match &self.state { + State::Starting(_) if ticket.is_none() => {} + State::Owned(Some(handoff)) | State::Waiting(handoff) + if ticket == Some(handoff.ticket.as_str()) => {} + State::Owned(_) => bail!("daemon already has a live owner"), + State::Stopped => bail!("daemon ownership deadline expired or daemon is stopping"), + _ => bail!("invalid handoff ticket"), + } + self.generation += 1; + self.state = State::Owned(None); + Ok(self.generation) + } + + pub fn is_owner(&self, generation: u64) -> bool { + self.generation == generation && matches!(self.state, State::Owned(_)) + } + + pub fn prepare(&mut self, generation: u64, now: Instant, unix_ms: u64) -> Result { + self.tick(now); + if self.generation != generation { + bail!("handoff requires the owner connection"); + } + let State::Owned(handoff) = &mut self.state else { + bail!("handoff requires the owner connection"); + }; + Ok(handoff + .get_or_insert_with(|| Handoff { + ticket: format!("{:032x}", rand::random::()), + expires_at: unix_ms + HANDOFF_TIMEOUT.as_millis() as u64, + deadline: now + HANDOFF_TIMEOUT, + }) + .clone()) + } + + pub fn disconnect(&mut self, generation: u64, now: Instant) { + // A ticket may transfer ownership before the previous connection closes. + if !self.is_owner(generation) { + return; + } + self.tick(now); + self.state = match &self.state { + State::Owned(Some(handoff)) => State::Waiting(handoff.clone()), + _ => State::Stopped, + }; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn acquisition_is_exclusive_and_bounded() { + let now = Instant::now(); + let mut owner = Ownership::new(now); + assert!(owner.claim(Some("unexpected"), now).is_err()); + let generation = owner.claim(None, now).unwrap(); + assert!(owner.claim(None, now).is_err()); + owner.disconnect(generation, now); + assert!(owner.tick(now)); + assert!(owner.claim(None, now).is_err()); + + let mut starting = Ownership::new(now); + assert!(!starting.tick(now + ACQUIRE_TIMEOUT - Duration::from_millis(1))); + assert!(starting.claim(None, now + ACQUIRE_TIMEOUT).is_err()); + assert!(starting.tick(now + ACQUIRE_TIMEOUT)); + } + + #[test] + fn handoff_is_nonrenewing_and_consumed_by_claim() { + let now = Instant::now(); + let mut owner = Ownership::new(now); + let generation = owner.claim(None, now).unwrap(); + let handoff = owner.prepare(generation, now, 1000).unwrap(); + assert_eq!(handoff.expires_at, 121_000); + assert_eq!( + owner + .prepare(generation, now + Duration::from_secs(60), 61_000) + .unwrap(), + handoff + ); + owner.disconnect(generation, now); + assert!(owner.claim(None, now).is_err()); + assert!(owner.claim(Some("wrong"), now).is_err()); + let successor = owner.claim(Some(&handoff.ticket), now).unwrap(); + assert!(owner.claim(Some(&handoff.ticket), now).is_err()); + owner.disconnect(successor, now); + assert!(owner.tick(now)); + } + + #[test] + fn valid_ticket_replaces_live_owner_and_fences_old_connection() { + let now = Instant::now(); + let mut owner = Ownership::new(now); + let generation = owner.claim(None, now).unwrap(); + let handoff = owner.prepare(generation, now, 1000).unwrap(); + assert!(owner.claim(None, now).is_err()); + assert!(owner.claim(Some("wrong"), now).is_err()); + assert!(owner.is_owner(generation)); + + let successor = owner.claim(Some(&handoff.ticket), now).unwrap(); + assert!(!owner.is_owner(generation)); + assert!(owner.is_owner(successor)); + assert!(owner.claim(Some(&handoff.ticket), now).is_err()); + assert!(owner.prepare(generation, now, 1000).is_err()); + owner.disconnect(generation, now); + assert!(owner.is_owner(successor)); + assert!(!owner.tick(now)); + + let next = owner.prepare(successor, now, 1000).unwrap(); + owner.disconnect(generation, now); + assert!(owner.is_owner(successor)); + owner.disconnect(successor, now); + // Stale cleanup must not discard the successor's pending handoff either. + owner.disconnect(generation, now); + let last = owner.claim(Some(&next.ticket), now).unwrap(); + owner.disconnect(last, now); + assert!(owner.tick(now)); + } + + #[test] + fn expiry_only_stops_a_disconnected_owner() { + let now = Instant::now(); + let mut owner = Ownership::new(now); + let generation = owner.claim(None, now).unwrap(); + let handoff = owner.prepare(generation, now, 1000).unwrap(); + assert!(!owner.tick(handoff.deadline)); + assert!(matches!(owner.state, State::Owned(None))); + assert!( + owner + .claim(Some(&handoff.ticket), handoff.deadline) + .is_err() + ); + assert!(owner.is_owner(generation)); + owner.disconnect(generation, handoff.deadline); + assert!(owner.tick(handoff.deadline)); + + let mut owner = Ownership::new(now); + let generation = owner.claim(None, now).unwrap(); + let handoff = owner.prepare(generation, now, 1000).unwrap(); + owner.disconnect(generation, now); + assert!(!owner.tick(handoff.deadline - Duration::from_millis(1))); + assert!( + owner + .claim(Some(&handoff.ticket), handoff.deadline) + .is_err() + ); + assert!(owner.tick(handoff.deadline)); + } +} diff --git a/packages/process-daemon/src/protocol.rs b/packages/process-daemon/src/protocol.rs new file mode 100644 index 000000000000..26f05b7bce20 --- /dev/null +++ b/packages/process-daemon/src/protocol.rs @@ -0,0 +1,287 @@ +use std::io::{Read, Write}; +use std::path::PathBuf; + +use anyhow::{Context, Result, bail}; +use serde::{Deserialize, Serialize}; + +use crate::service::{TerminalId, TerminalInfo}; + +pub const PROTOCOL_VERSION: u32 = 7; +pub const MAX_FRAME_BYTES: usize = 8 * 1024 * 1024; +const OUTPUT_FRAME_TAG: u8 = 0; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AttachmentRole { + Controller, + Observer, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Envelope { + pub token: String, + pub request: Request, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "snake_case", tag = "op")] +pub enum Request { + Ping, + Own { + instance_id: String, + ticket: Option, + }, + PrepareHandoff, + Create { + program: String, + args: Vec, + cwd: PathBuf, + title: String, + group_id: String, + env: std::collections::HashMap, + cols: u16, + rows: u16, + }, + List, + Write { + id: TerminalId, + attachment_id: Option, + data_base64: String, + }, + Resize { + id: TerminalId, + attachment_id: Option, + cols: u16, + rows: u16, + }, + Control { + id: TerminalId, + attachment_id: String, + cols: u16, + rows: u16, + }, + Input { + id: TerminalId, + attachment_id: String, + cols: u16, + rows: u16, + data_base64: String, + }, + Snapshot { + id: TerminalId, + }, + ReadRows { + id: TerminalId, + rows: Option, + }, + Replay { + id: TerminalId, + offset: u64, + }, + Subscribe { + id: TerminalId, + offset: u64, + attachment_id: String, + role: AttachmentRole, + takeover: bool, + }, + Terminate { + id: TerminalId, + }, + Shutdown, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "snake_case", tag = "type")] +pub enum Response { + Owned, + Handoff { + ticket: String, + expires_at: u64, + }, + Pong { + instance_id: String, + pid: u32, + protocol: u32, + }, + Created { + terminal: TerminalInfo, + }, + Terminals { + terminals: Vec, + }, + Ok, + Snapshot { + terminal: TerminalInfo, + text: String, + checkpoint_base64: String, + cursor_x: u16, + cursor_y: u16, + }, + Rows { + terminal: TerminalInfo, + lines: Vec, + cursor_x: u16, + cursor_y: u16, + }, + Replay { + requested_offset: u64, + available_offset: u64, + end_offset: u64, + truncated: bool, + data_base64: String, + }, + Attached { + terminal: TerminalInfo, + role: AttachmentRole, + generation: u64, + requested_offset: u64, + available_offset: u64, + end_offset: u64, + truncated: bool, + replay_base64: String, + }, + Resized { + cols: u16, + rows: u16, + generation: u64, + checkpoint_base64: String, + }, + Exited { + exit_code: Option, + final_offset: u64, + }, + ControllerChanged { + attachment_id: Option, + generation: u64, + }, + TitleChanged { + title: String, + }, + ForegroundProcessChanged { + process: Option, + }, + Error { + message: String, + }, +} + +pub fn write_frame(mut writer: impl Write, value: &impl Serialize) -> Result<()> { + let payload = serde_json::to_vec(value)?; + if payload.len() > MAX_FRAME_BYTES { + bail!("frame exceeds {MAX_FRAME_BYTES} byte limit"); + } + let length = u32::try_from(payload.len()).context("frame length exceeds u32")?; + writer.write_all(&length.to_be_bytes())?; + writer.write_all(&payload)?; + writer.flush()?; + Ok(()) +} + +pub fn write_output_frame( + mut writer: impl Write, + start: u64, + end: u64, + bytes: &[u8], +) -> Result<()> { + let payload_len = bytes + .len() + .checked_add(17) + .context("output frame length overflow")?; + if payload_len > MAX_FRAME_BYTES { + bail!("frame exceeds {MAX_FRAME_BYTES} byte limit"); + } + let length = u32::try_from(payload_len).context("frame length exceeds u32")?; + writer.write_all(&length.to_be_bytes())?; + writer.write_all(&[OUTPUT_FRAME_TAG])?; + writer.write_all(&start.to_be_bytes())?; + writer.write_all(&end.to_be_bytes())?; + writer.write_all(bytes)?; + writer.flush()?; + Ok(()) +} + +#[derive(Debug)] +pub enum SubscriptionEvent { + Output { + start: u64, + end: u64, + bytes: Vec, + }, + Response(Box), +} + +pub fn read_subscription_event(mut reader: impl Read) -> Result { + let payload = read_payload(&mut reader)?; + if payload.first() != Some(&OUTPUT_FRAME_TAG) { + return serde_json::from_slice(&payload) + .map(Box::new) + .map(SubscriptionEvent::Response) + .context("invalid protocol JSON"); + } + if payload.len() < 17 { + bail!("invalid output frame"); + } + Ok(SubscriptionEvent::Output { + start: u64::from_be_bytes(payload[1..9].try_into().expect("fixed start offset")), + end: u64::from_be_bytes(payload[9..17].try_into().expect("fixed end offset")), + bytes: payload[17..].to_vec(), + }) +} + +pub fn read_frame Deserialize<'de>>(mut reader: impl Read) -> Result { + serde_json::from_slice(&read_payload(&mut reader)?).context("invalid protocol JSON") +} + +fn read_payload(mut reader: impl Read) -> Result> { + let mut length = [0_u8; 4]; + reader.read_exact(&mut length)?; + let length = u32::from_be_bytes(length) as usize; + if length > MAX_FRAME_BYTES { + bail!("frame claims {length} bytes, limit is {MAX_FRAME_BYTES}"); + } + let mut payload = vec![0_u8; length]; + reader.read_exact(&mut payload)?; + Ok(payload) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn read_rows_accepts_optional_height_and_u16_counts() { + for json in [ + r#"{"op":"read_rows","id":1}"#, + r#"{"op":"read_rows","id":1,"rows":null}"#, + ] { + assert!(matches!( + serde_json::from_str::(json).unwrap(), + Request::ReadRows { id: 1, rows: None } + )); + } + for rows in [0, 1, u16::MAX] { + let value = serde_json::to_value(Request::ReadRows { + id: 1, + rows: Some(rows), + }) + .unwrap(); + assert_eq!( + value, + serde_json::json!({"op": "read_rows", "id": 1, "rows": rows}) + ); + assert!( + matches!(serde_json::from_value::(value).unwrap(), Request::ReadRows { rows: Some(count), .. } if count == rows) + ); + } + for rows in [-1, 65536] { + assert!( + serde_json::from_value::( + serde_json::json!({"op": "read_rows", "id": 1, "rows": rows}) + ) + .is_err() + ); + } + assert_eq!(PROTOCOL_VERSION, 7); + } +} diff --git a/packages/process-daemon/src/service.rs b/packages/process-daemon/src/service.rs new file mode 100644 index 000000000000..6b13c8b10173 --- /dev/null +++ b/packages/process-daemon/src/service.rs @@ -0,0 +1,2496 @@ +#[cfg(target_os = "linux")] +use std::collections::HashSet; +use std::collections::{HashMap, VecDeque}; +use std::env; +use std::io::{self, Read, Write}; +use std::path::PathBuf; +use std::sync::{Arc, Mutex, RwLock, mpsc as std_mpsc}; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result, anyhow, bail}; +use crossbeam_channel::{Receiver, Sender, bounded}; +use portable_pty::{ChildKiller, CommandBuilder, MasterPty, PtySize, native_pty_system}; +use serde::{Deserialize, Serialize}; + +use crate::ghostty::{Format, Terminal, TerminalOptions}; +use crate::protocol::AttachmentRole; + +#[cfg(windows)] +mod windows; + +const DEFAULT_COLS: u16 = 100; +const DEFAULT_ROWS: u16 = 30; +const REPLAY_CAPACITY: usize = 2 * 1024 * 1024; +const ACTOR_QUEUE_CAPACITY: usize = 128; +const WRITER_QUEUE_CAPACITY: usize = 128; +const SUBSCRIBER_QUEUE_CAPACITY: usize = 1024; +const OUTPUT_BATCH_BYTES: usize = 8 * 1024; +const OUTPUT_BATCH_DELAY: Duration = Duration::from_millis(1); +const MAX_INPUT_BYTES: usize = 1024 * 1024; +const MAX_ROWS_BYTES: usize = 1024 * 1024; + +pub type TerminalId = u64; + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case", tag = "status")] +pub enum TerminalLifecycle { + Running, + Exited { exit_code: Option }, + Failed { message: String }, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct TerminalInfo { + pub id: TerminalId, + pub pid: Option, + pub title: String, + pub foreground_process: Option, + pub group_id: String, + pub command: Vec, + pub cwd: PathBuf, + pub cols: u16, + pub rows: u16, + pub lifecycle: TerminalLifecycle, + pub output_head: u64, + pub output_tail: u64, +} + +#[derive(Clone, Debug)] +pub struct TerminalSnapshot { + pub info: TerminalInfo, + pub text: String, + pub checkpoint: Vec, + pub cursor_x: u16, + pub cursor_y: u16, +} + +#[derive(Clone, Debug)] +pub struct TerminalRows { + pub terminal: TerminalInfo, + pub lines: Vec, + pub cursor_x: u16, + pub cursor_y: u16, +} + +#[derive(Clone, Debug)] +pub struct RawReplay { + pub requested_offset: u64, + pub available_offset: u64, + pub end_offset: u64, + pub truncated: bool, + pub bytes: Vec, +} + +#[derive(Clone, Debug)] +pub enum StreamEvent { + Output { + start: u64, + end: u64, + bytes: Vec, + }, + Resized { + cols: u16, + rows: u16, + generation: u64, + checkpoint: Vec, + }, + Exited { + exit_code: Option, + final_offset: u64, + }, + ControllerChanged { + attachment_id: Option, + generation: u64, + }, + TitleChanged { + title: String, + }, + ForegroundProcessChanged { + process: Option, + }, +} + +pub struct TerminalAttachment { + pub terminal: TerminalInfo, + pub role: AttachmentRole, + pub generation: u64, + pub replay: RawReplay, + pub events: Receiver, + actor_tx: Sender, + attachment_id: String, + disconnect_tx: Option>, +} + +impl Drop for TerminalAttachment { + fn drop(&mut self) { + // Guard lifetime, not the lifetime of public event-receiver clones, + // controls subscription cancellation. Wake a blocked controller send + // before reliably queuing Detach on the possibly full actor channel. + drop(self.disconnect_tx.take()); + let _ = self.actor_tx.send(ActorMessage::Detach { + attachment_id: self.attachment_id.clone(), + }); + } +} + +#[derive(Clone, Debug)] +pub struct CreateTerminal { + pub program: String, + pub args: Vec, + pub cwd: PathBuf, + pub title: String, + pub group_id: String, + pub env: HashMap, + pub cols: u16, + pub rows: u16, +} + +impl CreateTerminal { + pub fn shell() -> Result { + let program = default_shell(); + let title = PathBuf::from(&program) + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("shell") + .to_string(); + Ok(Self { + program, + args: Vec::new(), + cwd: env::current_dir()?, + title, + group_id: format!("local:{}", env::current_dir()?.display()), + env: HashMap::new(), + cols: DEFAULT_COLS, + rows: DEFAULT_ROWS, + }) + } +} + +pub struct TerminalService { + terminals: Mutex>>, + replay_capacity: usize, +} + +impl Default for TerminalService { + fn default() -> Self { + Self::new(REPLAY_CAPACITY) + } +} + +impl TerminalService { + pub fn new(replay_capacity: usize) -> Self { + Self { + terminals: Mutex::new(HashMap::new()), + replay_capacity, + } + } + + pub fn create(&self, request: CreateTerminal) -> Result { + validate_size(request.cols, request.rows)?; + if request.program.trim().is_empty() { + bail!("terminal program cannot be empty"); + } + + let id = loop { + // JSON clients can represent integers exactly only through 53 bits. + let id = rand::random::() & ((1_u64 << 53) - 1); + if id != 0 + && !self + .terminals + .lock() + .map_err(|_| anyhow!("terminal registry lock poisoned"))? + .contains_key(&id) + { + break id; + } + }; + let handle = TerminalHandle::spawn(id, request, self.replay_capacity)?; + let info = handle.info(); + self.terminals + .lock() + .map_err(|_| anyhow!("terminal registry lock poisoned"))? + .insert(id, Arc::new(handle)); + Ok(info) + } + + pub fn list(&self) -> Result> { + let handles = self + .terminals + .lock() + .map_err(|_| anyhow!("terminal registry lock poisoned"))? + .values() + .cloned() + .collect::>(); + let mut terminals = Vec::with_capacity(handles.len()); + for terminal in handles { + // Windows has no foreground query; do not wait behind actor + // backpressure just to read its already-cached metadata. + #[cfg(not(windows))] + terminal.request(|reply| ActorMessage::RefreshForegroundProcess { reply })?; + terminals.push(terminal.info()); + } + terminals.sort_by_key(|terminal| terminal.id); + Ok(terminals) + } + + pub fn write(&self, id: TerminalId, bytes: Vec) -> Result<()> { + self.write_for(id, None, bytes) + } + + pub fn write_for( + &self, + id: TerminalId, + attachment_id: Option, + bytes: Vec, + ) -> Result<()> { + if bytes.len() > MAX_INPUT_BYTES { + bail!("input exceeds {MAX_INPUT_BYTES} byte limit"); + } + self.get(id)?.request(|reply| ActorMessage::Write { + attachment_id, + bytes, + reply, + }) + } + + pub fn resize(&self, id: TerminalId, cols: u16, rows: u16) -> Result<()> { + self.resize_for(id, None, cols, rows) + } + + pub fn resize_for( + &self, + id: TerminalId, + attachment_id: Option, + cols: u16, + rows: u16, + ) -> Result<()> { + validate_size(cols, rows)?; + self.get(id)?.request(|reply| ActorMessage::Resize { + attachment_id, + cols, + rows, + reply, + }) + } + + pub fn control( + &self, + id: TerminalId, + attachment_id: String, + cols: u16, + rows: u16, + ) -> Result<()> { + validate_size(cols, rows)?; + self.get(id)?.request(|reply| ActorMessage::Control { + attachment_id, + cols, + rows, + bytes: None, + reply, + }) + } + + pub fn input( + &self, + id: TerminalId, + attachment_id: String, + cols: u16, + rows: u16, + bytes: Vec, + ) -> Result<()> { + validate_size(cols, rows)?; + if bytes.len() > MAX_INPUT_BYTES { + bail!("input exceeds {MAX_INPUT_BYTES} byte limit"); + } + self.get(id)?.request(|reply| ActorMessage::Control { + attachment_id, + cols, + rows, + bytes: Some(bytes), + reply, + }) + } + + pub fn snapshot(&self, id: TerminalId) -> Result { + self.get(id)? + .request(|reply| ActorMessage::Snapshot { reply }) + } + + pub fn read_rows(&self, id: TerminalId, rows: Option) -> Result { + self.get(id)? + .request(|reply| ActorMessage::ReadRows { rows, reply }) + } + + pub fn replay(&self, id: TerminalId, offset: u64) -> Result { + self.get(id)? + .request(|reply| ActorMessage::Replay { offset, reply }) + } + + pub fn attach( + &self, + id: TerminalId, + offset: u64, + attachment_id: String, + role: AttachmentRole, + takeover: bool, + ) -> Result { + let terminal = self.get(id)?; + let (reply_tx, reply_rx) = bounded(1); + terminal + .actor_tx + .send(ActorMessage::Attach { + offset, + attachment_id: attachment_id.clone(), + role, + takeover, + reply: reply_tx, + }) + .map_err(|_| anyhow!("terminal actor stopped"))?; + let attached = await_reply(&reply_rx, &terminal.actor_done)?; + Ok(TerminalAttachment { + terminal: terminal.info(), + role, + generation: attached.generation, + replay: attached.replay, + events: attached.events, + actor_tx: terminal.actor_tx.clone(), + attachment_id, + disconnect_tx: Some(attached.disconnect_tx), + }) + } + + pub fn terminate(&self, id: TerminalId) -> Result<()> { + let terminal = self + .terminals + .lock() + .map_err(|_| anyhow!("terminal registry lock poisoned"))? + .remove(&id) + .ok_or_else(|| anyhow!("terminal {id} not found"))?; + terminal.shutdown() + } + + fn get(&self, id: TerminalId) -> Result> { + self.terminals + .lock() + .map_err(|_| anyhow!("terminal registry lock poisoned"))? + .get(&id) + .cloned() + .ok_or_else(|| anyhow!("terminal {id} not found")) + } + + pub(crate) fn shutdown(&self) { + let terminals = self + .terminals + .lock() + .map(|mut terminals| { + terminals + .drain() + .map(|(_, terminal)| terminal) + .collect::>() + }) + .unwrap_or_default(); + // Start every terminal's existing kill/join path before waiting for any + // one terminal's potentially blocked PTY workers. + thread::scope(|scope| { + for terminal in terminals { + scope.spawn(move || { + let _ = terminal.shutdown(); + }); + } + }); + } +} + +impl Drop for TerminalService { + fn drop(&mut self) { + self.shutdown(); + } +} + +struct TerminalHandle { + actor_tx: Sender, + /// Disconnects when the actor thread exits; never carries a message. + actor_done: Receiver<()>, + shutdown_tx: Mutex>>, + killer: Mutex>, + info: Arc>, + joins: Mutex>>>, +} + +impl TerminalHandle { + fn spawn(id: TerminalId, request: CreateTerminal, replay_capacity: usize) -> Result { + let pty_system = native_pty_system(); + let pair = pty_system + .openpty(PtySize { + rows: request.rows, + cols: request.cols, + pixel_width: 0, + pixel_height: 0, + }) + .context("failed to open PTY")?; + + let mut command = CommandBuilder::new(&request.program); + command.args(request.args.iter()); + command.cwd(&request.cwd); + for (key, value) in &request.env { + command.env(key, value); + } + command.env("TERM", "xterm-256color"); + command.env("COLORTERM", "truecolor"); + + let child = pair + .slave + .spawn_command(command) + .with_context(|| format!("failed to spawn {}", request.program))?; + let pid = child.process_id(); + let killer = child.clone_killer(); + let mut reader = pair + .master + .try_clone_reader() + .context("failed to clone PTY reader")?; + let writer = pair + .master + .take_writer() + .context("failed to take PTY writer")?; + drop(pair.slave); + + let info = Arc::new(RwLock::new(TerminalInfo { + id, + pid, + title: request.title, + foreground_process: None, + group_id: request.group_id, + command: std::iter::once(request.program) + .chain(request.args) + .collect(), + cwd: request.cwd, + cols: request.cols, + rows: request.rows, + lifecycle: TerminalLifecycle::Running, + output_head: 0, + output_tail: 0, + })); + + let (actor_tx, actor_rx) = bounded(ACTOR_QUEUE_CAPACITY); + let (writer_tx, writer_rx) = bounded(WRITER_QUEUE_CAPACITY); + // Disconnecting this channel wakes every cancellable queue operation, + // even when the actor cannot consume an ordinary shutdown message. + let (shutdown_tx, shutdown) = bounded(0); + #[cfg(windows)] + let (close_tx, close_rx) = bounded(1); + let (init_tx, init_rx) = std_mpsc::sync_channel(1); + + let actor_info = Arc::clone(&info); + let actor_writer_tx = writer_tx.clone(); + let actor_shutdown = shutdown.clone(); + let (actor_done_tx, actor_done) = bounded::<()>(0); + let actor_thread = thread::Builder::new() + .name(format!("opencode-pty-actor-{id}")) + .spawn(move || { + let _actor_done = actor_done_tx; + let result = run_actor(ActorConfig { + master: ActorMaster { + pty: Some(pair.master), + #[cfg(windows)] + close_tx, + }, + messages: actor_rx, + writes: actor_writer_tx, + shutdown: actor_shutdown, + info: actor_info, + replay_capacity, + cols: request.cols, + rows: request.rows, + init_tx, + }); + if let Err(error) = result { + eprintln!("terminal {id} actor failed: {error:#}"); + } + }) + .context("failed to spawn terminal actor")?; + + match init_rx + .recv() + .context("terminal actor stopped during initialization")? + { + Ok(()) => {} + Err(error) => { + let mut killer = killer; + let _ = killer.kill(); + let _ = actor_thread.join(); + bail!(error); + } + } + + let writer_actor_tx = actor_tx.clone(); + let writer_thread = thread::Builder::new() + .name(format!("opencode-pty-writer-{id}")) + .spawn(move || run_writer(writer, writer_rx, writer_actor_tx, shutdown)) + .context("failed to spawn terminal writer")?; + + let reader_actor_tx = actor_tx.clone(); + let reader_thread = thread::Builder::new() + .name(format!("opencode-pty-reader-{id}")) + .spawn(move || { + let mut buffer = [0_u8; 8192]; + let mut forwarding = true; + loop { + match reader.read(&mut buffer) { + Ok(0) => { + let _ = reader_actor_tx.send(ActorMessage::ReaderEof); + break; + } + Ok(length) => { + if forwarding { + forwarding = reader_actor_tx + .send(ActorMessage::Output(buffer[..length].to_vec())) + .is_ok(); + #[cfg(unix)] + if !forwarding { + break; + } + } + // On Windows, ClosePseudoConsole can wait for its + // output pipe to drain. Once the actor stops, keep + // this sole reader draining without forwarding. + } + Err(error) if error.kind() == io::ErrorKind::Interrupted => continue, + #[cfg(unix)] + Err(error) if error.raw_os_error() == Some(libc::EIO) => { + let _ = reader_actor_tx.send(ActorMessage::ReaderEof); + break; + } + Err(error) => { + let _ = + reader_actor_tx.send(ActorMessage::ReaderFailed(error.to_string())); + break; + } + } + } + }) + .context("failed to spawn terminal reader")?; + + let wait_actor_tx = actor_tx.clone(); + let wait_thread = thread::Builder::new() + .name(format!("opencode-pty-wait-{id}")) + .spawn(move || { + #[cfg(windows)] + windows::wait_and_close(child, wait_actor_tx, close_rx); + #[cfg(unix)] + { + let mut child = child; + let result = child.wait().map(|status| Some(status.exit_code())); + let _ = wait_actor_tx.send(ActorMessage::ChildExited( + result.map_err(|error| error.to_string()), + )); + } + }) + .context("failed to spawn terminal child waiter")?; + + Ok(Self { + actor_tx, + actor_done, + shutdown_tx: Mutex::new(Some(shutdown_tx)), + killer: Mutex::new(killer), + info, + joins: Mutex::new(Some(vec![ + actor_thread, + writer_thread, + reader_thread, + wait_thread, + ])), + }) + } + + fn info(&self) -> TerminalInfo { + self.info + .read() + .map(|info| info.clone()) + .unwrap_or_else(|poisoned| poisoned.into_inner().clone()) + } + + fn request(&self, make: impl FnOnce(Reply>) -> ActorMessage) -> Result { + let (reply_tx, reply_rx) = bounded(1); + self.actor_tx + .send(make(reply_tx)) + .map_err(|_| anyhow!("terminal actor stopped"))?; + await_reply(&reply_rx, &self.actor_done) + } + + fn shutdown(&self) -> Result<()> { + // Keep this guard through the joins: concurrent shutdown callers must + // not return early or signal the child again after it has been reaped. + let mut joins = self + .joins + .lock() + .map_err(|_| anyhow!("terminal join lock poisoned"))?; + let Some(handles) = joins.take() else { + return Ok(()); + }; + self.shutdown_tx + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .take(); + if let Ok(mut killer) = self.killer.lock() { + let _ = killer.kill(); + } + for join in handles { + let _ = join.join(); + } + Ok(()) + } +} + +type Reply = Sender; + +/// A request accepted into the actor queue may never be handled: shutdown +/// overtakes queued work, and a stopped actor's buffered messages (with their +/// reply senders) stay alive while other handles hold queue senders. Waiting on +/// the actor's lifetime as well as the reply keeps callers from blocking forever. +fn await_reply(reply: &Receiver>, actor_done: &Receiver<()>) -> Result { + crossbeam_channel::select_biased! { + recv(reply) -> result => result.map_err(|_| anyhow!("terminal actor dropped response"))?, + recv(actor_done) -> _ => reply + .try_recv() + .map_err(|_| anyhow!("terminal actor stopped"))?, + } +} + +enum ActorMessage { + Output(Vec), + ReaderEof, + ReaderFailed(String), + WriterFailed(String), + ChildExited(Result, String>), + #[cfg(not(windows))] + RefreshForegroundProcess { + reply: Reply>, + }, + Write { + attachment_id: Option, + bytes: Vec, + reply: Reply>, + }, + Resize { + attachment_id: Option, + cols: u16, + rows: u16, + reply: Reply>, + }, + Control { + attachment_id: String, + cols: u16, + rows: u16, + bytes: Option>, + reply: Reply>, + }, + Snapshot { + reply: Reply>, + }, + ReadRows { + rows: Option, + reply: Reply>, + }, + Replay { + offset: u64, + reply: Reply>, + }, + Attach { + offset: u64, + attachment_id: String, + role: AttachmentRole, + takeover: bool, + reply: Reply>, + }, + Detach { + attachment_id: String, + }, +} + +struct Attached { + generation: u64, + replay: RawReplay, + events: Receiver, + disconnect_tx: Sender<()>, +} + +struct Subscriber { + events: Sender, + disconnected: Receiver<()>, + role: AttachmentRole, + last_control: u64, +} + +impl Subscriber { + fn is_disconnected(&self) -> bool { + matches!( + self.disconnected.try_recv(), + Err(crossbeam_channel::TryRecvError::Disconnected) + ) + } +} + +enum WriterMessage { + Bytes(Vec), +} + +struct ActorMaster { + pty: Option>, + #[cfg(windows)] + close_tx: Sender>, +} + +impl ActorMaster { + fn get(&self) -> Result<&(dyn MasterPty + Send)> { + self.pty + .as_deref() + .ok_or_else(|| anyhow!("terminal child has exited")) + } + + #[cfg(windows)] + fn close(&mut self) -> Result<()> { + if let Some(master) = self.pty.take() { + // A capacity-one channel receives this unique master exactly once; + // handoff cannot wait on ClosePseudoConsole or PTY output drainage. + if let Err(error) = self.close_tx.send(master) { + // Keep ownership on failure. The actor stops receiving before + // dropping this fallback master, so its reader can still drain. + self.pty = Some(error.0); + bail!("terminal close worker stopped"); + } + } + Ok(()) + } +} + +impl Drop for ActorMaster { + fn drop(&mut self) { + #[cfg(windows)] + let _ = self.close(); + } +} + +struct ActorConfig { + master: ActorMaster, + messages: Receiver, + writes: Sender, + shutdown: Receiver<()>, + info: Arc>, + replay_capacity: usize, + cols: u16, + rows: u16, + init_tx: std_mpsc::SyncSender>, +} + +fn run_actor(config: ActorConfig) -> Result<()> { + let ActorConfig { + master, + messages, + writes, + shutdown, + info, + replay_capacity, + cols, + rows, + init_tx, + } = config; + #[cfg(windows)] + let mut master = master; + let mut terminal = Terminal::new(TerminalOptions { + cols, + rows, + max_scrollback: replay_capacity, + })?; + + let mut replay = ReplayBuffer::new(replay_capacity); + let mut subscribers = HashMap::::new(); + let mut controller = None::; + let mut controller_generation = 0_u64; + let mut child_exit = None::, String>>; + let mut reader_eof = false; + let mut final_exit = None::; + let mut pending_message = None; + let _ = init_tx.send(Ok(())); + + let result = loop { + match receive_actor_message(&messages, &mut pending_message, &shutdown) { + #[cfg(not(windows))] + Ok(ActorMessage::RefreshForegroundProcess { reply }) => { + if let Ok(master) = master.get() { + publish_foreground_process( + &shutdown, + master, + &info, + &mut subscribers, + &mut controller, + &mut controller_generation, + ); + } + let _ = reply.send(Ok(())); + } + Ok(ActorMessage::Output(bytes)) => { + let (start, end) = replay.append(&bytes); + terminal.vt_write(&bytes); + // Once the Windows master is handed off for closing, the writer + // may already have stopped; replies to the closing console are + // discarded so final output still reaches the real EOF. + #[cfg(windows)] + let closing = master.get().is_err(); + #[cfg(not(windows))] + let closing = false; + if closing { + terminal.take_writes(); + } else if let Err(error) = forward_replies(&mut terminal, &writes, &shutdown) { + break Err(error); + } + update_offsets(&info, &replay); + broadcast( + &shutdown, + &mut subscribers, + &mut controller, + &mut controller_generation, + StreamEvent::Output { start, end, bytes }, + ); + if let Some(title) = terminal.take_title() { + let changed = if let Ok(mut value) = info.write() { + if value.title == title { + false + } else { + value.title = title.clone(); + true + } + } else { + false + }; + if changed { + broadcast( + &shutdown, + &mut subscribers, + &mut controller, + &mut controller_generation, + StreamEvent::TitleChanged { title }, + ); + } + } + } + Ok(ActorMessage::Write { + attachment_id, + bytes, + reply, + }) => { + let result = + authorize_controller(&controller, attachment_id.as_deref()).and_then(|_| { + #[cfg(windows)] + master.get()?; + queue_write(&writes, &shutdown, bytes) + }); + let _ = reply.send(result); + } + Ok(ActorMessage::Resize { + attachment_id, + cols, + rows, + reply, + }) => { + let result = (|| { + authorize_controller(&controller, attachment_id.as_deref())?; + master.get()?.resize(PtySize { + rows, + cols, + pixel_width: 0, + pixel_height: 0, + })?; + let resized = terminal.resize(cols, rows); + forward_replies(&mut terminal, &writes, &shutdown)?; + resized?; + if let Ok(mut value) = info.write() { + value.cols = cols; + value.rows = rows; + } + let generation = controller_generation; + let checkpoint = format_terminal(&terminal, Format::Vt)?.into_bytes(); + broadcast( + &shutdown, + &mut subscribers, + &mut controller, + &mut controller_generation, + StreamEvent::Resized { + cols, + rows, + generation, + checkpoint, + }, + ); + Ok(()) + })(); + let _ = reply.send(result); + } + Ok(ActorMessage::Control { + attachment_id, + cols, + rows, + bytes, + reply, + }) => { + let result = (|| { + if !subscribers.contains_key(&attachment_id) { + bail!("attachment {attachment_id} is not subscribed"); + } + if controller.as_ref() != Some(&attachment_id) { + if let Some(current) = controller.as_ref() + && let Some(subscriber) = subscribers.get_mut(current) + { + subscriber.role = AttachmentRole::Observer; + } + controller_generation = controller_generation.saturating_add(1); + controller = Some(attachment_id.clone()); + if let Some(subscriber) = subscribers.get_mut(&attachment_id) { + subscriber.role = AttachmentRole::Controller; + subscriber.last_control = controller_generation; + } + let generation = controller_generation; + broadcast( + &shutdown, + &mut subscribers, + &mut controller, + &mut controller_generation, + StreamEvent::ControllerChanged { + attachment_id: Some(attachment_id.clone()), + generation, + }, + ); + } + let changed = info + .read() + .map(|value| value.cols != cols || value.rows != rows) + .unwrap_or(true); + if changed { + master.get()?.resize(PtySize { + rows, + cols, + pixel_width: 0, + pixel_height: 0, + })?; + let resized = terminal.resize(cols, rows); + forward_replies(&mut terminal, &writes, &shutdown)?; + resized?; + if let Ok(mut value) = info.write() { + value.cols = cols; + value.rows = rows; + } + let generation = controller_generation; + let checkpoint = format_terminal(&terminal, Format::Vt)?.into_bytes(); + broadcast( + &shutdown, + &mut subscribers, + &mut controller, + &mut controller_generation, + StreamEvent::Resized { + cols, + rows, + generation, + checkpoint, + }, + ); + } + if let Some(bytes) = bytes { + #[cfg(windows)] + master.get()?; + queue_write(&writes, &shutdown, bytes)?; + } + Ok(()) + })(); + let _ = reply.send(result); + } + Ok(ActorMessage::Snapshot { reply }) => { + let _ = reply.send(make_snapshot(&terminal, &info)); + } + Ok(ActorMessage::ReadRows { rows, reply }) => { + let result = (|| { + Ok(TerminalRows { + lines: format_rows(&terminal, rows)?, + terminal: info + .read() + .map_err(|_| anyhow!("terminal info lock poisoned"))? + .clone(), + cursor_x: terminal.cursor_x()?, + cursor_y: terminal.cursor_y()?, + }) + })(); + let _ = reply.send(result); + } + Ok(ActorMessage::Replay { offset, reply }) => { + let _ = reply.send(replay.read_from(offset)); + } + Ok(ActorMessage::Attach { + offset, + attachment_id, + role, + takeover, + reply, + }) => { + let result = (|| { + if role == AttachmentRole::Controller { + if let Some(current) = controller.as_ref() { + if current != &attachment_id && !takeover { + bail!("terminal already has controller {current}"); + } + if current != &attachment_id + && let Some(subscriber) = subscribers.get_mut(current) + { + subscriber.role = AttachmentRole::Observer; + } + } + if controller.as_ref() != Some(&attachment_id) { + controller_generation = controller_generation.saturating_add(1); + controller = Some(attachment_id.clone()); + } + } + let replay = replay.read_from(offset)?; + let (events_tx, events) = bounded(SUBSCRIBER_QUEUE_CAPACITY); + let (disconnect_tx, disconnected) = bounded(0); + subscribers.insert( + attachment_id.clone(), + Subscriber { + events: events_tx.clone(), + disconnected, + role, + last_control: if role == AttachmentRole::Controller { + controller_generation + } else { + 0 + }, + }, + ); + if role == AttachmentRole::Controller { + let controller_id = controller.clone(); + let generation = controller_generation; + broadcast( + &shutdown, + &mut subscribers, + &mut controller, + &mut controller_generation, + StreamEvent::ControllerChanged { + attachment_id: controller_id, + generation, + }, + ); + } + if let Some(event) = &final_exit { + // This fresh receiver is still actor-owned and has at + // most its initial ControllerChanged event queued. + let _ = events_tx.send(event.clone()); + } + Ok(Attached { + generation: controller_generation, + replay, + events, + disconnect_tx, + }) + })(); + let _ = reply.send(result); + } + Ok(ActorMessage::Detach { attachment_id }) => { + // A stale guard may share a string ID with a newer attachment. + // Its queued message must not remove the newer, still-live guard. + let removed = if subscribers + .get(&attachment_id) + .is_some_and(Subscriber::is_disconnected) + { + subscribers.remove(&attachment_id) + } else { + None + }; + if removed.is_some() && controller.as_ref() == Some(&attachment_id) { + controller_generation = controller_generation.saturating_add(1); + controller = promote_latest(&mut subscribers, controller_generation); + let generation = controller_generation; + let attachment_id = controller.clone(); + broadcast( + &shutdown, + &mut subscribers, + &mut controller, + &mut controller_generation, + StreamEvent::ControllerChanged { + attachment_id, + generation, + }, + ); + } + } + Ok(ActorMessage::ChildExited(result)) => { + child_exit = Some(result); + // ConPTY keeps its output pipe open until HPCON is closed. + // The wait worker closes it while this actor and the reader + // keep processing output, including the real final EOF. + #[cfg(windows)] + if let Err(error) = master.close() { + break Err(error); + } + } + Ok(ActorMessage::ReaderFailed(message)) | Ok(ActorMessage::WriterFailed(message)) => { + // Closing ConPTY can finish a pending write with BrokenPipe + // after the final exit event. Never overwrite a published exit. + if final_exit.is_none() + && let Ok(mut value) = info.write() + { + value.lifecycle = TerminalLifecycle::Failed { message }; + } + } + Ok(ActorMessage::ReaderEof) => reader_eof = true, + Err(crossbeam_channel::RecvTimeoutError::Disconnected) => { + break Ok(()); + } + Err(crossbeam_channel::RecvTimeoutError::Timeout) => continue, + } + if reader_eof + && final_exit.is_none() + && let Some(result) = child_exit.take() + { + let exit_code = result.as_ref().ok().copied().flatten(); + if let Ok(mut value) = info.write() { + value.lifecycle = match result { + Ok(exit_code) => TerminalLifecycle::Exited { exit_code }, + Err(message) => TerminalLifecycle::Failed { message }, + }; + } + let event = StreamEvent::Exited { + exit_code, + final_offset: replay.tail, + }; + broadcast( + &shutdown, + &mut subscribers, + &mut controller, + &mut controller_generation, + event.clone(), + ); + final_exit = Some(event); + } + }; + // Release senders blocked on the actor queue before closing ConPTY. Its + // synchronous close may need the reader to continue draining final output. + drop(messages); + drop(terminal); + drop(master); + drop(writes); + if matches!( + shutdown.try_recv(), + Err(crossbeam_channel::TryRecvError::Disconnected) + ) { + // Interrupted terminal-generated replies are expected during shutdown. + Ok(()) + } else { + result + } +} + +fn receive_actor_message( + messages: &Receiver, + pending: &mut Option, + shutdown: &Receiver<()>, +) -> std::result::Result { + if matches!( + shutdown.try_recv(), + Err(crossbeam_channel::TryRecvError::Disconnected) + ) { + return Err(crossbeam_channel::RecvTimeoutError::Disconnected); + } + let message = match pending.take() { + Some(message) => message, + None => crossbeam_channel::select_biased! { + recv(shutdown) -> _ => return Err(crossbeam_channel::RecvTimeoutError::Disconnected), + recv(messages) -> message => message.map_err(|_| crossbeam_channel::RecvTimeoutError::Disconnected)?, + }, + }; + let ActorMessage::Output(mut bytes) = message else { + return Ok(message); + }; + let deadline = Instant::now() + OUTPUT_BATCH_DELAY; + while bytes.len() < OUTPUT_BATCH_BYTES { + match messages.recv_deadline(deadline) { + Ok(ActorMessage::Output(next)) => bytes.extend(next), + Ok(message) => { + *pending = Some(message); + break; + } + Err(crossbeam_channel::RecvTimeoutError::Timeout) + | Err(crossbeam_channel::RecvTimeoutError::Disconnected) => break, + } + } + Ok(ActorMessage::Output(bytes)) +} + +#[cfg(not(windows))] +fn publish_foreground_process( + shutdown: &Receiver<()>, + master: &dyn MasterPty, + info: &Arc>, + subscribers: &mut HashMap, + controller: &mut Option, + controller_generation: &mut u64, +) { + let process = foreground_process(master); + let changed = if let Ok(mut value) = info.write() { + if value.foreground_process == process { + false + } else { + value.foreground_process = process.clone(); + true + } + } else { + false + }; + if changed { + broadcast( + shutdown, + subscribers, + controller, + controller_generation, + StreamEvent::ForegroundProcessChanged { process }, + ); + } +} + +#[cfg(target_os = "linux")] +fn foreground_process(master: &dyn MasterPty) -> Option { + let foreground_group = master.process_group_leader()?; + let tty = master.tty_name(); + let processes = std::fs::read_dir("/proc") + .ok()? + .filter_map(|entry| entry.ok()) + .filter_map(|entry| entry.file_name().to_str()?.parse::().ok()) + .filter_map(read_process) + .collect::>(); + let parents = processes + .iter() + .map(|process| (process.pid, process.ppid)) + .collect::>(); + let mut group = processes + .iter() + .filter(|process| process.pgrp == foreground_group) + .collect::>(); + if let Some(tty) = tty { + let attached = group + .iter() + .copied() + .filter(|process| process_attached_to_tty(process.pid, &tty)) + .collect::>(); + if !attached.is_empty() { + group = attached; + } + } + group + .into_iter() + .max_by_key(|process| (process_depth(process.pid, &parents), process.pid)) + .map(|process| process.name.clone()) +} + +#[cfg(not(any(windows, target_os = "linux")))] +fn foreground_process(_master: &dyn MasterPty) -> Option { + None +} + +#[cfg(target_os = "linux")] +struct ProcessInfo { + pid: i32, + ppid: i32, + pgrp: i32, + name: String, +} + +#[cfg(target_os = "linux")] +fn read_process(pid: i32) -> Option { + let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?; + let fields = stat + .get(stat.rfind(") ")? + 2..)? + .split_whitespace() + .collect::>(); + Some(ProcessInfo { + pid, + ppid: fields.get(1)?.parse().ok()?, + pgrp: fields.get(2)?.parse().ok()?, + name: std::fs::read_to_string(format!("/proc/{pid}/comm")) + .ok()? + .trim() + .to_string(), + }) +} + +#[cfg(target_os = "linux")] +fn process_attached_to_tty(pid: i32, tty: &std::path::Path) -> bool { + [0, 1, 2] + .into_iter() + .any(|fd| std::fs::read_link(format!("/proc/{pid}/fd/{fd}")).is_ok_and(|path| path == tty)) +} + +#[cfg(target_os = "linux")] +fn process_depth(pid: i32, parents: &HashMap) -> usize { + let mut current = pid; + let mut seen = HashSet::new(); + while current > 0 && seen.insert(current) { + let Some(parent) = parents.get(¤t) else { + break; + }; + current = *parent; + } + seen.len() +} + +// Forward effects immediately after each native mutation, even if it reports an +// error. In particular, resize notifications must precede any accompanying input. +// Actual PTY I/O remains on the single writer thread, never inside a callback. +fn forward_replies( + terminal: &mut Terminal, + writes: &Sender, + shutdown: &Receiver<()>, +) -> Result<()> { + for response in terminal.take_writes() { + queue_write(writes, shutdown, response)?; + } + Ok(()) +} + +fn queue_write( + writes: &Sender, + shutdown: &Receiver<()>, + bytes: Vec, +) -> Result<()> { + crossbeam_channel::select_biased! { + recv(shutdown) -> _ => bail!("terminal is stopping"), + send(writes, WriterMessage::Bytes(bytes)) -> result => result.map_err(|_| anyhow!("terminal writer stopped")), + } +} + +fn run_writer( + mut writer: Box, + writer_rx: Receiver, + actor_tx: Sender, + shutdown: Receiver<()>, +) { + loop { + let message = crossbeam_channel::select_biased! { + recv(shutdown) -> _ => break, + recv(writer_rx) -> message => match message { + Ok(message) => message, + Err(_) => break, + }, + }; + let result = match message { + WriterMessage::Bytes(bytes) => writer.write_all(&bytes).and_then(|_| writer.flush()), + }; + if let Err(error) = result { + let _ = actor_tx.send(ActorMessage::WriterFailed(error.to_string())); + break; + } + } +} + +fn make_snapshot( + terminal: &Terminal, + info: &Arc>, +) -> Result { + let text = format_terminal(terminal, Format::Plain)?; + let checkpoint = format_terminal(terminal, Format::Vt)?.into_bytes(); + let info = info + .read() + .map_err(|_| anyhow!("terminal info lock poisoned"))? + .clone(); + Ok(TerminalSnapshot { + info, + text, + checkpoint, + cursor_x: terminal.cursor_x()?, + cursor_y: terminal.cursor_y()?, + }) +} + +fn format_rows(terminal: &Terminal, rows: Option) -> Result> { + let rows = rows.unwrap_or(terminal.rows()?); + if rows == 0 { + bail!("row count must be positive"); + } + let total = terminal.total_rows()?; + let count = usize::from(rows).min(total); + // Charge both JSON punctuation and owned String slots, including empty rows. + let mut bytes = 2 + count * (size_of::() + 1); + if bytes > MAX_ROWS_BYTES { + bail!("rows exceed {MAX_ROWS_BYTES} byte limit"); + } + let mut lines = Vec::with_capacity(count); + let mut buffer = vec![0; MAX_ROWS_BYTES - bytes]; + for y in total - count..total { + let y = u32::try_from(y).context("terminal row index exceeds u32")?; + // A single-row selection preserves blank rows without joining soft wraps. + let len = terminal + .format_row(y, &mut buffer[..MAX_ROWS_BYTES - bytes]) + .with_context(|| { + format!("rows exceed {MAX_ROWS_BYTES} byte limit or formatting failed") + })?; + let line = std::str::from_utf8(&buffer[..len])?; + bytes += serde_json::to_vec(line)?.len(); + if bytes > MAX_ROWS_BYTES { + bail!("rows exceed {MAX_ROWS_BYTES} byte limit"); + } + lines.push(line.to_owned()); + } + Ok(lines) +} + +fn format_terminal(terminal: &Terminal, format: Format) -> Result { + let bytes = terminal.format(format)?; + let output = String::from_utf8_lossy(bytes.as_ref()).into_owned(); + if format != Format::Vt { + return Ok(output); + } + // Ghostty restores tab stops after screen state, moving the cursor in the process. + Ok(format!( + "{output}\x1b[{};{}H", + terminal.cursor_y()? + 1, + terminal.cursor_x()? + 1, + )) +} + +fn update_offsets(info: &Arc>, replay: &ReplayBuffer) { + if let Ok(mut info) = info.write() { + info.output_head = replay.head; + info.output_tail = replay.tail; + } +} + +struct ReplayBuffer { + bytes: VecDeque, + capacity: usize, + head: u64, + tail: u64, +} + +impl ReplayBuffer { + fn new(capacity: usize) -> Self { + Self { + bytes: VecDeque::with_capacity(capacity.min(64 * 1024)), + capacity, + head: 0, + tail: 0, + } + } + + fn append(&mut self, bytes: &[u8]) -> (u64, u64) { + let start = self.tail; + self.tail = self.tail.saturating_add(bytes.len() as u64); + if self.capacity == 0 { + self.bytes.clear(); + self.head = self.tail; + return (start, self.tail); + } + self.bytes.extend(bytes); + let excess = self.bytes.len().saturating_sub(self.capacity); + self.bytes.drain(..excess); + self.head = self.tail.saturating_sub(self.bytes.len() as u64); + (start, self.tail) + } + + fn read_from(&self, requested_offset: u64) -> Result { + if requested_offset > self.tail { + bail!( + "requested offset {requested_offset} is beyond tail {}", + self.tail + ); + } + let available_offset = requested_offset.max(self.head); + let skip = (available_offset - self.head) as usize; + Ok(RawReplay { + requested_offset, + available_offset, + end_offset: self.tail, + truncated: requested_offset < self.head, + bytes: self.bytes.iter().skip(skip).copied().collect(), + }) + } +} + +fn broadcast( + shutdown: &Receiver<()>, + subscribers: &mut HashMap, + controller: &mut Option, + controller_generation: &mut u64, + event: StreamEvent, +) { + let mut dropped = Vec::new(); + for (id, subscriber) in subscribers.iter() { + if subscriber.is_disconnected() { + dropped.push(id.clone()); + continue; + } + let failed = match subscriber.role { + AttachmentRole::Controller => crossbeam_channel::select_biased! { + // Quitting is not a subscriber disconnect: do not promote and + // recursively notify controllers while tearing the actor down. + recv(shutdown) -> _ => return, + recv(subscriber.disconnected) -> _ => true, + send(subscriber.events, event.clone()) -> result => result.is_err(), + }, + AttachmentRole::Observer => subscriber.events.try_send(event.clone()).is_err(), + }; + if failed { + dropped.push(id.clone()); + } + } + if matches!( + shutdown.try_recv(), + Err(crossbeam_channel::TryRecvError::Disconnected) + ) { + return; + } + let mut controller_dropped = false; + for id in dropped { + subscribers.remove(&id); + controller_dropped |= controller.as_ref() == Some(&id); + } + // Remove the whole failed set before selecting a replacement. In + // particular, an already-full observer must not become a blocking sender. + if controller_dropped { + *controller_generation = controller_generation.saturating_add(1); + *controller = promote_latest(subscribers, *controller_generation); + let generation = *controller_generation; + let attachment_id = controller.clone(); + broadcast( + shutdown, + subscribers, + controller, + controller_generation, + StreamEvent::ControllerChanged { + attachment_id, + generation, + }, + ); + } +} + +fn promote_latest( + subscribers: &mut HashMap, + generation: u64, +) -> Option { + subscribers.retain(|_, subscriber| !subscriber.is_disconnected()); + let attachment_id = subscribers + .iter() + .max_by_key(|(_, subscriber)| subscriber.last_control) + .map(|(attachment_id, _)| attachment_id.clone())?; + if let Some(subscriber) = subscribers.get_mut(&attachment_id) { + subscriber.role = AttachmentRole::Controller; + subscriber.last_control = generation; + } + Some(attachment_id) +} + +fn authorize_controller(controller: &Option, attachment_id: Option<&str>) -> Result<()> { + let Some(attachment_id) = attachment_id else { + if controller.is_none() { + return Ok(()); + } + bail!("terminal input requires the active controller attachment"); + }; + if controller.as_deref() != Some(attachment_id) { + bail!("attachment {attachment_id} is not the terminal controller"); + } + Ok(()) +} + +fn validate_size(cols: u16, rows: u16) -> Result<()> { + if cols == 0 || rows == 0 { + bail!("terminal dimensions must be positive"); + } + Ok(()) +} + +fn default_shell() -> String { + env::var("SHELL").unwrap_or_else(|_| { + if cfg!(windows) { + "cmd.exe".to_string() + } else { + "/bin/sh".to_string() + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + struct TestMaster; + + impl MasterPty for TestMaster { + fn resize(&self, _: PtySize) -> Result<()> { + Ok(()) + } + fn get_size(&self) -> Result { + Ok(PtySize::default()) + } + fn try_clone_reader(&self) -> Result> { + unreachable!() + } + fn take_writer(&self) -> Result> { + unreachable!() + } + #[cfg(unix)] + fn process_group_leader(&self) -> Option { + None + } + #[cfg(unix)] + fn as_raw_fd(&self) -> Option { + None + } + #[cfg(unix)] + fn tty_name(&self) -> Option { + None + } + } + + #[derive(Debug)] + struct TestKiller; + + impl ChildKiller for TestKiller { + fn kill(&mut self) -> io::Result<()> { + Ok(()) + } + fn clone_killer(&self) -> Box { + Box::new(Self) + } + } + + fn test_info() -> TerminalInfo { + TerminalInfo { + id: 1, + pid: None, + title: "test".into(), + foreground_process: None, + group_id: "test".into(), + command: vec![], + cwd: PathBuf::new(), + cols: 80, + rows: 24, + lifecycle: TerminalLifecycle::Running, + output_head: 0, + output_tail: 0, + } + } + + // Real actor/parser and bounded channels, without OS pipe capacity or a + // large output flood. Native child/handle coverage lives in tests/runtime.rs. + fn test_actor() -> (TerminalHandle, Receiver) { + let (actor_tx, messages) = bounded(ACTOR_QUEUE_CAPACITY); + let (writes, writer_rx) = bounded(1); + let (shutdown_tx, shutdown) = bounded(0); + #[cfg(windows)] + let (close_tx, close_rx) = bounded(1); + #[cfg(windows)] + let closer = thread::spawn(move || { + if let Ok(master) = close_rx.recv() { + drop(master); + } + }); + let (init_tx, init_rx) = std_mpsc::sync_channel(1); + let info = Arc::new(RwLock::new(test_info())); + let actor_info = Arc::clone(&info); + let (actor_done_tx, actor_done) = bounded::<()>(0); + let actor = thread::spawn(move || { + let _actor_done = actor_done_tx; + run_actor(ActorConfig { + master: ActorMaster { + pty: Some(Box::new(TestMaster)), + #[cfg(windows)] + close_tx, + }, + messages, + writes, + shutdown, + info: actor_info, + replay_capacity: 4096, + cols: 80, + rows: 24, + init_tx, + }) + .unwrap() + }); + init_rx.recv().unwrap().unwrap(); + ( + TerminalHandle { + actor_tx, + actor_done, + shutdown_tx: Mutex::new(Some(shutdown_tx)), + killer: Mutex::new(Box::new(TestKiller)), + info, + joins: Mutex::new(Some(vec![ + actor, + #[cfg(windows)] + closer, + ])), + }, + writer_rx, + ) + } + + #[test] + #[cfg(windows)] + fn windows_list_reads_metadata_without_an_actor_request() { + let (actor, _writer) = test_actor(); + actor.shutdown().unwrap(); + let service = TerminalService::default(); + service.terminals.lock().unwrap().insert(1, Arc::new(actor)); + + // Even a closed actor channel is irrelevant to cached Windows metadata. + let terminals = service.list().unwrap(); + assert_eq!(terminals.len(), 1); + assert_eq!(terminals[0].id, 1); + assert_eq!(terminals[0].title, "test"); + assert!(terminals[0].foreground_process.is_none()); + } + + #[test] + fn child_exit_and_reader_eof_are_distinct_actor_signals() { + for eof_first in [false, true] { + let (actor, _writer) = test_actor(); + actor + .actor_tx + .send(if eof_first { + ActorMessage::ReaderEof + } else { + ActorMessage::ChildExited(Ok(Some(23))) + }) + .unwrap(); + let snapshot = actor + .request(|reply| ActorMessage::Snapshot { reply }) + .unwrap(); + assert_eq!(snapshot.info.lifecycle, TerminalLifecycle::Running); + if !eof_first { + actor + .actor_tx + .send(ActorMessage::Output(b"final".to_vec())) + .unwrap(); + } + actor + .actor_tx + .send(if eof_first { + ActorMessage::ChildExited(Ok(Some(23))) + } else { + ActorMessage::ReaderEof + }) + .unwrap(); + let snapshot = actor + .request(|reply| ActorMessage::Snapshot { reply }) + .unwrap(); + assert_eq!( + snapshot.info.lifecycle, + TerminalLifecycle::Exited { + exit_code: Some(23) + } + ); + if !eof_first { + assert_eq!(snapshot.text, "final"); + } + actor + .actor_tx + .send(ActorMessage::WriterFailed("late broken pipe".into())) + .unwrap(); + let after_write_error = actor + .request(|reply| ActorMessage::Snapshot { reply }) + .unwrap(); + assert_eq!(after_write_error.info.lifecycle, snapshot.info.lifecycle); + actor.shutdown().unwrap(); + actor.shutdown().unwrap(); + } + } + + #[test] + fn late_attachments_receive_final_exit_after_replay_and_control() { + let (actor, _writer) = test_actor(); + for message in [ + ActorMessage::Output(b"final".to_vec()), + ActorMessage::ChildExited(Ok(Some(23))), + ActorMessage::ReaderEof, + ] { + actor.actor_tx.send(message).unwrap(); + } + let mut received = Vec::new(); + for role in [AttachmentRole::Observer, AttachmentRole::Controller] { + let attached = actor + .request(|reply| ActorMessage::Attach { + offset: 2, + attachment_id: "late".into(), + role, + takeover: false, + reply, + }) + .unwrap(); + received.push(( + role, + attached.replay.clone(), + attached.events.try_iter().collect::>(), + )); + drop(attached); + actor + .actor_tx + .send(ActorMessage::Detach { + attachment_id: "late".into(), + }) + .unwrap(); + } + actor.shutdown().unwrap(); + for (role, replay, mut events) in received { + assert_eq!(replay.bytes, b"nal"); + assert_eq!((replay.available_offset, replay.end_offset), (2, 5)); + if role == AttachmentRole::Controller { + assert!(matches!( + events.remove(0), + StreamEvent::ControllerChanged { .. } + )); + } + assert!(matches!( + events.as_slice(), + [StreamEvent::Exited { + exit_code: Some(23), + final_offset: 5 + }] + )); + } + } + + #[test] + #[cfg(windows)] + fn failed_close_handoff_retains_master_ownership() { + let (close_tx, close_rx) = bounded(1); + drop(close_rx); + let mut master = ActorMaster { + pty: Some(Box::new(TestMaster)), + close_tx, + }; + assert!(master.close().is_err()); + assert!(master.get().is_ok()); + } + + #[test] + fn attachment_drop_unblocks_controller_send_with_a_receiver_clone() { + let (actor_tx, messages) = bounded(1); + actor_tx.send(ActorMessage::ReaderEof).unwrap(); + let (events_tx, events) = bounded(1); + events_tx + .send(StreamEvent::TitleChanged { + title: "queued".into(), + }) + .unwrap(); + let retained_events = events.clone(); + let (disconnect_tx, disconnected) = bounded(0); + let attachment = TerminalAttachment { + terminal: test_info(), + role: AttachmentRole::Controller, + generation: 1, + replay: ReplayBuffer::new(0).read_from(0).unwrap(), + events, + actor_tx, + attachment_id: "controller".into(), + disconnect_tx: Some(disconnect_tx), + }; + let (shutdown_tx, shutdown) = bounded(0); + let (done, finished) = std_mpsc::channel(); + let actor = thread::spawn(move || { + let mut subscribers = HashMap::from([( + "controller".into(), + Subscriber { + events: events_tx, + disconnected, + role: AttachmentRole::Controller, + last_control: 1, + }, + )]); + let mut controller = Some("controller".into()); + let mut generation = 1; + broadcast( + &shutdown, + &mut subscribers, + &mut controller, + &mut generation, + StreamEvent::TitleChanged { + title: "blocked".into(), + }, + ); + let removed = subscribers.is_empty() && controller.is_none(); + let _ = messages.recv(); // Release the actor queue's occupied slot. + let detached = matches!(messages.recv_timeout(Duration::from_secs(1)), + Ok(ActorMessage::Detach { attachment_id }) if attachment_id == "controller"); + let _ = done.send(removed && detached); + }); + let dropper = thread::spawn(move || drop(attachment)); + let progress = finished.recv_timeout(Duration::from_secs(1)); + // A red regression can always unwind without leaking worker threads. + drop(shutdown_tx); + actor.join().unwrap(); + dropper.join().unwrap(); + assert_eq!(progress, Ok(true)); + assert_eq!(retained_events.len(), 1); + } + + #[test] + fn stale_detach_does_not_remove_a_live_replacement() { + let (actor, _writer) = test_actor(); + let attach = || { + actor + .request(|reply| ActorMessage::Attach { + offset: 0, + attachment_id: "shared".into(), + role: AttachmentRole::Controller, + takeover: false, + reply, + }) + .unwrap() + }; + let old = attach(); + let current = attach(); + drop(old); + actor + .actor_tx + .send(ActorMessage::Detach { + attachment_id: "shared".into(), + }) + .unwrap(); + let replacement = actor.request(|reply| ActorMessage::Control { + attachment_id: "shared".into(), + cols: 80, + rows: 24, + bytes: None, + reply, + }); + drop(current); + actor + .actor_tx + .send(ActorMessage::Detach { + attachment_id: "shared".into(), + }) + .unwrap(); + let detached = actor.request(|reply| ActorMessage::Control { + attachment_id: "shared".into(), + cols: 80, + rows: 24, + bytes: None, + reply, + }); + actor.shutdown().unwrap(); + assert!(replacement.is_ok()); + assert!(detached.is_err()); + } + + #[test] + fn broadcast_removes_failed_observers_before_promoting() { + let mut subscribers = HashMap::new(); + let mut receivers = HashMap::new(); + let mut disconnects = Vec::new(); + for id in ["a", "b"] { + let (events, receiver) = bounded(1); + events + .send(StreamEvent::TitleChanged { + title: "queued".into(), + }) + .unwrap(); + let (disconnect, disconnected) = bounded(0); + disconnects.push(disconnect); + receivers.insert(id.to_string(), receiver); + subscribers.insert( + id.to_string(), + Subscriber { + events, + disconnected, + role: AttachmentRole::Observer, + last_control: 1, + }, + ); + } + // Force the failed controller to precede the full observer in iteration + // order, regardless of HashMap's randomized seed. + let first = subscribers.keys().next().unwrap().clone(); + subscribers.get_mut(&first).unwrap().role = AttachmentRole::Controller; + drop(receivers.remove(&first)); + let (shutdown_tx, shutdown) = bounded(0); + let (done, finished) = std_mpsc::channel(); + let worker = thread::spawn(move || { + let mut controller = Some(first); + let mut generation = 1; + broadcast( + &shutdown, + &mut subscribers, + &mut controller, + &mut generation, + StreamEvent::TitleChanged { + title: "next".into(), + }, + ); + let _ = done.send(subscribers.is_empty() && controller.is_none() && generation == 2); + }); + let progress = finished.recv_timeout(Duration::from_secs(1)); + drop(shutdown_tx); + worker.join().unwrap(); + assert_eq!(progress, Ok(true)); + } + + #[test] + fn shutdown_interrupts_controller_backpressure() { + let (actor, _writer) = test_actor(); + let attached = actor + .request(|reply| ActorMessage::Attach { + offset: 0, + attachment_id: "controller".into(), + role: AttachmentRole::Controller, + takeover: false, + reply, + }) + .unwrap(); + // Attach already queued ControllerChanged. Force one output per batch + // with a following actor request, filling the queue with only 1 KiB. + for _ in 1..SUBSCRIBER_QUEUE_CAPACITY { + actor + .actor_tx + .send(ActorMessage::Output(b"x".to_vec())) + .unwrap(); + actor + .request(|reply| ActorMessage::Replay { offset: 0, reply }) + .unwrap(); + } + assert!(attached.events.is_full()); + actor + .actor_tx + .send(ActorMessage::Output(b"!".to_vec())) + .unwrap(); + let (done, finished) = std_mpsc::channel(); + let shutdown = thread::spawn(move || { + actor.shutdown().unwrap(); + done.send(()).unwrap(); + }); + let stopped = finished.recv_timeout(Duration::from_secs(1)).is_ok(); + // Release the test consumer even on failure so a red regression never + // leaves a thread stuck or depends on the runtime-test watchdog. + drop(attached); + shutdown.join().unwrap(); + assert!( + stopped, + "shutdown waited for a controller to consume output" + ); + } + + #[test] + fn shutdown_interrupts_actor_to_writer_backpressure() { + let (actor, writer) = test_actor(); + actor + .request(|reply| ActorMessage::Write { + attachment_id: None, + bytes: b"first".to_vec(), + reply, + }) + .unwrap(); + assert!(writer.is_full()); + let (reply, _reply_rx) = bounded(1); + actor + .actor_tx + .send(ActorMessage::Write { + attachment_id: None, + bytes: b"blocked".to_vec(), + reply, + }) + .unwrap(); + // Shutdown overtakes this queued request; its caller must still return. + let actor = Arc::new(actor); + let (queued_tx, queued) = std_mpsc::channel(); + let requester = thread::spawn({ + let actor = Arc::clone(&actor); + move || { + let _ = queued_tx.send(actor.request(|reply| ActorMessage::Snapshot { reply })); + } + }); + let (done, finished) = std_mpsc::channel(); + let shutdown = thread::spawn({ + let actor = Arc::clone(&actor); + move || { + actor.shutdown().unwrap(); + done.send(()).unwrap(); + } + }); + let stopped = finished.recv_timeout(Duration::from_secs(1)).is_ok(); + let queued = queued.recv_timeout(Duration::from_secs(1)); + drop(writer); + shutdown.join().unwrap(); + assert!(stopped, "shutdown waited for the full input queue"); + assert!( + queued.is_ok_and(|result| result.is_err()), + "a request queued before shutdown never returned" + ); + requester.join().unwrap(); + } + + fn rows_terminal(cols: u16, rows: u16, input: &str) -> Terminal { + let mut terminal = Terminal::new(TerminalOptions { + cols, + rows, + max_scrollback: 2 * 1024 * 1024, + }) + .unwrap(); + terminal.vt_write(input.as_bytes()); + terminal + } + + #[test] + fn forwarding_replies_drains_once_and_reports_writer_failure() { + let mut terminal = rows_terminal(10, 3, "\x1b[5n\x1b[6n"); + let (writes, reader) = bounded(4); + let (_shutdown_tx, shutdown) = bounded(0); + forward_replies(&mut terminal, &writes, &shutdown).unwrap(); + for expected in [b"\x1b[0n".as_slice(), b"\x1b[1;1R".as_slice()] { + let WriterMessage::Bytes(bytes) = reader.try_recv().unwrap(); + assert_eq!(bytes, expected); + } + assert!(reader.try_recv().is_err()); + assert!(terminal.take_writes().is_empty()); + + terminal.vt_write(b"\x1b[5n"); + drop(reader); + assert_eq!( + forward_replies(&mut terminal, &writes, &shutdown) + .unwrap_err() + .to_string(), + "terminal writer stopped", + ); + assert!(terminal.take_writes().is_empty()); + } + + #[test] + fn rows_include_live_height_and_preserve_blank_rows() { + let terminal = rows_terminal(10, 5, ""); + assert_eq!(format_rows(&terminal, None).unwrap(), ["", "", "", "", ""]); + let terminal = rows_terminal(10, 5, "\x1b[31mone \x1b[0m\r\n\r\n three\r\n"); + assert_eq!( + format_rows(&terminal, None).unwrap(), + ["one", "", " three", "", ""] + ); + assert_eq!(format_rows(&terminal, Some(2)).unwrap(), ["", ""]); + assert_eq!( + format_rows(&terminal, Some(u16::MAX)).unwrap(), + ["one", "", " three", "", ""] + ); + } + + #[test] + fn rows_include_history_only_when_requested() { + let terminal = rows_terminal(10, 3, "one\r\ntwo\r\nthree\r\nfour\r\nfive"); + assert_eq!( + format_rows(&terminal, None).unwrap(), + ["three", "four", "five"] + ); + assert_eq!(format_rows(&terminal, Some(2)).unwrap(), ["four", "five"]); + assert_eq!( + format_rows(&terminal, Some(4)).unwrap(), + ["two", "three", "four", "five"] + ); + assert_eq!( + format_rows(&terminal, Some(u16::MAX)).unwrap(), + ["one", "two", "three", "four", "five"] + ); + } + + #[test] + fn rows_keep_physical_wraps_and_unicode_graphemes() { + let terminal = rows_terminal(4, 3, "abcdefghij"); + assert_eq!( + format_rows(&terminal, None).unwrap(), + ["abcd", "efgh", "ij"] + ); + assert_eq!(format_rows(&terminal, Some(2)).unwrap(), ["efgh", "ij"]); + let terminal = rows_terminal(4, 3, "a\u{301}\u{754c}bc"); + assert_eq!( + format_rows(&terminal, None).unwrap(), + ["a\u{301}\u{754c}b", "c", ""] + ); + } + + #[test] + fn rows_isolate_alternate_screen_and_restore_primary_history() { + let mut terminal = rows_terminal(10, 3, "one\r\ntwo\r\nthree\r\nfour\r\nfive"); + let primary = format_rows(&terminal, Some(u16::MAX)).unwrap(); + terminal.vt_write(b"\x1b[?1049hA\r\nB\r\nC\r\nD\r\nE"); + assert_eq!( + format_rows(&terminal, Some(u16::MAX)).unwrap(), + ["C", "D", "E"] + ); + assert_eq!(format_rows(&terminal, None).unwrap(), ["C", "D", "E"]); + terminal.vt_write(b"\x1b[?1049l"); + assert_eq!(format_rows(&terminal, Some(u16::MAX)).unwrap(), primary); + } + + #[test] + fn rows_follow_resize_and_reflow() { + let mut terminal = rows_terminal(4, 3, "abcdefghij"); + terminal.resize(6, 4).unwrap(); + assert_eq!( + format_rows(&terminal, None).unwrap(), + ["abcdef", "ghij", "", ""] + ); + terminal.resize(4, 2).unwrap(); + assert_eq!(format_rows(&terminal, None).unwrap(), ["efgh", "ij"]); + assert_eq!( + format_rows(&terminal, Some(3)).unwrap(), + ["abcd", "efgh", "ij"] + ); + } + + #[test] + fn rows_do_not_change_viewport_selection_cursor_or_checkpoint() { + let mut terminal = rows_terminal(10, 3, "one\r\ntwo\r\nthree\r\nfour\r\nfive"); + terminal.scroll_to_top(); + terminal.set_selection((0, 0), (2, 0)).unwrap(); + let viewport = terminal.viewport_row().unwrap(); + let checkpoint = format_terminal(&terminal, Format::Vt).unwrap(); + let cursor = (terminal.cursor_x().unwrap(), terminal.cursor_y().unwrap()); + assert_eq!( + format_rows(&terminal, None).unwrap(), + ["three", "four", "five"] + ); + assert_eq!(format_terminal(&terminal, Format::Vt).unwrap(), checkpoint); + assert_eq!( + (terminal.cursor_x().unwrap(), terminal.cursor_y().unwrap()), + cursor + ); + assert_eq!(terminal.viewport_row().unwrap(), viewport); + let mut buffer = [0; 32]; + let len = terminal.selection_text(&mut buffer).unwrap().unwrap(); + assert_eq!(&buffer[..len], b"one"); + } + + #[test] + fn rows_reject_zero_and_bound_escaped_payload_by_bytes() { + let terminal = rows_terminal(10, 3, ""); + assert!( + format_rows(&terminal, Some(0)) + .unwrap_err() + .to_string() + .contains("positive") + ); + for (character, rows) in [("x", 1100), ("\"", 600)] { + let terminal = rows_terminal(1000, rows, &character.repeat(1000 * usize::from(rows))); + assert!( + format_rows(&terminal, None) + .unwrap_err() + .to_string() + .contains("byte limit") + ); + assert_eq!( + format_rows(&terminal, Some(1)).unwrap(), + [character.repeat(1000)] + ); + } + let terminal = rows_terminal(1, u16::MAX, ""); + assert!( + format_rows(&terminal, None) + .unwrap_err() + .to_string() + .contains("byte limit") + ); + assert_eq!(format_rows(&terminal, Some(1)).unwrap(), [""]); + } + + #[test] + fn checkpoint_restores_cursor_and_tabstops() { + for (cols, rows) in [(80, 24), (30, 100), (100, 30)] { + for input in [ + "", + "hello\r\nworld\x1b[2;3H", + "\x1b[3g\x1b[5G\x1bH\x1b[15G\x1bH\x1b[3;4H", + "\x1b[3;20r\x1b[5;7H", + ] { + let mut source = Terminal::new(TerminalOptions { + cols: 80, + rows: 24, + max_scrollback: 0, + }) + .unwrap(); + source.vt_write(input.as_bytes()); + source.resize(cols, rows).unwrap(); + let checkpoint = format_terminal(&source, Format::Vt).unwrap(); + let mut restored = Terminal::new(TerminalOptions { + cols, + rows, + max_scrollback: 0, + }) + .unwrap(); + restored.vt_write(checkpoint.as_bytes()); + + assert_eq!(restored.cursor_x().unwrap(), source.cursor_x().unwrap()); + assert_eq!(restored.cursor_y().unwrap(), source.cursor_y().unwrap()); + assert_eq!( + format_terminal(&restored, Format::Plain).unwrap(), + format_terminal(&source, Format::Plain).unwrap(), + ); + + source.vt_write(b"\tcontinued"); + restored.vt_write(b"\tcontinued"); + assert_eq!( + format_terminal(&restored, Format::Plain).unwrap(), + format_terminal(&source, Format::Plain).unwrap(), + ); + } + } + } + + #[test] + fn checkpoint_does_not_leave_zsh_partial_line_marker() { + let options = TerminalOptions { + cols: 80, + rows: 24, + max_scrollback: 0, + }; + let source = Terminal::new(options).unwrap(); + let checkpoint = format_terminal(&source, Format::Vt).unwrap(); + let mut restored = Terminal::new(TerminalOptions { + cols: 80, + rows: 24, + max_scrollback: 0, + }) + .unwrap(); + restored.vt_write(checkpoint.as_bytes()); + restored.vt_write( + format!( + "\x1b[1m\x1b[7m%\x1b[0m{}\r \r\r\x1b[Jprompt% ", + " ".repeat(79) + ) + .as_bytes(), + ); + + assert_eq!( + format_terminal(&restored, Format::Plain).unwrap(), + "prompt%" + ); + assert_eq!(restored.cursor_y().unwrap(), 0); + } + + #[test] + fn replay_reports_truncation_and_offsets() { + let mut replay = ReplayBuffer::new(5); + replay.append(b"abc"); + replay.append(b"defg"); + assert_eq!(replay.head, 2); + assert_eq!(replay.tail, 7); + + let value = replay.read_from(0).unwrap(); + assert!(value.truncated); + assert_eq!(value.available_offset, 2); + assert_eq!(value.bytes, b"cdefg"); + + let value = replay.read_from(5).unwrap(); + assert!(!value.truncated); + assert_eq!(value.bytes, b"fg"); + } + + #[test] + fn controller_backpressures_instead_of_disconnecting() { + let (_shutdown_tx, shutdown) = bounded(0); + let (_disconnect_tx, disconnected) = bounded(0); + let (events_tx, events) = bounded(1); + events_tx + .send(StreamEvent::Output { + start: 0, + end: 1, + bytes: b"a".to_vec(), + }) + .unwrap(); + let mut subscribers = HashMap::from([( + "controller".to_string(), + Subscriber { + events: events_tx, + disconnected, + role: AttachmentRole::Controller, + last_control: 1, + }, + )]); + let broadcast = std::thread::spawn(move || { + let mut controller = Some("controller".to_string()); + let mut generation = 1; + broadcast( + &shutdown, + &mut subscribers, + &mut controller, + &mut generation, + StreamEvent::Output { + start: 1, + end: 2, + bytes: b"b".to_vec(), + }, + ); + subscribers.contains_key("controller") + }); + + assert!(matches!( + events.recv().unwrap(), + StreamEvent::Output { start: 0, .. } + )); + assert!(matches!( + events.recv().unwrap(), + StreamEvent::Output { start: 1, .. } + )); + assert!(broadcast.join().unwrap()); + } + + #[test] + fn slow_observer_is_disconnected() { + let (_shutdown_tx, shutdown) = bounded(0); + let (_disconnect_tx, disconnected) = bounded(0); + let (events_tx, _events) = bounded(1); + events_tx + .send(StreamEvent::Output { + start: 0, + end: 1, + bytes: b"a".to_vec(), + }) + .unwrap(); + let mut subscribers = HashMap::from([( + "observer".to_string(), + Subscriber { + events: events_tx, + disconnected, + role: AttachmentRole::Observer, + last_control: 0, + }, + )]); + + broadcast( + &shutdown, + &mut subscribers, + &mut None, + &mut 0, + StreamEvent::Output { + start: 1, + end: 2, + bytes: b"b".to_vec(), + }, + ); + + assert!(!subscribers.contains_key("observer")); + } + + #[test] + fn output_is_batched_without_reordering_other_messages() { + let (_shutdown_tx, shutdown) = bounded(0); + let (messages_tx, messages) = bounded(4); + messages_tx + .send(ActorMessage::Output(b"abc".to_vec())) + .unwrap(); + messages_tx + .send(ActorMessage::Output(b"def".to_vec())) + .unwrap(); + messages_tx.send(ActorMessage::ReaderEof).unwrap(); + let mut pending = None; + + assert!(matches!( + receive_actor_message(&messages, &mut pending, &shutdown).unwrap(), + ActorMessage::Output(bytes) if bytes == b"abcdef" + )); + assert!(matches!( + receive_actor_message(&messages, &mut pending, &shutdown).unwrap(), + ActorMessage::ReaderEof + )); + } +} diff --git a/packages/process-daemon/src/service/windows.rs b/packages/process-daemon/src/service/windows.rs new file mode 100644 index 000000000000..81c8cd426514 --- /dev/null +++ b/packages/process-daemon/src/service/windows.rs @@ -0,0 +1,77 @@ +use std::time::Duration; + +use crossbeam_channel::{Receiver, RecvTimeoutError, Sender}; +use portable_pty::{Child, MasterPty}; +use windows_sys::Win32::{ + Foundation::{WAIT_OBJECT_0, WAIT_TIMEOUT}, + System::Threading::{INFINITE, WaitForSingleObject}, +}; + +use super::ActorMessage; + +/// Keep blocking ClosePseudoConsole off the actor/reader without introducing +/// another worker. The actor hands its unique master here on root exit or on +/// teardown; closing it then produces the real output-pipe EOF. +/// Root-exit detection uses a 10 ms poll interval. After reaping, the +/// worker blocks on the close channel rather than continuing to poll. +pub(super) fn wait_and_close( + mut child: Box, + events: Sender, + close: Receiver>, +) { + let mut reported = false; + loop { + if !reported { + // SAFETY: the child owns this process handle throughout this call; + // cloned killer handles do not close or replace it. Poll the wait + // handle rather than GetExitCodeProcess so exit code 259 is not + // confused with STILL_ACTIVE by portable-pty's try_wait. + let ready = child + .as_raw_handle() + .is_none_or(|handle| unsafe { WaitForSingleObject(handle, 0) != WAIT_TIMEOUT }); + if ready { + report_exit(&mut *child, &events); + reported = true; + } + } + let request = if reported { + close.recv().map_err(|_| RecvTimeoutError::Disconnected) + } else { + close.recv_timeout(Duration::from_millis(10)) + }; + match request { + Ok(master) => { + if !reported { + // Also cover actor failure/early teardown, not just normal + // exit or the service's already-issued termination request. + let _ = child.kill(); + } + drop(master); + break; + } + Err(RecvTimeoutError::Disconnected) => break, + Err(RecvTimeoutError::Timeout) => {} + } + } + if !reported { + report_exit(&mut *child, &events); + } +} + +fn report_exit(child: &mut dyn Child, events: &Sender) { + let result = (|| { + if let Some(handle) = child.as_raw_handle() { + // SAFETY: this worker owns the child and therefore its wait handle. + // GetExitCodeProcess can expose the termination status before the + // process object is signaled; portable-pty's wait fast path is not + // sufficient to prove that TerminateProcess teardown has finished. + if unsafe { WaitForSingleObject(handle, INFINITE) } != WAIT_OBJECT_0 { + return Err(std::io::Error::last_os_error()); + } + } + child.wait().map(|status| Some(status.exit_code())) + })(); + let _ = events.send(ActorMessage::ChildExited( + result.map_err(|error| error.to_string()), + )); +} diff --git a/packages/process-daemon/src/transport/mod.rs b/packages/process-daemon/src/transport/mod.rs new file mode 100644 index 000000000000..e7a78737b7c9 --- /dev/null +++ b/packages/process-daemon/src/transport/mod.rs @@ -0,0 +1,24 @@ +//! Local byte-stream transport boundary. Protocol framing and authentication live +//! above this module. Cancellation interrupts pending I/O; successful response +//! completion is separate so cancelling a connection cannot discard its final +//! frame before the peer has had a chance to read it. +//! +//! The daemon polls `Listener::accept`; connection reads/writes run only on its +//! handler threads. A separate `Cancellation` can wake both directions without +//! waiting for either handler or peer. `finish_response` and a disconnect +//! monitor's `finish` are delivery operations, not aliases for cancellation. +//! In particular, a future named-pipe backend must not emulate Unix half-close: +//! it must retain final bytes until peer close or a bounded, cancellable deadline. + +#[cfg(unix)] +mod unix; +#[cfg(unix)] +pub(crate) use unix::{Cancellation, Connection, Listener}; + +#[cfg(windows)] +mod retained; + +#[cfg(windows)] +pub(crate) mod windows; +#[cfg(windows)] +pub(crate) use windows::{Cancellation, Connection, Listener}; diff --git a/packages/process-daemon/src/transport/retained.rs b/packages/process-daemon/src/transport/retained.rs new file mode 100644 index 000000000000..e1aaa2d15284 --- /dev/null +++ b/packages/process-daemon/src/transport/retained.rs @@ -0,0 +1,26 @@ +use std::ptr::NonNull; + +/// Own a value whose raw pointer is retained by native I/O. Moving this owner or +/// retrieving its pointer must not reborrow the value while native code accesses +/// it. The caller must reap native I/O before dropping this allocation. +pub(crate) struct Retained(NonNull); + +impl Retained { + pub fn new(value: T) -> Self { + Self(NonNull::new(Box::into_raw(Box::new(value))).expect("Box pointer is non-null")) + } + + pub fn as_ptr(&self) -> *mut T { + self.0.as_ptr() + } +} + +impl Drop for Retained { + fn drop(&mut self) { + // SAFETY: this owner uniquely owns the Box allocation. All native access + // must have finished before the caller permits this owner to drop. + unsafe { + drop(Box::from_raw(self.0.as_ptr())); + } + } +} diff --git a/packages/process-daemon/src/transport/unix.rs b/packages/process-daemon/src/transport/unix.rs new file mode 100644 index 000000000000..429edf61fee0 --- /dev/null +++ b/packages/process-daemon/src/transport/unix.rs @@ -0,0 +1,206 @@ +use std::io::{self, Read, Write}; +use std::net::Shutdown; +use std::os::unix::net::{UnixListener, UnixStream}; +use std::path::Path; +use std::thread::{self, JoinHandle}; +use std::time::Duration; + +use crossbeam_channel::{Receiver, bounded}; + +pub(crate) struct Listener(Option); + +impl Listener { + pub fn bind(endpoint: &Path) -> io::Result { + let listener = UnixListener::bind(endpoint)?; + listener.set_nonblocking(true)?; + Ok(Self(Some(listener))) + } + + /// Poll for a connection without blocking the daemon control path. + pub fn accept(&mut self) -> io::Result> { + let Some(listener) = &self.0 else { + return Ok(None); + }; + match listener.accept() { + Ok((stream, _)) => { + // macOS inherits the listener's nonblocking mode on accepted sockets. + stream.set_nonblocking(false)?; + Ok(Some(Connection(stream))) + } + Err(error) if error.kind() == io::ErrorKind::WouldBlock => Ok(None), + Err(error) => Err(error), + } + } + + pub fn stop(&mut self) { + self.0.take(); + } +} + +pub(crate) struct Connection(UnixStream); + +impl Connection { + pub fn connect(endpoint: &Path) -> io::Result { + UnixStream::connect(endpoint).map(Self) + } + + pub fn set_read_timeout(&self, timeout: Option) -> io::Result<()> { + self.0.set_read_timeout(timeout) + } + + pub fn set_write_timeout(&self, timeout: Option) -> io::Result<()> { + self.0.set_write_timeout(timeout) + } + + pub fn cancellation(&self) -> io::Result { + self.0.try_clone().map(Cancellation) + } + + /// Unix close preserves queued response bytes; no peer acknowledgement is needed. + pub fn finish_response(&mut self) -> io::Result<()> { + Ok(()) + } + + /// Subscriptions have no more request input. A read detects peer closure (or + /// unexpected extra input) while the handler writes events. + pub fn monitor_disconnect(&self) -> io::Result { + let mut reader = self.0.try_clone()?; + let stream = self.0.try_clone()?; + let (sender, disconnected) = bounded(1); + let thread = thread::spawn(move || { + let _ = reader.read(&mut [0_u8; 1]); + let _ = sender.send(()); + }); + Ok(DisconnectMonitor { + stream, + disconnected, + thread, + }) + } +} + +impl Read for Connection { + fn read(&mut self, bytes: &mut [u8]) -> io::Result { + self.0.read(bytes) + } +} + +impl Write for Connection { + fn write(&mut self, bytes: &[u8]) -> io::Result { + self.0.write(bytes) + } + + fn flush(&mut self) -> io::Result<()> { + self.0.flush() + } +} + +pub(crate) struct Cancellation(UnixStream); + +impl Cancellation { + /// Idempotently wake partial request reads and backpressured writes. + pub fn cancel(&self) { + let _ = self.0.shutdown(Shutdown::Both); + } +} + +pub(crate) struct DisconnectMonitor { + stream: UnixStream, + disconnected: Receiver<()>, + thread: JoinHandle<()>, +} + +impl DisconnectMonitor { + pub fn disconnected(&self) -> &Receiver<()> { + &self.disconnected + } + + pub fn finish(self) { + // A full shutdown can discard a just-written final frame on macOS. + // Half-close first so the peer drains queued output before closing. + // Daemon-wide cancellation still interrupts this wait during shutdown. + let _ = self.stream.shutdown(Shutdown::Write); + let _ = self.thread.join(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::protocol::{Response, read_frame, write_frame}; + + #[test] + fn listener_polls_and_connections_roundtrip() { + let endpoint = + std::env::temp_dir().join(format!("pty-{:032x}.sock", rand::random::())); + let mut listener = Listener::bind(&endpoint).unwrap(); + assert!(listener.accept().unwrap().is_none()); + let mut client = Connection::connect(&endpoint).unwrap(); + client + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let mut server = listener.accept().unwrap().unwrap(); + write_frame(&mut client, &Response::Owned).unwrap(); + assert!(matches!(read_frame(&mut server).unwrap(), Response::Owned)); + write_frame(&mut server, &Response::Ok).unwrap(); + server.finish_response().unwrap(); + assert!(matches!(read_frame(&mut client).unwrap(), Response::Ok)); + drop(listener); + std::fs::remove_file(endpoint).unwrap(); + } + + #[test] + fn cancellation_wakes_partial_reads_and_is_idempotent() { + let (reader, mut peer) = UnixStream::pair().unwrap(); + let mut connection = Connection(reader); + let cancellation = connection.cancellation().unwrap(); + peer.write_all(&[0, 0]).unwrap(); + let (sender, receiver) = bounded(1); + let reader = thread::spawn(move || { + sender + .send(read_frame::(&mut connection)) + .unwrap(); + }); + cancellation.cancel(); + cancellation.cancel(); + assert!( + receiver + .recv_timeout(Duration::from_secs(2)) + .unwrap() + .is_err() + ); + reader.join().unwrap(); + } + + #[test] + fn subscription_completion_preserves_final_frame() { + let (server, client) = UnixStream::pair().unwrap(); + let mut server = Connection(server); + let mut client = Connection(client); + client + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let monitor = server.monitor_disconnect().unwrap(); + let writer = thread::spawn(move || { + write_frame( + &mut server, + &Response::Exited { + exit_code: Some(0), + final_offset: 123, + }, + ) + .unwrap(); + monitor.finish(); + }); + assert!(matches!( + read_frame(&mut client).unwrap(), + Response::Exited { + exit_code: Some(0), + final_offset: 123, + } + )); + assert_eq!(client.read(&mut [0]).unwrap(), 0); + drop(client); + writer.join().unwrap(); + } +} diff --git a/packages/process-daemon/src/transport/windows.rs b/packages/process-daemon/src/transport/windows.rs new file mode 100644 index 000000000000..b3ccd57435a6 --- /dev/null +++ b/packages/process-daemon/src/transport/windows.rs @@ -0,0 +1,742 @@ +//! Full-duplex, overlapped byte-mode named pipes. Every pending operation owns +//! its OVERLAPPED/event and is cancelled and reaped before its buffer is released. +//! No FlushFileBuffers: it can wait indefinitely for an uncooperative client. + +use std::io::{self, Read, Write}; +use std::os::windows::ffi::OsStrExt; +use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}; +use std::path::Path; +use std::ptr::{null, null_mut}; +use std::sync::Arc; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant}; + +use crossbeam_channel::{Receiver, bounded}; +use windows_sys::Win32::Foundation::{ + ERROR_BROKEN_PIPE, ERROR_IO_INCOMPLETE, ERROR_IO_PENDING, ERROR_NO_DATA, ERROR_PIPE_BUSY, + ERROR_PIPE_CONNECTED, ERROR_PIPE_NOT_CONNECTED, GENERIC_READ, GENERIC_WRITE, HANDLE, + INVALID_HANDLE_VALUE, WAIT_OBJECT_0, WAIT_TIMEOUT, +}; +use windows_sys::Win32::Storage::FileSystem::{ + CreateFileW, FILE_FLAG_FIRST_PIPE_INSTANCE, FILE_FLAG_OVERLAPPED, OPEN_EXISTING, + PIPE_ACCESS_DUPLEX, ReadFile, SECURITY_IDENTIFICATION, SECURITY_SQOS_PRESENT, WriteFile, +}; +use windows_sys::Win32::System::IO::{CancelIoEx, GetOverlappedResult, OVERLAPPED}; +use windows_sys::Win32::System::Pipes::{ + ConnectNamedPipe, CreateNamedPipeW, GetNamedPipeServerProcessId, PIPE_READMODE_BYTE, + PIPE_REJECT_REMOTE_CLIENTS, PIPE_TYPE_BYTE, PIPE_UNLIMITED_INSTANCES, PIPE_WAIT, + WaitNamedPipeW, +}; +use windows_sys::Win32::System::Threading::{ + CreateEventW, INFINITE, SetEvent, WaitForMultipleObjects, WaitForSingleObject, +}; + +pub(crate) mod security; +use super::retained::Retained; +use security::PrivateSecurity; + +const BUFFER_BYTES: u32 = 64 * 1024; +const CONNECT_TIMEOUT: Duration = Duration::from_secs(5); +const COMPLETION_TIMEOUT: Duration = Duration::from_secs(2); + +pub(crate) struct Listener { + endpoint: Vec, + security: PrivateSecurity, + pending: PendingConnection, + stopped: bool, +} + +impl Listener { + pub fn bind(endpoint: &Path) -> io::Result { + let endpoint = endpoint_name(endpoint)?; + let security = PrivateSecurity::new()?; + // Fail closed if anyone already owns this name. Never attach to or + // replace an existing server, even one with the same user SID. + let pending = PendingConnection::new(&endpoint, &security, true)?; + Ok(Self { + endpoint, + security, + pending, + stopped: false, + }) + } + + pub fn accept(&mut self) -> io::Result> { + if self.stopped || !self.pending.ready()? { + return Ok(None); + } + // Create the next instance BEFORE handing off the connected one, so the + // registered name is continuously owned even between short requests. + let next = PendingConnection::new(&self.endpoint, &self.security, false)?; + let previous = std::mem::replace(&mut self.pending, next); + Ok(Some(Connection { + pipe: Arc::clone(&previous.operation.pipe), + read_timeout: None, + write_timeout: None, + })) + } + + /// Stop accepting without releasing the namespace. Keep the listener alive + /// until registration is removed, including throughout daemon cleanup. + pub fn stop(&mut self) { + self.stopped = true; + self.pending.operation.pipe.cancel(); + } +} + +struct PendingConnection { + operation: Operation, + connected: bool, +} + +impl PendingConnection { + fn new(endpoint: &[u16], security: &PrivateSecurity, first: bool) -> io::Result { + let flags = PIPE_ACCESS_DUPLEX + | FILE_FLAG_OVERLAPPED + | if first { + FILE_FLAG_FIRST_PIPE_INSTANCE + } else { + 0 + }; + // SAFETY: endpoint is NUL-terminated; attributes and their descriptor live + // through the call. The returned handle is uniquely adopted below. + let handle = unsafe { + CreateNamedPipeW( + endpoint.as_ptr(), + flags, + PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT | PIPE_REJECT_REMOTE_CLIENTS, + PIPE_UNLIMITED_INSTANCES, + BUFFER_BYTES, + BUFFER_BYTES, + 0, + &security.attributes(), + ) + }; + let pipe = Arc::new(Pipe::new(owned_handle(handle)?)?); + let mut operation = Operation::new(pipe)?; + let connected = match operation.begin(|handle, overlapped| { + // SAFETY: Operation retains the handle and OVERLAPPED through completion. + unsafe { ConnectNamedPipe(handle, overlapped) } + }) { + Ok(result) => result.is_some(), + // A client can connect (and even close) between CreateNamedPipe and + // ConnectNamedPipe. Hand off that connection; its reader sees EOF. + Err(error) if matches!(error.raw_os_error(), Some(code) if code == ERROR_PIPE_CONNECTED as i32 || code == ERROR_NO_DATA as i32) => { + true + } + Err(error) => return Err(error), + }; + Ok(Self { + operation, + connected, + }) + } + + fn ready(&mut self) -> io::Result { + if !self.connected { + self.connected = self.operation.poll()?.is_some(); + } + Ok(self.connected) + } +} + +struct Pipe { + handle: OwnedHandle, + cancelled: OwnedHandle, +} + +impl Pipe { + fn new(handle: OwnedHandle) -> io::Result { + Ok(Self { + handle, + cancelled: event()?, + }) + } + + fn cancel(&self) { + // SAFETY: this manual-reset event stays valid while any operation or + // cancellation handle holds the Pipe. Signalling it is idempotent. + unsafe { + SetEvent(self.cancelled.as_raw_handle()); + } + } +} + +pub struct Connection { + pipe: Arc, + read_timeout: Option, + write_timeout: Option, +} + +impl Connection { + pub fn connect(endpoint: &Path) -> io::Result { + let endpoint = endpoint_name(endpoint)?; + let deadline = Instant::now() + CONNECT_TIMEOUT; + loop { + // SECURITY_IDENTIFICATION prevents a rogue server from impersonating + // this client. Client handles are non-inheritable and overlapped too. + // SAFETY: endpoint is NUL-terminated; unused pointers are null. + let handle = unsafe { + CreateFileW( + endpoint.as_ptr(), + GENERIC_READ | GENERIC_WRITE, + 0, + null(), + OPEN_EXISTING, + FILE_FLAG_OVERLAPPED | SECURITY_SQOS_PRESENT | SECURITY_IDENTIFICATION, + null_mut(), + ) + }; + match owned_handle(handle) { + Ok(handle) => { + return Ok(Self { + pipe: Arc::new(Pipe::new(handle)?), + read_timeout: None, + write_timeout: None, + }); + } + Err(error) if error.raw_os_error() == Some(ERROR_PIPE_BUSY as i32) => { + if Instant::now() >= deadline { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "named pipe is busy", + )); + } + // SAFETY: endpoint is valid; each kernel wait is bounded. + unsafe { + WaitNamedPipeW(endpoint.as_ptr(), 10); + } + } + Err(error) => return Err(error), + } + } + } + + /// Connects only if the pipe server is the registered daemon process. + /// + /// Pipe names are visible to other local users. After a daemon crash its + /// registration remains, and another user could create a pipe with the + /// stale name; the endpoint's protections only last while the daemon holds + /// it. Clients must verify the server before sending credentials or input. + pub fn connect_to(endpoint: &Path, server_pid: u32) -> io::Result { + let connection = Self::connect(endpoint)?; + let mut pid = 0; + // SAFETY: the connection owns a live pipe handle; pid is valid output. + if unsafe { GetNamedPipeServerProcessId(connection.pipe.handle.as_raw_handle(), &mut pid) } + == 0 + { + return Err(io::Error::last_os_error()); + } + if pid != server_pid { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "named pipe server is not the registered opencode-pty daemon", + )); + } + Ok(connection) + } + + /// A timed-out operation is cancelled but may already have transferred + /// some bytes, so the protocol stream is no longer framed reliably. + /// Discard the connection after any timeout error. + pub fn set_read_timeout(&mut self, timeout: Option) { + self.read_timeout = timeout; + } + + pub fn set_write_timeout(&mut self, timeout: Option) { + self.write_timeout = timeout; + } + + pub(crate) fn cancellation(&self) -> io::Result { + Ok(Cancellation(Arc::clone(&self.pipe))) + } + + /// Protocol 7 has an explicit response/final event, not a pipe half-close. + /// Retain the pipe until the client reads that frame and closes its end. + /// An uncooperative peer is cancelled after a bounded grace period. + pub(crate) fn finish_response(&mut self) -> io::Result<()> { + match self.read_with_timeout(&mut [0], Some(COMPLETION_TIMEOUT)) { + Ok(0) => Ok(()), + Ok(_) => Err(io::Error::new( + io::ErrorKind::InvalidData, + "unexpected data after request", + )), + Err(error) => { + self.pipe.cancel(); + Err(error) + } + } + } + + pub(crate) fn monitor_disconnect(&self) -> io::Result { + let mut reader = Self { + pipe: Arc::clone(&self.pipe), + read_timeout: None, + write_timeout: None, + }; + let cancellation = self.cancellation()?; + let (sender, disconnected) = bounded(1); + let thread = thread::spawn(move || { + let _ = reader.read(&mut [0]); + let _ = sender.send(()); + }); + Ok(DisconnectMonitor { + disconnected, + cancellation, + thread, + }) + } + + fn read_with_timeout( + &mut self, + bytes: &mut [u8], + timeout: Option, + ) -> io::Result { + if bytes.is_empty() { + return Ok(0); + } + let mut operation = Operation::new(Arc::clone(&self.pipe))?; + let result = operation + .begin(|handle, overlapped| { + // SAFETY: bytes and the OVERLAPPED remain live until wait/drop reaps + // the operation. Only one reader is used on each end of a pipe. + unsafe { + ReadFile( + handle, + bytes.as_mut_ptr(), + bytes.len().min(BUFFER_BYTES as usize) as u32, + null_mut(), + overlapped, + ) + } + }) + .and_then(|ready| match ready { + Some(count) => Ok(count), + None => operation.wait(timeout), + }); + match result { + Ok(count) => Ok(count as usize), + Err(error) if matches!(error.raw_os_error(), Some(code) if code == ERROR_BROKEN_PIPE as i32 || code == ERROR_PIPE_NOT_CONNECTED as i32 || code == ERROR_NO_DATA as i32) => { + Ok(0) + } + Err(error) => Err(error), + } + } +} + +impl Read for Connection { + fn read(&mut self, bytes: &mut [u8]) -> io::Result { + self.read_with_timeout(bytes, self.read_timeout) + } +} + +impl Write for Connection { + fn write(&mut self, bytes: &[u8]) -> io::Result { + if bytes.is_empty() { + return Ok(0); + } + let mut operation = Operation::new(Arc::clone(&self.pipe))?; + let ready = operation.begin(|handle, overlapped| { + // SAFETY: bytes and OVERLAPPED remain live through completion; at + // most BUFFER_BYTES are submitted, bounding each kernel I/O buffer. + unsafe { + WriteFile( + handle, + bytes.as_ptr(), + bytes.len().min(BUFFER_BYTES as usize) as u32, + null_mut(), + overlapped, + ) + } + })?; + Ok(match ready { + Some(count) => count, + None => operation.wait(self.write_timeout)?, + } as usize) + } + + fn flush(&mut self) -> io::Result<()> { + // No userspace buffering. FlushFileBuffers is NOT a cancellable stream + // flush and must never be used for framing or shutdown. + Ok(()) + } +} + +pub(crate) struct Cancellation(Arc); + +impl Cancellation { + pub fn cancel(&self) { + self.0.cancel(); + } +} + +pub(crate) struct DisconnectMonitor { + disconnected: Receiver<()>, + cancellation: Cancellation, + thread: JoinHandle<()>, +} + +impl DisconnectMonitor { + pub fn disconnected(&self) -> &Receiver<()> { + &self.disconnected + } + + pub fn finish(self) { + // No half-close exists. Protocol clients close after their final frame; + // retain queued bytes for them, but never wait indefinitely for closure. + if self.disconnected.recv_timeout(COMPLETION_TIMEOUT).is_err() { + self.cancellation.cancel(); + } + let _ = self.thread.join(); + } +} + +struct Operation { + pipe: Arc, + overlapped: Retained, + event: OwnedHandle, + pending: bool, +} + +impl Operation { + fn new(pipe: Arc) -> io::Result { + // SAFETY: the event is owned by pipe and zero timeout only queries it. + if unsafe { WaitForSingleObject(pipe.cancelled.as_raw_handle(), 0) } == WAIT_OBJECT_0 { + return Err(cancelled()); + } + let event = event()?; + let overlapped = Retained::new(OVERLAPPED { + hEvent: event.as_raw_handle(), + ..Default::default() + }); + Ok(Self { + pipe, + overlapped, + event, + pending: false, + }) + } + + fn begin( + &mut self, + submit: impl FnOnce(HANDLE, *mut OVERLAPPED) -> i32, + ) -> io::Result> { + self.pending = true; + if submit(self.pipe.handle.as_raw_handle(), self.overlapped.as_ptr()) != 0 { + return self.poll(); + } + let error = io::Error::last_os_error(); + if error.raw_os_error() == Some(ERROR_IO_PENDING as i32) { + Ok(None) + } else { + self.pending = false; + Err(error) + } + } + + fn poll(&mut self) -> io::Result> { + let mut count = 0; + // SAFETY: both handle and OVERLAPPED belong to this live operation. + if unsafe { + GetOverlappedResult( + self.pipe.handle.as_raw_handle(), + self.overlapped.as_ptr(), + &mut count, + 0, + ) + } != 0 + { + self.pending = false; + return Ok(Some(count)); + } + let error = io::Error::last_os_error(); + if error.raw_os_error() == Some(ERROR_IO_INCOMPLETE as i32) { + return Ok(None); + } + self.pending = false; + Err(error) + } + + fn wait(&mut self, timeout: Option) -> io::Result { + let handles = [ + self.pipe.cancelled.as_raw_handle(), + self.event.as_raw_handle(), + ]; + let milliseconds = timeout.map_or(INFINITE, |time| { + time.as_millis().min((INFINITE - 1) as u128) as u32 + }); + // SAFETY: both event handles remain live throughout this bounded or + // explicitly cancellable wait. Cancellation wins if both are signalled. + let result = unsafe { WaitForMultipleObjects(2, handles.as_ptr(), 0, milliseconds) }; + if result == WAIT_OBJECT_0 { + return Err(cancelled()); + } + if result == WAIT_TIMEOUT { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "named pipe completion timed out", + )); + } + if result != WAIT_OBJECT_0 + 1 { + return Err(io::Error::last_os_error()); + } + self.poll()? + .ok_or_else(|| io::Error::other("named pipe signalled before I/O completion")) + } +} + +impl Drop for Operation { + fn drop(&mut self) { + if self.pending { + // SAFETY: cancel THIS operation, then reap its completion before + // freeing the OVERLAPPED/event or returning the borrowed I/O buffer. + // CancelIoEx alone does not guarantee the kernel has stopped using it. + unsafe { + CancelIoEx(self.pipe.handle.as_raw_handle(), self.overlapped.as_ptr()); + let mut count = 0; + GetOverlappedResult( + self.pipe.handle.as_raw_handle(), + self.overlapped.as_ptr(), + &mut count, + 1, + ); + } + } + } +} + +fn event() -> io::Result { + // SAFETY: create an unnamed, non-inheritable, initially unset manual-reset event. + owned_handle(unsafe { CreateEventW(null(), 1, 0, null()) }) +} + +pub(crate) fn owned_handle(handle: HANDLE) -> io::Result { + if handle.is_null() || handle == INVALID_HANDLE_VALUE { + return Err(io::Error::last_os_error()); + } + // SAFETY: callers pass newly created, uniquely owned handles. + Ok(unsafe { OwnedHandle::from_raw_handle(handle) }) +} + +fn cancelled() -> io::Error { + // Not Interrupted: Read::read_exact retries Interrupted forever. + io::Error::new( + io::ErrorKind::ConnectionAborted, + "named pipe operation cancelled", + ) +} + +fn endpoint_name(endpoint: &Path) -> io::Result> { + let value = endpoint.as_os_str().encode_wide().collect::>(); + let prefix = r"\\.\pipe\opencode-pty-".encode_utf16().collect::>(); + if !value.starts_with(&prefix) + || value.len() == prefix.len() + || value[prefix.len()..] + .iter() + .any(|unit| *unit == 0 || *unit == b'\\' as u16 || *unit == b'/' as u16) + { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "expected a local opencode-pty named-pipe endpoint", + )); + } + Ok(value.into_iter().chain([0]).collect()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::protocol::{Response, read_frame, write_frame}; + use std::path::PathBuf; + + fn endpoint() -> PathBuf { + PathBuf::from(format!( + r"\\.\pipe\opencode-pty-{:032x}", + rand::random::() + )) + } + + fn accept(listener: &mut Listener) -> Connection { + let deadline = Instant::now() + Duration::from_secs(3); + loop { + if let Some(connection) = listener.accept().unwrap() { + return connection; + } + assert!(Instant::now() < deadline, "named pipe accept timed out"); + thread::sleep(Duration::from_millis(1)); + } + } + + #[test] + fn multiple_byte_stream_connections_and_namespace_ownership() { + let endpoint = endpoint(); + let mut listener = Listener::bind(&endpoint).unwrap(); + assert!(listener.accept().unwrap().is_none()); + assert!( + Listener::bind(&endpoint).is_err(), + "must not join an existing server name" + ); + let mut first = Connection::connect(&endpoint).unwrap(); + let mut first_server = accept(&mut listener); + let mut second = Connection::connect(&endpoint).unwrap(); + let mut second_server = accept(&mut listener); + // Split writes are a byte stream, not Windows message-mode records. + first.write_all(b"ab").unwrap(); + first.write_all(b"cd").unwrap(); + second.write_all(b"xy").unwrap(); + let mut bytes = [0; 4]; + first_server.read_exact(&mut bytes).unwrap(); + assert_eq!(&bytes, b"abcd"); + second_server.read_exact(&mut bytes[..2]).unwrap(); + assert_eq!(&bytes[..2], b"xy"); + write_frame(&mut first_server, &Response::Owned).unwrap(); + assert!(matches!(read_frame(&mut first).unwrap(), Response::Owned)); + drop((first, first_server, second, second_server)); + assert!(Listener::bind(&endpoint).is_err()); + listener.stop(); + assert!(listener.accept().unwrap().is_none()); + assert!( + Listener::bind(&endpoint).is_err(), + "stop must retain the registered name" + ); + drop(listener); + Listener::bind(&endpoint).unwrap(); + } + + #[test] + fn cancellation_wakes_partial_request_reads() { + let endpoint = endpoint(); + let mut listener = Listener::bind(&endpoint).unwrap(); + let mut client = Connection::connect(&endpoint).unwrap(); + let mut server = accept(&mut listener); + let cancellation = server.cancellation().unwrap(); + client.write_all(&[0, 0]).unwrap(); + let (sender, receiver) = bounded(1); + let reader = thread::spawn(move || { + sender.send(read_frame::(&mut server)).unwrap(); + }); + cancellation.cancel(); + cancellation.cancel(); + assert!( + receiver + .recv_timeout(Duration::from_secs(3)) + .unwrap() + .is_err() + ); + reader.join().unwrap(); + } + + #[test] + fn cancellation_wakes_backpressured_writes() { + let endpoint = endpoint(); + let mut listener = Listener::bind(&endpoint).unwrap(); + let _client = Connection::connect(&endpoint).unwrap(); + let mut server = accept(&mut listener); + let cancellation = server.cancellation().unwrap(); + let (started, ready) = bounded(1); + let (sender, receiver) = bounded(1); + let writer = thread::spawn(move || { + started.send(()).unwrap(); + sender + .send(server.write_all(&[2; 2 * BUFFER_BYTES as usize])) + .unwrap(); + }); + ready.recv_timeout(Duration::from_secs(3)).unwrap(); + assert!( + receiver.recv_timeout(Duration::from_millis(50)).is_err(), + "write should be backpressured" + ); + cancellation.cancel(); + assert!( + receiver + .recv_timeout(Duration::from_secs(3)) + .unwrap() + .is_err() + ); + writer.join().unwrap(); + } + + #[test] + fn final_response_survives_until_client_reads_and_disconnects() { + let endpoint = endpoint(); + let mut listener = Listener::bind(&endpoint).unwrap(); + let mut client = Connection::connect(&endpoint).unwrap(); + let mut server = accept(&mut listener); + let (sender, receiver) = bounded(1); + let writer = thread::spawn(move || { + write_frame( + &mut server, + &Response::Exited { + exit_code: Some(0), + final_offset: 123, + }, + ) + .unwrap(); + sender.send(server.finish_response()).unwrap(); + }); + assert!( + receiver.recv_timeout(Duration::from_millis(50)).is_err(), + "server must retain final response" + ); + assert!(matches!( + read_frame(&mut client).unwrap(), + Response::Exited { + final_offset: 123, + .. + } + )); + drop(client); + receiver + .recv_timeout(Duration::from_secs(3)) + .unwrap() + .unwrap(); + writer.join().unwrap(); + } + + #[test] + fn subscription_completion_is_bounded_and_cancellable() { + let endpoint = endpoint(); + let mut listener = Listener::bind(&endpoint).unwrap(); + let _client = Connection::connect(&endpoint).unwrap(); + let server = accept(&mut listener); + let monitor = server.monitor_disconnect().unwrap(); + let start = Instant::now(); + monitor.finish(); + assert!(start.elapsed() < Duration::from_secs(4)); + + let _client = Connection::connect(&endpoint).unwrap(); + let server = accept(&mut listener); + let monitor = server.monitor_disconnect().unwrap(); + let cancellation = server.cancellation().unwrap(); + cancellation.cancel(); + monitor + .disconnected() + .recv_timeout(Duration::from_secs(3)) + .unwrap(); + monitor.finish(); + } + + #[test] + fn verified_connect_requires_the_expected_server_process() { + let endpoint = endpoint(); + let mut listener = Listener::bind(&endpoint).unwrap(); + let _verified = Connection::connect_to(&endpoint, std::process::id()).unwrap(); + let _server = accept(&mut listener); + let error = Connection::connect_to(&endpoint, std::process::id().wrapping_add(4)) + .err() + .expect("a different server process must be rejected"); + assert_eq!(error.kind(), io::ErrorKind::PermissionDenied); + } + + #[test] + fn remote_and_non_pipe_endpoints_are_rejected() { + for endpoint in [ + r"\\remote\pipe\opencode-pty-test", + r"C:\tmp\pipe", + r"\\.\pipe\opencode-pty-test\nested", + ] { + assert!(Connection::connect(Path::new(endpoint)).is_err()); + assert!(Listener::bind(Path::new(endpoint)).is_err()); + } + } +} diff --git a/packages/process-daemon/src/transport/windows/security.rs b/packages/process-daemon/src/transport/windows/security.rs new file mode 100644 index 000000000000..57f8f3760bed --- /dev/null +++ b/packages/process-daemon/src/transport/windows/security.rs @@ -0,0 +1,287 @@ +use std::io; +use std::os::windows::io::AsRawHandle; +use std::ptr::null_mut; + +use windows_sys::Win32::Foundation::{HANDLE, LocalFree}; +use windows_sys::Win32::Security::Authorization::{ + ConvertSidToStringSidW, ConvertStringSecurityDescriptorToSecurityDescriptorW, GetSecurityInfo, + SDDL_REVISION_1, SE_FILE_OBJECT, SetSecurityInfo, +}; +use windows_sys::Win32::Security::{ + ACCESS_ALLOWED_ACE, ACE_HEADER, DACL_SECURITY_INFORMATION, EqualSid, GetAce, + GetSecurityDescriptorControl, GetSecurityDescriptorDacl, GetSecurityDescriptorOwner, + GetTokenInformation, OWNER_SECURITY_INFORMATION, PROTECTED_DACL_SECURITY_INFORMATION, + SE_DACL_PROTECTED, SECURITY_ATTRIBUTES, TOKEN_QUERY, TOKEN_USER, TokenUser, +}; +use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; + +use super::owned_handle; + +pub(crate) struct PrivateSecurity(*mut core::ffi::c_void); + +impl PrivateSecurity { + pub fn new() -> io::Result { + let sid = current_user_sid()?; + // Protected DACL: only the current user, no inherited Everyone/network + // grants. Explicit owner also avoids inheriting an Administrators owner. + let sddl = format!("O:{sid}D:P(A;;GA;;;{sid})\0") + .encode_utf16() + .collect::>(); + let mut descriptor = null_mut(); + // SAFETY: valid NUL-terminated SDDL; successful allocation uses LocalFree. + if unsafe { + ConvertStringSecurityDescriptorToSecurityDescriptorW( + sddl.as_ptr(), + SDDL_REVISION_1, + &mut descriptor, + null_mut(), + ) + } == 0 + { + return Err(io::Error::last_os_error()); + } + Ok(Self(descriptor)) + } + + pub fn attributes(&self) -> SECURITY_ATTRIBUTES { + SECURITY_ATTRIBUTES { + nLength: size_of::() as u32, + lpSecurityDescriptor: self.0, + bInheritHandle: 0, + } + } + + /// Existing files/directories are never adopted from a different owner. + /// Apply the protected current-user DACL before any secrets are published. + pub fn make_private(&self, handle: HANDLE) -> io::Result<()> { + self.check_owner(handle)?; + let mut present = 0; + let mut defaulted = 0; + let mut dacl = null_mut(); + // SAFETY: self owns a valid descriptor and returned DACL remains inside it. + if unsafe { GetSecurityDescriptorDacl(self.0, &mut present, &mut dacl, &mut defaulted) } + == 0 + { + return Err(io::Error::last_os_error()); + } + // SAFETY: the live handle is opened with WRITE_DAC; dacl is valid above. + let error = unsafe { + SetSecurityInfo( + handle, + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION, + null_mut(), + null_mut(), + dacl, + null_mut(), + ) + }; + if error != 0 { + return Err(io::Error::from_raw_os_error(error as i32)); + } + Ok(()) + } + + pub fn check_private(&self, handle: HANDLE) -> io::Result<()> { + let actual = self.check_owner(handle)?; + let mut present = 0; + let mut defaulted = 0; + let mut dacl = null_mut(); + let mut control = 0; + let mut revision = 0; + // SAFETY: actual owns a valid Windows descriptor throughout inspection. + if unsafe { GetSecurityDescriptorDacl(actual.0, &mut present, &mut dacl, &mut defaulted) } + == 0 + || unsafe { GetSecurityDescriptorControl(actual.0, &mut control, &mut revision) } == 0 + { + return Err(io::Error::last_os_error()); + } + if present == 0 || dacl.is_null() || control & SE_DACL_PROTECTED == 0 { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "unprotected PTY storage DACL", + )); + } + let mut ace = null_mut(); + // SAFETY: dacl is non-null and belongs to the live actual descriptor. + if unsafe { (*dacl).AceCount } != 1 || unsafe { GetAce(dacl, 0, &mut ace) } == 0 { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "PTY storage must grant only the current user", + )); + } + // ACCESS_ALLOWED_ACE_TYPE is zero. Other ACE layouts must not be cast. + // SAFETY: Windows returned an ACE from a validated security descriptor. + if unsafe { (*ace.cast::()).AceType } != 0 { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "unexpected PTY storage ACE", + )); + } + // SAFETY: an ACCESS_ALLOWED_ACE has an inline SID beginning at SidStart. + let sid = unsafe { (&raw mut (*ace.cast::()).SidStart).cast() }; + if !self.is_current_user(sid)? { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "PTY storage grants another principal", + )); + } + Ok(()) + } + + fn check_owner(&self, handle: HANDLE) -> io::Result { + let mut owner = null_mut(); + let mut descriptor = null_mut(); + // SAFETY: query a live file handle. Descriptor is a unique LocalFree allocation. + let error = unsafe { + GetSecurityInfo( + handle, + SE_FILE_OBJECT, + OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION, + &mut owner, + null_mut(), + null_mut(), + null_mut(), + &mut descriptor, + ) + }; + if error != 0 { + return Err(io::Error::from_raw_os_error(error as i32)); + } + let actual = Self(descriptor); + if owner.is_null() || !self.is_current_user(owner)? { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "PTY storage is not owned by the current user", + )); + } + Ok(actual) + } + + fn is_current_user(&self, sid: *mut core::ffi::c_void) -> io::Result { + let mut owner = null_mut(); + let mut defaulted = 0; + // SAFETY: self owns the descriptor; supplied SID belongs to another live descriptor. + if unsafe { GetSecurityDescriptorOwner(self.0, &mut owner, &mut defaulted) } == 0 { + return Err(io::Error::last_os_error()); + } + Ok(unsafe { EqualSid(owner, sid) } != 0) + } +} + +impl Drop for PrivateSecurity { + fn drop(&mut self) { + // SAFETY: this is the unique descriptor allocation returned by conversion. + unsafe { + LocalFree(self.0); + } + } +} + +fn current_user_sid() -> io::Result { + let mut token = null_mut(); + // SAFETY: query the current process token; successful handle is adopted below. + if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) } == 0 { + return Err(io::Error::last_os_error()); + } + let token = owned_handle(token)?; + let mut length = 0; + // SAFETY: first call only determines the variable-sized token information buffer. + unsafe { + GetTokenInformation(token.as_raw_handle(), TokenUser, null_mut(), 0, &mut length); + } + if length == 0 { + return Err(io::Error::last_os_error()); + } + // usize provides the alignment TOKEN_USER requires, unlike Vec. + let mut buffer = vec![0_usize; (length as usize).div_ceil(size_of::())]; + // SAFETY: buffer is aligned, large enough, and retained through SID conversion. + if unsafe { + GetTokenInformation( + token.as_raw_handle(), + TokenUser, + buffer.as_mut_ptr().cast(), + length, + &mut length, + ) + } == 0 + { + return Err(io::Error::last_os_error()); + } + // SAFETY: a successful TokenUser query initializes a TOKEN_USER at this address. + let user = unsafe { &*buffer.as_ptr().cast::() }; + let mut text = null_mut(); + // SAFETY: the queried token owns a valid SID; returned string uses LocalFree. + if unsafe { ConvertSidToStringSidW(user.User.Sid, &mut text) } == 0 { + return Err(io::Error::last_os_error()); + } + // SAFETY: conversion returns a NUL-terminated wide string allocated by Windows. + let sid = unsafe { + let mut length = 0; + while *text.add(length) != 0 { + length += 1; + } + let sid = String::from_utf16_lossy(std::slice::from_raw_parts(text, length)); + LocalFree(text.cast()); + sid + }; + Ok(sid) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Foundation::{GENERIC_READ, GENERIC_WRITE}; + use windows_sys::Win32::Storage::FileSystem::{ + CREATE_NEW, CreateFileW, READ_CONTROL, WRITE_DAC, + }; + + #[test] + fn rejects_public_storage_acl_and_privatises_only_our_owned_file() { + let path = std::env::temp_dir().join(format!("pty-acl-{:032x}", rand::random::())); + let wide = path + .as_os_str() + .encode_wide() + .chain([0]) + .collect::>(); + let sid = current_user_sid().unwrap(); + let public = format!("O:{sid}D:P(A;;GA;;;WD)\0") + .encode_utf16() + .collect::>(); + let mut descriptor = null_mut(); + // SAFETY: valid SDDL; allocation is immediately adopted by the RAII owner. + assert_ne!( + unsafe { + ConvertStringSecurityDescriptorToSecurityDescriptorW( + public.as_ptr(), + SDDL_REVISION_1, + &mut descriptor, + null_mut(), + ) + }, + 0 + ); + let public = PrivateSecurity(descriptor); + // SAFETY: a unique temporary file, live private owner + deliberately public + // test DACL. No secrets are written while the DACL is permissive. + let file = owned_handle(unsafe { + CreateFileW( + wide.as_ptr(), + GENERIC_READ | GENERIC_WRITE | READ_CONTROL | WRITE_DAC, + 0, + &public.attributes(), + CREATE_NEW, + 0, + null_mut(), + ) + }) + .unwrap(); + let private = PrivateSecurity::new().unwrap(); + assert!(private.check_private(file.as_raw_handle()).is_err()); + private.make_private(file.as_raw_handle()).unwrap(); + private.check_private(file.as_raw_handle()).unwrap(); + drop(file); + std::fs::remove_file(path).unwrap(); + } +} diff --git a/packages/process-daemon/tests/ghostty-effects.rs b/packages/process-daemon/tests/ghostty-effects.rs new file mode 100644 index 000000000000..a16a2d8864e8 --- /dev/null +++ b/packages/process-daemon/tests/ghostty-effects.rs @@ -0,0 +1,107 @@ +//! Exercise the production callback owner without linking or calling native code. +//! The small NativeOwner stand-in retains the same userdata pointer that C would. +//! Also run by the isolated tests/ghostty-effects Cargo manifest under Miri. +#![cfg(test)] + +#[path = "../src/ghostty/effects.rs"] +mod effects; +#[path = "../src/ghostty/ffi.rs"] +mod ffi; + +use std::collections::VecDeque; +use std::ffi::c_void; +use std::panic::{AssertUnwindSafe, catch_unwind}; +use std::ptr; + +use effects::Effects; + +struct NativeOwner { + effects: Effects, + userdata: *mut c_void, +} + +impl NativeOwner { + fn new() -> Self { + let effects = Effects::default(); + // Register before returning/moving the owner, exactly as Terminal::new does. + let userdata = effects.userdata().cast_mut(); + Self { effects, userdata } + } + + fn emit(&mut self, bytes: &[u8]) { + // SAFETY: effects still owns the retained userdata, and bytes lives through the call. + unsafe { Effects::write_pty(ptr::null_mut(), self.userdata, bytes.as_ptr(), bytes.len()) }; + self.effects.resume_panic(); + } + + fn take_writes(&mut self) -> VecDeque> { + // Exercise a mutable borrow of the containing owner, not just a standalone Rc. + self.effects.take_writes() + } +} + +impl Drop for NativeOwner { + fn drop(&mut self) { + // Model native teardown while callback state must still be alive, as in Terminal::drop. + // SAFETY: the Effects field is only dropped after this destructor returns. + unsafe { + Effects::write_pty(ptr::null_mut(), self.userdata, b"teardown".as_ptr(), 8); + Effects::title_changed(ptr::null_mut(), self.userdata); + } + } +} + +#[test] +fn retained_userdata_survives_moves_and_repeated_drains() { + let mut owners = Vec::with_capacity(1); + owners.push(NativeOwner::new()); + owners.reserve_exact(32); + let mut owner = Box::new(owners.pop().unwrap()); + + for byte in 0..8 { + owner.emit(&[byte]); + assert_eq!(owner.take_writes(), VecDeque::from([vec![byte]])); + assert!(owner.take_writes().is_empty()); + // This pointer was never refreshed after construction or mutable owner borrows. + unsafe { Effects::title_changed(ptr::null_mut(), owner.userdata) }; + assert!(owner.effects.take_title_changed()); + assert!(!owner.effects.take_title_changed()); + } +} + +#[test] +fn callback_bytes_are_owned_after_the_source_buffer_is_gone() { + let mut owner = NativeOwner::new(); + let mut bytes = vec![1, 2, 3]; + owner.emit(&bytes); + bytes.fill(0); + drop(bytes); + assert_eq!(owner.take_writes(), VecDeque::from([vec![1, 2, 3]])); +} + +#[test] +fn empty_callback_does_not_dereference_a_null_buffer() { + let mut owner = NativeOwner::new(); + // SAFETY: zero-length callbacks do not require a data allocation. + unsafe { Effects::write_pty(ptr::null_mut(), owner.userdata, ptr::null(), 0) }; + owner.effects.resume_panic(); + assert!(owner.take_writes().is_empty()); +} + +#[test] +fn callback_panics_return_to_the_caller_before_resuming() { + let mut owner = NativeOwner::new(); + // Intentionally trigger the defensive assertion before any buffer dereference. + unsafe { Effects::write_pty(ptr::null_mut(), owner.userdata, ptr::null(), 1) }; + assert!(catch_unwind(AssertUnwindSafe(|| owner.effects.resume_panic())).is_err()); + owner.emit(b"after panic"); + assert_eq!( + owner.take_writes(), + VecDeque::from([b"after panic".to_vec()]) + ); +} + +#[test] +fn retained_userdata_is_live_during_native_teardown() { + drop(NativeOwner::new()); +} diff --git a/packages/process-daemon/tests/ghostty-effects/.gitignore b/packages/process-daemon/tests/ghostty-effects/.gitignore new file mode 100644 index 000000000000..ea8c4bf7f35f --- /dev/null +++ b/packages/process-daemon/tests/ghostty-effects/.gitignore @@ -0,0 +1 @@ +/target diff --git a/packages/process-daemon/tests/ghostty-effects/Cargo.lock b/packages/process-daemon/tests/ghostty-effects/Cargo.lock new file mode 100644 index 000000000000..c56595963c72 --- /dev/null +++ b/packages/process-daemon/tests/ghostty-effects/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ghostty-effects-miri" +version = "0.0.0" diff --git a/packages/process-daemon/tests/ghostty-effects/Cargo.toml b/packages/process-daemon/tests/ghostty-effects/Cargo.toml new file mode 100644 index 000000000000..685fc2f48b55 --- /dev/null +++ b/packages/process-daemon/tests/ghostty-effects/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "ghostty-effects-miri" +version = "0.0.0" +edition = "2024" +publish = false + +[lib] +path = "../ghostty-effects.rs" +doctest = false + +# Isolate Miri from the native build and PTY dependencies. The test imports the +# production effects module directly, rather than copying its implementation. +[workspace] diff --git a/packages/process-daemon/tests/ownership.rs b/packages/process-daemon/tests/ownership.rs new file mode 100644 index 000000000000..a9f6725902e4 --- /dev/null +++ b/packages/process-daemon/tests/ownership.rs @@ -0,0 +1,447 @@ +#![cfg(unix)] + +use std::io::{Read, Write}; +use std::os::unix::net::{UnixListener, UnixStream}; +use std::path::PathBuf; +use std::process::{Child, Command, Stdio}; +use std::sync::Barrier; +use std::thread; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use base64::Engine; +use opencode_pty::daemon::Registration; +use opencode_pty::protocol::{ + AttachmentRole, Envelope, Request, Response, read_frame, write_frame, +}; +use opencode_pty::service::TerminalInfo; + +struct Daemon { + child: Child, + root: PathBuf, + directory: PathBuf, + registration: Registration, +} + +impl Daemon { + fn start() -> Self { + let root = std::env::temp_dir().join(format!( + "opencode-pty-ownership-{:032x}", + rand::random::() + )); + let directory = root.join("test"); + let mut child = Command::new(env!("CARGO_BIN_EXE_opencode-pty")) + .args(["daemon", "--name", "test", "--runtime-dir"]) + .arg(&root) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .spawn() + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(3); + let registration = loop { + if let Ok(data) = std::fs::read(directory.join("service.json")) { + break serde_json::from_slice::(&data).unwrap(); + } + assert!( + child.try_wait().unwrap().is_none(), + "daemon exited at startup" + ); + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + panic!("daemon did not register"); + } + thread::sleep(Duration::from_millis(10)); + }; + assert_eq!(registration.protocol, 7); + Self { + child, + root, + directory, + registration, + } + } + + fn connect(&self) -> UnixStream { + let stream = UnixStream::connect(&self.registration.socket).unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(3))) + .unwrap(); + stream + .set_write_timeout(Some(Duration::from_secs(3))) + .unwrap(); + stream + } + + fn send(&self, stream: &mut UnixStream, request: Request) -> Response { + write_frame( + &mut *stream, + &Envelope { + token: self.registration.token.clone(), + request, + }, + ) + .unwrap(); + read_frame(stream).unwrap() + } + + fn request(&self, request: Request) -> Response { + self.send(&mut self.connect(), request) + } + + fn own(&self, ticket: Option) -> (UnixStream, Response) { + let mut stream = self.connect(); + let response = self.send( + &mut stream, + Request::Own { + instance_id: self.registration.instance_id.clone(), + ticket, + }, + ); + (stream, response) + } + + fn terminal(&self, command: &str) -> TerminalInfo { + match self.request(Request::Create { + program: "/bin/sh".into(), + args: vec!["-c".into(), command.into()], + cwd: self.directory.clone(), + title: "ownership".into(), + group_id: "ownership".into(), + env: Default::default(), + cols: 80, + rows: 24, + }) { + Response::Created { terminal } => terminal, + response => panic!("create: {response:?}"), + } + } + + fn wait(&mut self) { + self.exit(); + assert!(!self.registration.socket.exists()); + } + + fn exit(&mut self) { + let deadline = Instant::now() + Duration::from_secs(7); + loop { + if let Some(status) = self.child.try_wait().unwrap() { + assert!(status.success(), "daemon exit: {status}"); + assert!( + !self.directory.exists(), + "runtime directory was not removed" + ); + return; + } + assert!(Instant::now() < deadline, "daemon did not stop"); + thread::sleep(Duration::from_millis(10)); + } + } +} + +impl Drop for Daemon { + fn drop(&mut self) { + if self.child.try_wait().ok().flatten().is_none() { + if let Ok(mut stream) = UnixStream::connect(&self.registration.socket) { + let _ = stream.set_write_timeout(Some(Duration::from_secs(1))); + let _ = write_frame( + &mut stream, + &Envelope { + token: self.registration.token.clone(), + request: Request::Shutdown, + }, + ); + } + let deadline = Instant::now() + Duration::from_secs(6); + while self.child.try_wait().ok().flatten().is_none() && Instant::now() < deadline { + thread::sleep(Duration::from_millis(10)); + } + let _ = self.child.kill(); + let _ = self.child.wait(); + } + let _ = std::fs::remove_dir_all(&self.root); + } +} + +fn assert_terminal_stopped(terminal: &TerminalInfo) { + // SAFETY: signal zero only checks whether this child PID still exists. + assert_eq!(unsafe { libc::kill(terminal.pid.unwrap() as i32, 0) }, -1); + assert_eq!( + std::io::Error::last_os_error().raw_os_error(), + Some(libc::ESRCH) + ); +} + +#[test] +fn owner_loss_stops_terminals_despite_blocked_clients() { + let mut daemon = Daemon::start(); + let mut unauthenticated = daemon.connect(); + write_frame( + &mut unauthenticated, + &Envelope { + token: "wrong-token".into(), + request: Request::Own { + instance_id: daemon.registration.instance_id.clone(), + ticket: None, + }, + }, + ) + .unwrap(); + assert!( + matches!(read_frame(&mut unauthenticated).unwrap(), Response::Error { message } if message == "authentication failed") + ); + assert!(matches!( + daemon.request(Request::Own { + instance_id: "wrong-instance".into(), + ticket: None, + }), + Response::Error { .. } + )); + let (owner, response) = daemon.own(None); + assert!(matches!(response, Response::Owned)); + assert!(matches!(daemon.own(None).1, Response::Error { .. })); + + let terminal = daemon.terminal("while :; do printf '\\rowner-output'; done"); + let mut subscription = daemon.connect(); + assert!(matches!( + daemon.send( + &mut subscription, + Request::Subscribe { + id: terminal.id, + offset: 0, + attachment_id: "blocked-controller".into(), + role: AttachmentRole::Controller, + takeover: false, + } + ), + Response::Attached { .. } + )); + let mut partial = daemon.connect(); + partial.write_all(&[0, 0]).unwrap(); + thread::sleep(Duration::from_millis(200)); + drop(owner); + daemon.wait(); + assert_terminal_stopped(&terminal); +} + +#[test] +fn subscription_delivers_final_exit_frame_before_closing() { + use opencode_pty::protocol::{SubscriptionEvent, read_subscription_event}; + + let mut daemon = Daemon::start(); + let (owner, response) = daemon.own(None); + assert!(matches!(response, Response::Owned)); + let terminal = daemon.terminal("read line; printf final-output"); + let mut subscription = daemon.connect(); + assert!(matches!( + daemon.send( + &mut subscription, + Request::Subscribe { + id: terminal.id, + offset: 0, + attachment_id: "final-observer".into(), + role: AttachmentRole::Observer, + takeover: false, + } + ), + Response::Attached { .. } + )); + assert!(matches!( + daemon.request(Request::Write { + id: terminal.id, + attachment_id: None, + data_base64: base64::engine::general_purpose::STANDARD.encode(b"finish\n"), + }), + Response::Ok + )); + let mut output = Vec::new(); + loop { + match read_subscription_event(&mut subscription).unwrap() { + SubscriptionEvent::Output { bytes, .. } => output.extend(bytes), + SubscriptionEvent::Response(response) => match *response { + Response::Exited { + exit_code, + final_offset, + } => { + assert_eq!(exit_code, Some(0)); + assert_eq!(final_offset, output.len() as u64); + break; + } + Response::Error { message } => panic!("subscription error: {message}"), + _ => {} + }, + } + } + assert!(String::from_utf8_lossy(&output).contains("final-output")); + drop(subscription); + drop(owner); + daemon.wait(); +} + +#[test] +fn handoff_preserves_daemon_and_terminal_and_shutdown_overrides_it() { + let mut daemon = Daemon::start(); + let (mut owner, response) = daemon.own(None); + assert!(matches!(response, Response::Owned)); + let terminal = daemon.terminal("exec cat"); + assert!(matches!( + daemon.request(Request::PrepareHandoff), + Response::Error { .. } + )); + let before = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_millis() as u64; + let Response::Handoff { ticket, expires_at } = daemon.send(&mut owner, Request::PrepareHandoff) + else { + panic!("handoff response"); + }; + assert!((before + 120_000..=before + 121_000).contains(&expires_at)); + assert!( + matches!(daemon.send(&mut owner, Request::PrepareHandoff), Response::Handoff { + ticket: repeated, expires_at: deadline, + } if repeated == ticket && deadline == expires_at) + ); + assert!(matches!( + daemon.own(Some("wrong-ticket".into())).1, + Response::Error { .. } + )); + // The old owner stays connected until the successor has acquired the daemon. + let (mut successor, response) = daemon.own(Some(ticket.clone())); + assert!(matches!(response, Response::Owned)); + assert!(matches!(daemon.own(Some(ticket)).1, Response::Error { .. })); + drop(owner); + + let deadline = Instant::now() + Duration::from_secs(3); + assert!( + matches!(daemon.request(Request::Ping), Response::Pong { instance_id, pid, .. } + if instance_id == daemon.registration.instance_id && pid == daemon.child.id()) + ); + assert!( + matches!(daemon.request(Request::List), Response::Terminals { terminals } + if terminals.len() == 1 && terminals[0].id == terminal.id && terminals[0].pid == terminal.pid) + ); + assert!(matches!( + daemon.request(Request::Write { + id: terminal.id, + attachment_id: None, + data_base64: base64::engine::general_purpose::STANDARD.encode(b"after-handoff\n"), + }), + Response::Ok + )); + loop { + if matches!(daemon.request(Request::Snapshot { id: terminal.id }), Response::Snapshot { text, .. } + if text.contains("after-handoff")) + { + break; + } + assert!( + Instant::now() < deadline, + "terminal stopped responding after handoff" + ); + thread::sleep(Duration::from_millis(10)); + } + assert!(matches!( + daemon.send(&mut successor, Request::PrepareHandoff), + Response::Handoff { .. } + )); + assert!(matches!(daemon.request(Request::Shutdown), Response::Ok)); + daemon.wait(); + assert_terminal_stopped(&terminal); +} + +#[test] +fn superseded_owner_cannot_prepare_handoff_or_shutdown() { + for request in [Request::PrepareHandoff, Request::Shutdown] { + let mut daemon = Daemon::start(); + let (mut owner, response) = daemon.own(None); + assert!(matches!(response, Response::Owned)); + let Response::Handoff { ticket, .. } = daemon.send(&mut owner, Request::PrepareHandoff) + else { + panic!("handoff response"); + }; + let (mut successor, response) = daemon.own(Some(ticket)); + assert!(matches!(response, Response::Owned)); + assert!(matches!( + daemon.send(&mut owner, request), + Response::Error { message } if message == "owner connection has been superseded" + )); + // EOF confirms the stale handler has run its disconnect cleanup. + assert_eq!(owner.read(&mut [0]).unwrap(), 0); + assert!(matches!( + daemon.request(Request::Ping), + Response::Pong { .. } + )); + assert!(matches!( + daemon.send(&mut successor, Request::PrepareHandoff), + Response::Handoff { .. } + )); + assert!(matches!( + daemon.send(&mut successor, Request::Shutdown), + Response::Ok + )); + daemon.wait(); + } +} + +#[test] +fn handoff_ticket_has_only_one_winner() { + let mut daemon = Daemon::start(); + let (mut owner, response) = daemon.own(None); + assert!(matches!(response, Response::Owned)); + let Response::Handoff { ticket, .. } = daemon.send(&mut owner, Request::PrepareHandoff) else { + panic!("handoff response"); + }; + let barrier = Barrier::new(2); + let results = thread::scope(|scope| { + let claim = || { + barrier.wait(); + daemon.own(Some(ticket.clone())) + }; + let first = scope.spawn(claim); + let second = scope.spawn(claim); + [first.join().unwrap(), second.join().unwrap()] + }); + assert_eq!( + results + .iter() + .filter(|(_, response)| matches!(response, Response::Owned)) + .count(), + 1 + ); + assert_eq!( + results + .iter() + .filter(|(_, response)| matches!(response, Response::Error { .. })) + .count(), + 1 + ); + // Losing the new owner must stop the daemon even if the old socket is still open. + drop(results); + daemon.wait(); + assert_eq!(owner.read(&mut [0]).unwrap(), 0); +} + +#[test] +fn unclaimed_daemon_times_out() { + let mut daemon = Daemon::start(); + let _partial = daemon.connect(); + daemon.wait(); +} + +#[test] +fn shutdown_keeps_a_replaced_socket() { + let mut daemon = Daemon::start(); + let (mut owner, response) = daemon.own(None); + assert!(matches!(response, Response::Owned)); + // A successor bound the same path; the exiting daemon must not unlink it. + std::fs::remove_file(&daemon.registration.socket).unwrap(); + let replacement = UnixListener::bind(&daemon.registration.socket).unwrap(); + assert!(matches!( + daemon.send(&mut owner, Request::Shutdown), + Response::Ok + )); + daemon.exit(); + assert!(daemon.registration.socket.exists()); + drop(replacement); + std::fs::remove_file(&daemon.registration.socket).unwrap(); +} diff --git a/packages/process-daemon/tests/playground.rs b/packages/process-daemon/tests/playground.rs new file mode 100644 index 000000000000..bcc404d59d2e --- /dev/null +++ b/packages/process-daemon/tests/playground.rs @@ -0,0 +1,220 @@ +#![cfg(unix)] + +use std::ffi::OsStr; +use std::io::{BufRead, BufReader, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; +use std::time::{SystemTime, UNIX_EPOCH}; + +fn runtime_root(name: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "opencode-pty-test-{name}-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos() + )) +} + +fn target(root: &Path) -> [&OsStr; 4] { + [ + OsStr::new("--name"), + OsStr::new("play"), + OsStr::new("--runtime-dir"), + root.as_os_str(), + ] +} + +fn output_with_timeout(mut child: std::process::Child) -> std::process::Output { + let deadline = Instant::now() + Duration::from_secs(10); + while Instant::now() < deadline { + if child.try_wait().expect("child status").is_some() { + return child.wait_with_output().expect("child output"); + } + std::thread::sleep(Duration::from_millis(10)); + } + let _ = child.kill(); + let output = child.wait_with_output().expect("timed out child output"); + panic!( + "child timed out\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + +fn created_terminal_id(output: &[u8]) -> String { + let output = String::from_utf8_lossy(output); + let marker = "created terminal "; + let start = output.find(marker).expect("created terminal output") + marker.len(); + output[start..] + .split_whitespace() + .next() + .expect("terminal ID") + .to_string() +} + +fn read_created_terminal(child: &mut std::process::Child) -> String { + let mut stdout = BufReader::new(child.stdout.as_mut().expect("stdout")); + loop { + let mut line = String::new(); + assert_ne!(stdout.read_line(&mut line).expect("playground output"), 0); + if line.contains("created terminal ") { + return line; + } + } +} + +#[test] +fn playground_proves_authoritative_query_response() { + let root = runtime_root("query"); + let runtime = root.join("play"); + let mut child = Command::new(env!("CARGO_BIN_EXE_opencode-pty")) + .arg("play") + .args(target(&root)) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .expect("playground starts"); + child + .stdin + .as_mut() + .expect("stdin") + .write_all(b"demo\nwait 50\nquit\n") + .expect("commands written"); + let output = output_with_timeout(child); + assert!(!runtime.join("service.json").exists()); + assert!(!runtime.exists(), "runtime directory was not removed"); + std::fs::remove_dir_all(&root).unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("QUERY_RESPONSE_OK"), "{stdout}"); +} + +#[test] +fn playground_exit_stops_all_terminals_and_observers_do_not_start_daemons() { + let root = runtime_root("ownership"); + let runtime = root.join("play"); + let mut owner = Command::new(env!("CARGO_BIN_EXE_opencode-pty")) + .arg("play") + .args(target(&root)) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .expect("playground starts"); + let pids = (0..2) + .map(|_| { + owner + .stdin + .as_mut() + .unwrap() + .write_all(b"new /bin/cat\n") + .unwrap(); + let line = read_created_terminal(&mut owner); + line.split_once("Some(") + .unwrap() + .1 + .trim_end() + .trim_end_matches("))") + .parse::() + .unwrap() + }) + .collect::>(); + + let second = Command::new(env!("CARGO_BIN_EXE_opencode-pty")) + .arg("play") + .args(target(&root)) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map(output_with_timeout) + .unwrap(); + assert!( + !second.status.success(), + "second playground must not adopt the live daemon" + ); + for command in ["list", "status"] { + assert!( + Command::new(env!("CARGO_BIN_EXE_opencode-pty")) + .arg(command) + .args(target(&root)) + .output() + .unwrap() + .status + .success() + ); + } + owner.stdin.as_mut().unwrap().write_all(b"quit\n").unwrap(); + assert!(output_with_timeout(owner).status.success()); + assert!(!runtime.join("service.json").exists()); + for pid in pids { + // SAFETY: signal zero only checks whether the terminal PID still exists. + assert_eq!(unsafe { libc::kill(pid, 0) }, -1); + assert_eq!( + std::io::Error::last_os_error().raw_os_error(), + Some(libc::ESRCH) + ); + } + for args in [&["list"][..], &["status"], &["watch", "1"]] { + assert!( + !Command::new(env!("CARGO_BIN_EXE_opencode-pty")) + .args(args) + .args(target(&root)) + .output() + .unwrap() + .status + .success() + ); + assert!(!runtime.join("service.json").exists()); + } + assert!(!runtime.exists(), "runtime directory was not removed"); + std::fs::remove_dir_all(&root).unwrap(); +} + +#[test] +fn observer_stream_replays_and_follows_until_exit() { + let root = runtime_root("stream"); + let runtime = root.join("play"); + let mut owner = Command::new(env!("CARGO_BIN_EXE_opencode-pty")) + .arg("play") + .args(target(&root)) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .expect("playground starts"); + owner + .stdin + .as_mut() + .expect("stdin") + .write_all(b"new /bin/sh -c \"printf watched; sleep 2; printf followed\"\n") + .unwrap(); + let terminal_id = created_terminal_id(read_created_terminal(&mut owner).as_bytes()); + + let watched = Command::new(env!("CARGO_BIN_EXE_opencode-pty")) + .args(["watch", &terminal_id]) + .args(target(&root)) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map(output_with_timeout) + .expect("observer exits with terminal"); + owner.stdin.as_mut().unwrap().write_all(b"quit\n").unwrap(); + assert!(output_with_timeout(owner).status.success()); + assert!(!runtime.join("service.json").exists()); + assert!(!runtime.exists(), "runtime directory was not removed"); + std::fs::remove_dir_all(&root).unwrap(); + assert!( + watched.status.success(), + "watch failed with {:?}: {}", + watched.status.code(), + String::from_utf8_lossy(&watched.stderr) + ); + assert!(String::from_utf8_lossy(&watched.stdout).contains("watched")); + assert!(String::from_utf8_lossy(&watched.stdout).contains("followed")); +} diff --git a/packages/process-daemon/tests/rows.rs b/packages/process-daemon/tests/rows.rs new file mode 100644 index 000000000000..1e222530b743 --- /dev/null +++ b/packages/process-daemon/tests/rows.rs @@ -0,0 +1,124 @@ +#![cfg(unix)] + +use std::process::{Command, Stdio}; +use std::thread; +use std::time::{Duration, Instant}; + +use opencode_pty::client::TerminalClient; +use opencode_pty::daemon::{REGISTRATION_FILE, Registration}; +use opencode_pty::protocol::{Envelope, Request, Response, read_frame, write_frame}; +use opencode_pty::service::CreateTerminal; + +#[test] +fn daemon_client_read_rows_roundtrip() { + // Run the Rust client in a child test process so discovery's environment is + // isolated without mutating this multithreaded test process's environment. + if let Some(directory) = std::env::var_os("OPENCODE_PTY_ROWS_TEST_CHILD") { + let directory = std::path::PathBuf::from(directory); + let client = TerminalClient::discover(&directory).unwrap(); + let mut request = CreateTerminal::shell().unwrap(); + request.program = "/bin/sh".to_string(); + request.args = vec!["-c".to_string(), "printf 'one\ntwo\n\nfour\n'".to_string()]; + request.cols = 10; + request.rows = 3; + let terminal = client.create(request).unwrap(); + let deadline = Instant::now() + Duration::from_secs(5); + loop { + let rows = client.read_rows(terminal.id, None).unwrap(); + if rows.lines == ["", "four", ""] { + assert_eq!((rows.terminal.cols, rows.terminal.rows), (10, 3)); + assert_eq!((rows.cursor_x, rows.cursor_y), (0, 2)); + break; + } + assert!(Instant::now() < deadline, "terminal output not received"); + thread::sleep(Duration::from_millis(10)); + } + assert_eq!(client.read_rows(terminal.id, Some(1)).unwrap().lines, [""]); + assert_eq!( + client.read_rows(terminal.id, Some(5)).unwrap().lines, + ["one", "two", "", "four", ""] + ); + assert!( + client + .read_rows(terminal.id, Some(0)) + .unwrap_err() + .to_string() + .contains("positive") + ); + + let registration = opencode_pty::daemon::read_registration(&directory).unwrap(); + assert_eq!(registration.protocol, 7); + // Exercise omitted rows independently of the Rust client's null encoding. + let mut stream = std::os::unix::net::UnixStream::connect(registration.socket).unwrap(); + write_frame( + &mut stream, + &serde_json::json!({ + "token": registration.token, + "request": { "op": "read_rows", "id": terminal.id } + }), + ) + .unwrap(); + let response: serde_json::Value = read_frame(&mut stream).unwrap(); + assert_eq!(response["type"], "rows"); + assert_eq!(response["lines"], serde_json::json!(["", "four", ""])); + assert_eq!(response["terminal"]["id"], terminal.id); + assert_eq!(response["cursor_y"], 2); + client.resize(terminal.id, 12, 4).unwrap(); + let rows = client.read_rows(terminal.id, None).unwrap(); + assert_eq!(rows.lines, ["two", "", "four", ""]); + assert_eq!((rows.terminal.cols, rows.terminal.rows), (12, 4)); + client.terminate(terminal.id).unwrap(); + return; + } + + let root = + std::env::temp_dir().join(format!("opencode-pty-rows-{:032x}", rand::random::())); + let runtime = root.join("rows"); + let mut daemon = Command::new(env!("CARGO_BIN_EXE_opencode-pty")) + .args(["daemon", "--name", "rows", "--runtime-dir"]) + .arg(&root) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .spawn() + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(10); + while !runtime.join(REGISTRATION_FILE).exists() && Instant::now() < deadline { + thread::sleep(Duration::from_millis(10)); + } + let registration: Registration = + serde_json::from_slice(&std::fs::read(runtime.join(REGISTRATION_FILE)).unwrap()).unwrap(); + let mut owner = std::os::unix::net::UnixStream::connect(®istration.socket).unwrap(); + write_frame( + &mut owner, + &Envelope { + token: registration.token, + request: Request::Own { + instance_id: registration.instance_id, + ticket: None, + }, + }, + ) + .unwrap(); + assert!(matches!( + read_frame::(&mut owner).unwrap(), + Response::Owned + )); + let result = Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "daemon_client_read_rows_roundtrip", + "--nocapture", + ]) + .env("OPENCODE_PTY_ROWS_TEST_CHILD", &runtime) + .output(); + drop(owner); + assert!(daemon.wait().unwrap().success()); + let _ = std::fs::remove_dir_all(root); + let output = result.unwrap(); + assert!( + output.status.success(), + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} diff --git a/packages/process-daemon/tests/runtime.rs b/packages/process-daemon/tests/runtime.rs new file mode 100644 index 000000000000..27eab0d70c25 --- /dev/null +++ b/packages/process-daemon/tests/runtime.rs @@ -0,0 +1,500 @@ +//! Native Unix PTY / Windows ConPTY coverage, without daemon or client transport. + +#[path = "support/terminal_fixture.rs"] +mod terminal_fixture; + +use std::thread; +use std::time::{Duration, Instant}; + +use opencode_pty::protocol::AttachmentRole; +use opencode_pty::service::{ + StreamEvent, TerminalId, TerminalLifecycle, TerminalService, TerminalSnapshot, +}; +use terminal_fixture::{Command, Deadline, Fixture, TempDir}; + +fn wait_text(service: &TerminalService, id: TerminalId, expected: &str) -> TerminalSnapshot { + let deadline = Instant::now() + Duration::from_secs(15); + loop { + let snapshot = service.snapshot(id).unwrap(); + if snapshot.text.contains(expected) { + return snapshot; + } + assert!( + Instant::now() < deadline, + "missing {expected:?}: {snapshot:?}" + ); + thread::sleep(Duration::from_millis(10)); + } +} + +#[test] +fn real_child_input_output_unicode_and_snapshots() { + let _deadline = Deadline::new(); + let fixture = Fixture::new(); + let service = TerminalService::default(); + let info = service.create(fixture.request()).unwrap(); + assert!(info.pid.is_some_and(|pid| pid != 0)); + let mut child = fixture.connect(); + child.command(Command::Output( + "\x1b[2J\x1b[Hplain café 界 🙂\r\nREADY".into(), + )); + let snapshot = wait_text(&service, info.id, "READY"); + assert!(snapshot.text.contains("plain café 界 🙂"), "{snapshot:?}"); + assert!(!snapshot.checkpoint.is_empty()); + let rows = service.read_rows(info.id, None).unwrap(); + assert!(rows.lines.iter().any(|line| line == "plain café 界 🙂")); + assert_eq!((rows.cursor_x, rows.cursor_y), (5, 1)); + let input = "typed café 界 🙂\r".as_bytes(); + service.write(info.id, input.to_vec()).unwrap(); + assert_eq!( + child.command(Command::Read(input.len())), + serde_json::json!(input) + ); + let replay = service.replay(info.id, 0).unwrap(); + assert!(!replay.truncated); + // ConPTY may repaint after the earlier snapshot, so offsets only grow. + assert!(replay.end_offset >= snapshot.info.output_tail); + assert_eq!(replay.bytes.len() as u64, replay.end_offset); + assert!( + service + .replay(info.id, replay.end_offset) + .unwrap() + .bytes + .is_empty() + ); + service.terminate(info.id).unwrap(); +} + +#[test] +fn child_cwd_environment_and_quoted_program_path() { + let _deadline = Deadline::new(); + let directory = TempDir::new(); + let fixture = Fixture::new(); + let service = TerminalService::default(); + let executable = directory.executable(if cfg!(windows) { + "child 界.exe" + } else { + "child 界" + }); + let mut request = fixture.request(); + request.program = executable.to_str().unwrap().to_owned(); + request.cwd = directory.0.clone(); + // Additional exact test filters exercise argv quoting, including an empty + // argument and backslashes immediately preceding a closing quote. + request + .args + .extend(["with spaces", "a\"quote", "trailing slash \\", "", "界"].map(str::to_owned)); + request + .env + .insert("PTY_FIXTURE_VALUE".into(), "value café 界 = ok".into()); + let expected_args = request.args.clone(); + let info = service.create(request).unwrap(); + let mut child = fixture.connect(); + let context = child.command(Command::Context); + assert_eq!( + std::fs::canonicalize(context["cwd"].as_str().unwrap()).unwrap(), + std::fs::canonicalize(&directory.0).unwrap() + ); + assert_eq!(context["args"], serde_json::json!(expected_args)); + assert_eq!(context["value"], "value café 界 = ok"); + assert_eq!(context["term"], "xterm-256color"); + assert_eq!(context["colorterm"], "truecolor"); + service.terminate(info.id).unwrap(); +} + +#[test] +fn executable_is_resolved_from_child_path() { + let _deadline = Deadline::new(); + let directory = TempDir::new(); + let fixture = Fixture::new(); + let service = TerminalService::default(); + let name = if cfg!(windows) { + "pty-path-fixture.exe" + } else { + "pty-path-fixture" + }; + directory.executable(name); + let mut request = fixture.request(); + request.program = name.into(); + request + .env + .insert("PATH".into(), directory.0.to_str().unwrap().into()); + let info = service.create(request).unwrap(); + let mut child = fixture.connect(); + child.command(Command::Output("\r\nPATH_OK".into())); + wait_text(&service, info.id, "PATH_OK"); + service.terminate(info.id).unwrap(); +} + +#[test] +fn resize_updates_real_console_and_parser() { + let _deadline = Deadline::new(); + let fixture = Fixture::new(); + let service = TerminalService::default(); + let info = service.create(fixture.request()).unwrap(); + let mut child = fixture.connect(); + assert_eq!(child.command(Command::Size), serde_json::json!([80, 24])); + let observer = service + .attach( + info.id, + 0, + "resize-observer".into(), + AttachmentRole::Observer, + false, + ) + .unwrap(); + for (cols, rows) in [(100, 40), (60, 18)] { + service.resize(info.id, cols, rows).unwrap(); + // ConPTY applies a resize asynchronously in conhost, so the child can + // briefly observe the old size after `resize` returns. + let expected = serde_json::json!([cols, rows]); + let deadline = Instant::now() + Duration::from_secs(15); + loop { + let size = child.command(Command::Size); + if size == expected { + break; + } + assert!(Instant::now() < deadline, "console size stayed {size}"); + thread::sleep(Duration::from_millis(10)); + } + let snapshot = service.snapshot(info.id).unwrap(); + assert_eq!((snapshot.info.cols, snapshot.info.rows), (cols, rows)); + assert_eq!( + service.read_rows(info.id, None).unwrap().lines.len(), + rows as usize + ); + loop { + if let StreamEvent::Resized { + cols: actual_cols, + rows: actual_rows, + checkpoint, + .. + } = observer + .events + .recv_timeout(Duration::from_secs(15)) + .unwrap() + { + assert_eq!((actual_cols, actual_rows), (cols, rows)); + assert!(!checkpoint.is_empty()); + break; + } + } + } + drop(observer); + service.terminate(info.id).unwrap(); +} + +#[test] +fn terminal_query_response_reaches_child() { + let _deadline = Deadline::new(); + let fixture = Fixture::new(); + let service = TerminalService::default(); + let info = service.create(fixture.request()).unwrap(); + let mut child = fixture.connect(); + child.command(Command::Output("\x1b[2J\x1b[H\x1b[3;7H\x1b[6n".into())); + let response = b"\x1b[3;7R"; + assert_eq!( + child.command(Command::Read(response.len())), + serde_json::json!(response) + ); + service.write(info.id, b"!".to_vec()).unwrap(); + assert_eq!(child.command(Command::Read(1)), serde_json::json!(b"!")); + // ConPTY answers application DSR itself; on Unix, Ghostty answers it. With + // cursor inheritance enabled, ConPTY's own startup DSR still appears in the + // output stream, so this checks the query reaches the reader and parser. + // It does not prove a reply was written back on Windows. + let replay = service.replay(info.id, 0).unwrap(); + assert!( + replay.bytes.windows(4).any(|bytes| bytes == b"\x1b[6n"), + "no terminal query in {replay:?}" + ); + service.terminate(info.id).unwrap(); +} + +#[test] +fn independent_terminals_keep_bounded_replay() { + let _deadline = Deadline::new(); + let alpha_fixture = Fixture::new(); + let beta_fixture = Fixture::new(); + let service = TerminalService::new(64); + let alpha = service.create(alpha_fixture.request()).unwrap(); + let beta = service.create(beta_fixture.request()).unwrap(); + assert_ne!(alpha.id, beta.id); + let mut a = alpha_fixture.connect(); + let mut b = beta_fixture.connect(); + a.command(Command::Output(format!( + "\x1b[2J\x1b[H{}\r\nALPHA", + "a".repeat(160) + ))); + b.command(Command::Output("\x1b[2J\x1b[HBETA".into())); + let snapshot = wait_text(&service, alpha.id, "ALPHA"); + assert!(!snapshot.text.contains("BETA")); + assert!(!wait_text(&service, beta.id, "BETA").text.contains("ALPHA")); + let replay = service.replay(alpha.id, 0).unwrap(); + assert!(replay.truncated); + assert_eq!(replay.bytes.len(), 64); + assert_eq!(replay.end_offset - replay.available_offset, 64); + service.terminate(alpha.id).unwrap(); + service.write(beta.id, b"still-alive".to_vec()).unwrap(); + assert_eq!( + b.command(Command::Read(11)), + serde_json::json!(b"still-alive") + ); + service.terminate(beta.id).unwrap(); + assert!(service.list().unwrap().is_empty()); +} + +#[test] +fn normal_exit_follows_final_output_and_preserves_exit_code() { + check_normal_exit(23); +} + +#[test] +#[cfg(windows)] +fn windows_exit_code_259_is_not_mistaken_for_a_running_child() { + check_normal_exit(259); +} + +fn check_normal_exit(code: u32) { + let _deadline = Deadline::new(); + let fixture = Fixture::new(); + let service = TerminalService::default(); + let info = service.create(fixture.request()).unwrap(); + let mut child = fixture.connect(); + let observer = service + .attach( + info.id, + 0, + "exit-observer".into(), + AttachmentRole::Observer, + false, + ) + .unwrap(); + let mut bytes = observer.replay.bytes.clone(); + let mut offset = observer.replay.end_offset; + child.command(Command::Output("\r\nFINAL_OUTPUT_BEFORE_EXIT\r\n".into())); + child.command(Command::Exit(code as i32)); + loop { + match observer + .events + .recv_timeout(Duration::from_secs(15)) + .unwrap_or_else(|error| { + panic!( + "missing exit event: {error}; snapshot={:?}", + service.snapshot(info.id) + ) + }) { + StreamEvent::Output { + start, + end, + bytes: output, + } => { + assert_eq!(start, offset); + assert_eq!(end - start, output.len() as u64); + bytes.extend(output); + offset = end; + } + StreamEvent::Exited { + exit_code, + final_offset, + } => { + assert_eq!(exit_code, Some(code)); + assert_eq!(final_offset, offset); + break; + } + _ => {} + } + } + assert_eq!(service.replay(info.id, 0).unwrap().bytes, bytes); + let snapshot = service.snapshot(info.id).unwrap(); + assert!( + snapshot.text.contains("FINAL_OUTPUT_BEFORE_EXIT"), + "{snapshot:?}" + ); + assert_eq!( + snapshot.info.lifecycle, + TerminalLifecycle::Exited { + exit_code: Some(code) + } + ); + assert_eq!(snapshot.info.output_tail, offset); + assert!(observer.events.try_recv().is_err()); + #[cfg(windows)] + { + assert!( + service + .resize(info.id, 100, 40) + .unwrap_err() + .to_string() + .contains("child has exited") + ); + assert!(service.write(info.id, b"late input".to_vec()).is_err()); + assert_eq!( + service.snapshot(info.id).unwrap().info.lifecycle, + snapshot.info.lifecycle + ); + assert!( + service + .read_rows(info.id, None) + .unwrap() + .lines + .iter() + .any(|line| line.contains("FINAL_OUTPUT_BEFORE_EXIT")) + ); + } + drop(observer); + service.terminate(info.id).unwrap(); +} + +#[test] +fn repeated_termination_and_drop_release_children() { + termination_cycles(false); +} + +// Linux has a pre-existing portable-pty blocking-write hang after child exit; +// that repro is retained outside this Windows cleanup suite, not claimed fixed. +#[test] +#[cfg(windows)] +fn repeated_termination_and_drop_release_nonreading_children() { + termination_cycles(true); +} + +fn termination_cycles(block_input: bool) { + let _deadline = Deadline::new(); + let service = TerminalService::default(); + for _ in 0..3 { + let fixture = Fixture::new(); + let info = service.create(fixture.request()).unwrap(); + let _child = fixture.connect(); + let process = ChildProcess::open(info.pid.unwrap()); + // Child waits on the private control channel, never draining PTY stdin. + if block_input { + service.write(info.id, vec![b'x'; 256 * 1024]).unwrap(); + } + service.terminate(info.id).unwrap(); + process.assert_exited(); + assert!(service.list().unwrap().is_empty()); + assert!(service.terminate(info.id).is_err()); + } + let fixture = Fixture::new(); + let info = service.create(fixture.request()).unwrap(); + let _child = fixture.connect(); + let process = ChildProcess::open(info.pid.unwrap()); + if block_input { + service.write(info.id, vec![b'x'; 256 * 1024]).unwrap(); + } + drop(service); + process.assert_exited(); +} + +struct ChildProcess { + #[cfg(unix)] + pid: u32, + #[cfg(windows)] + handle: std::os::windows::io::OwnedHandle, +} + +#[test] +#[cfg(windows)] +fn terminating_a_shell_releases_its_console_child() { + use base64::Engine; + + let _deadline = Deadline::new(); + let fixture = Fixture::new(); + let service = TerminalService::default(); + let mut request = fixture.request(); + request + .env + .insert("PTY_FIXTURE_EXE".into(), request.program); + request.program = std::path::PathBuf::from(std::env::var_os("SystemRoot").unwrap()) + .join("System32/WindowsPowerShell/v1.0/powershell.exe") + .to_str() + .unwrap() + .to_owned(); + // EncodedCommand avoids imposing cmd.exe's distinct quoting rules on the + // portable-pty CommandBuilder's normal Windows argv quoting. + let script = "& $env:PTY_FIXTURE_EXE --ignored --exact terminal_fixture::child --nocapture --test-threads=1; exit $LASTEXITCODE"; + let script = script + .encode_utf16() + .flat_map(u16::to_le_bytes) + .collect::>(); + request.args = vec![ + "-NoLogo".into(), + "-NoProfile".into(), + "-NonInteractive".into(), + "-EncodedCommand".into(), + base64::engine::general_purpose::STANDARD.encode(script), + ]; + let info = service.create(request).unwrap(); + let root = ChildProcess::open(info.pid.unwrap()); + let mut child = fixture.connect(); + let pid = u32::try_from(child.command(Command::Context)["pid"].as_u64().unwrap()).unwrap(); + assert_ne!(pid, info.pid.unwrap()); + let descendant = ChildProcess::open(pid); + child.command(Command::Output("\r\nSHELL_DESCENDANT_READY".into())); + wait_text(&service, info.id, "SHELL_DESCENDANT_READY"); + service.terminate(info.id).unwrap(); + root.assert_exited(); + descendant.assert_exited(); +} + +impl ChildProcess { + fn open(pid: u32) -> Self { + #[cfg(unix)] + { + Self { pid } + } + #[cfg(windows)] + { + use std::os::windows::io::FromRawHandle; + use windows_sys::Win32::System::Threading::{OpenProcess, PROCESS_SYNCHRONIZE}; + // SAFETY: request only wait access to this known-live test child. + let handle = unsafe { OpenProcess(PROCESS_SYNCHRONIZE, 0, pid) }; + assert!(!handle.is_null(), "{}", std::io::Error::last_os_error()); + Self { + handle: unsafe { std::os::windows::io::OwnedHandle::from_raw_handle(handle) }, + } + } + } + + fn assert_exited(&self) { + #[cfg(unix)] + { + // SAFETY: signal zero checks liveness without signalling a process. + assert_eq!(unsafe { libc::kill(self.pid as i32, 0) }, -1); + assert_eq!( + std::io::Error::last_os_error().raw_os_error(), + Some(libc::ESRCH) + ); + } + #[cfg(windows)] + { + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::{ + Foundation::WAIT_OBJECT_0, System::Threading::WaitForSingleObject, + }; + // Descendants exit asynchronously once their console closes, so allow + // a bounded wait. The root is already signaled when terminate returns. + // SAFETY: the owned process handle has wait access and stays live. + assert_eq!( + unsafe { WaitForSingleObject(self.handle.as_raw_handle(), 5000) }, + WAIT_OBJECT_0 + ); + } + } +} + +#[test] +#[cfg(windows)] +fn shutdown_drains_conpty_while_a_child_is_producing_output() { + let _deadline = Deadline::new(); + let fixture = Fixture::new(); + let service = TerminalService::default(); + let info = service.create(fixture.request()).unwrap(); + let mut child = fixture.connect(); + let process = ChildProcess::open(info.pid.unwrap()); + child.command(Command::Flood); + wait_text(&service, info.id, "FLOOD_OUTPUT"); + drop(service); + process.assert_exited(); +} diff --git a/packages/process-daemon/tests/service.rs b/packages/process-daemon/tests/service.rs new file mode 100644 index 000000000000..6fbac909d607 --- /dev/null +++ b/packages/process-daemon/tests/service.rs @@ -0,0 +1,467 @@ +#![cfg(unix)] + +#[path = "support/resize_fixture.rs"] +mod resize_fixture; + +use std::env; +use std::thread; +use std::time::{Duration, Instant}; + +use opencode_pty::service::{CreateTerminal, TerminalLifecycle, TerminalService}; +use opencode_pty::{protocol::AttachmentRole, service::StreamEvent}; +use resize_fixture::ResizeFixture; + +#[test] +fn resize_reports_reach_a_silent_child() { + let mut fixture = ResizeFixture::new("resize_reports_reach_a_silent_child"); + fixture.service.resize(fixture.id, 100, 40).unwrap(); + fixture.expect_input(b"\x1b[48;40;100;0;0t"); +} + +#[test] +fn control_resize_reports_precede_user_input() { + let mut fixture = ResizeFixture::new("control_resize_reports_precede_user_input"); + let _controller = fixture + .service + .attach( + fixture.id, + 0, + "controller".into(), + AttachmentRole::Controller, + false, + ) + .unwrap(); + let _observer = fixture + .service + .attach( + fixture.id, + 0, + "observer".into(), + AttachmentRole::Observer, + false, + ) + .unwrap(); + fixture + .service + .input(fixture.id, "observer".into(), 120, 50, b"typed".to_vec()) + .unwrap(); + fixture.expect_input(b"\x1b[48;50;120;0;0ttyped"); +} + +#[test] +fn repeated_resize_reports_are_delivered_once() { + let mut fixture = ResizeFixture::new("repeated_resize_reports_are_delivered_once"); + for (cols, rows) in [(30, 10), (31, 11), (32, 12)] { + fixture.service.resize(fixture.id, cols, rows).unwrap(); + fixture.expect_input(format!("\x1b[48;{rows};{cols};0;0t").as_bytes()); + } + fixture.service.write(fixture.id, b"!".to_vec()).unwrap(); + fixture.expect_input(b"!\x1b[0n"); +} + +fn command(script: &str) -> CreateTerminal { + CreateTerminal { + program: "/bin/sh".to_string(), + args: vec!["-c".to_string(), script.to_string()], + cwd: env::current_dir().expect("cwd"), + title: "test".to_string(), + group_id: "test".to_string(), + env: std::collections::HashMap::new(), + cols: 80, + rows: 24, + } +} + +fn wait_for(mut condition: impl FnMut() -> bool) { + let deadline = Instant::now() + Duration::from_secs(5); + while Instant::now() < deadline { + if condition() { + return; + } + thread::sleep(Duration::from_millis(10)); + } + panic!("condition did not become true"); +} + +#[test] +fn read_rows_snapshots_live_state_without_taking_control() { + let service = TerminalService::default(); + let mut request = + command(r"printf '\033]2;rows-title\007one\ntwo\nthree\nfour\nfive'; sleep 1"); + request.cols = 10; + request.rows = 3; + let terminal = service.create(request).expect("terminal created"); + wait_for(|| { + service + .read_rows(terminal.id, None) + .is_ok_and(|rows| rows.lines == ["three", "four", "five"]) + }); + let controller = service + .attach( + terminal.id, + 0, + "controller".to_string(), + AttachmentRole::Controller, + false, + ) + .unwrap(); + let snapshot = service.snapshot(terminal.id).unwrap(); + let replay = service.replay(terminal.id, 0).unwrap(); + let rows = service.read_rows(terminal.id, Some(u16::MAX)).unwrap(); + assert_eq!(rows.lines, ["one", "two", "three", "four", "five"]); + assert_eq!(rows.terminal.id, terminal.id); + assert_eq!(rows.terminal.title, "rows-title"); + assert_eq!((rows.terminal.cols, rows.terminal.rows), (10, 3)); + assert_eq!((rows.cursor_x, rows.cursor_y), (4, 2)); + assert_eq!(rows.terminal.output_tail, replay.end_offset); + assert_eq!( + service.snapshot(terminal.id).unwrap().checkpoint, + snapshot.checkpoint + ); + assert_eq!(service.replay(terminal.id, 0).unwrap().bytes, replay.bytes); + assert!(service.resize(terminal.id, 12, 4).is_err()); + service + .resize_for(terminal.id, Some("controller".to_string()), 12, 4) + .unwrap(); + let rows = service.read_rows(terminal.id, None).unwrap(); + assert_eq!(rows.lines, ["two", "three", "four", "five"]); + assert_eq!((rows.terminal.cols, rows.terminal.rows), (12, 4)); + assert_eq!((rows.cursor_x, rows.cursor_y), (4, 3)); + assert!( + service + .read_rows(terminal.id, Some(0)) + .unwrap_err() + .to_string() + .contains("positive") + ); + drop(controller); + service.terminate(terminal.id).unwrap(); + assert!(service.read_rows(terminal.id, None).is_err()); +} + +#[test] +fn terminals_run_independently_and_one_exit_does_not_stop_service() { + let service = TerminalService::new(64 * 1024); + let alpha = service + .create(command("printf 'alpha\\n'; sleep 0.05")) + .expect("alpha created"); + let beta = service + .create(command("printf 'beta\\n'; sleep 0.2")) + .expect("beta created"); + + wait_for(|| { + service + .snapshot(alpha.id) + .is_ok_and(|snapshot| snapshot.text.contains("alpha")) + && service + .snapshot(beta.id) + .is_ok_and(|snapshot| snapshot.text.contains("beta")) + }); + wait_for(|| { + service.list().is_ok_and(|terminals| { + terminals.iter().any(|terminal| { + terminal.id == alpha.id + && matches!(terminal.lifecycle, TerminalLifecycle::Exited { .. }) + }) + }) + }); + + let gamma = service + .create(command("printf 'gamma\\n'; sleep 0.05")) + .expect("service creates terminal after child exit"); + wait_for(|| { + service + .snapshot(gamma.id) + .is_ok_and(|snapshot| snapshot.text.contains("gamma")) + }); + + service.terminate(alpha.id).expect("alpha removed"); + service.terminate(beta.id).expect("beta removed"); + service.terminate(gamma.id).expect("gamma removed"); +} + +#[test] +fn hidden_terminal_output_uses_bounded_replay() { + let service = TerminalService::new(32); + let terminal = service + .create(command("printf '0123456789abcdefghijklmnopqrstuvwxyz'")) + .expect("terminal created"); + + wait_for(|| { + service.list().is_ok_and(|items| { + items + .iter() + .any(|item| item.id == terminal.id && item.output_tail >= 36) + }) + }); + let replay = service.replay(terminal.id, 0).expect("replay"); + assert!(replay.truncated); + assert_eq!(replay.bytes.len(), 32); + assert_eq!(replay.end_offset - replay.available_offset, 32); + + service.terminate(terminal.id).expect("terminal removed"); +} + +#[test] +fn title_changes_update_metadata_and_notify_subscribers() { + let service = TerminalService::new(64 * 1024); + let terminal = service + .create(command( + r"sleep 0.05; printf '\033]2;dynamic-title\007'; sleep 0.2", + )) + .expect("terminal created"); + let observer = service + .attach( + terminal.id, + 0, + "observer".to_string(), + AttachmentRole::Observer, + false, + ) + .expect("observer attached"); + + let deadline = Instant::now() + Duration::from_secs(2); + let title = loop { + assert!( + Instant::now() < deadline, + "timed out waiting for title change" + ); + match observer.events.recv_timeout(Duration::from_millis(50)) { + Ok(StreamEvent::TitleChanged { title }) => break title, + Ok(_) | Err(crossbeam_channel::RecvTimeoutError::Timeout) => {} + Err(crossbeam_channel::RecvTimeoutError::Disconnected) => { + panic!("observer disconnected before title change") + } + } + }; + + assert_eq!(title, "dynamic-title"); + assert!(service.list().is_ok_and(|items| { + items + .iter() + .any(|item| item.id == terminal.id && item.title == "dynamic-title") + })); + + service.terminate(terminal.id).expect("terminal removed"); +} + +#[test] +#[cfg(target_os = "linux")] +fn foreground_process_tracks_deepest_tty_attached_job() { + let service = TerminalService::new(64 * 1024); + let terminal = service + .create(command("exec /bin/sh")) + .expect("terminal created"); + let controller = service + .attach( + terminal.id, + 0, + "controller".to_string(), + AttachmentRole::Controller, + false, + ) + .expect("controller attached"); + service + .input( + terminal.id, + "controller".to_string(), + 80, + 24, + b"tail --pid=$$ -f /dev/null & sleep 5\n".to_vec(), + ) + .expect("sleep submitted"); + + let deadline = Instant::now() + Duration::from_secs(2); + let process = loop { + assert!( + Instant::now() < deadline, + "timed out waiting for foreground process" + ); + service.list().expect("foreground process refreshed"); + match controller.events.recv_timeout(Duration::from_millis(50)) { + Ok(StreamEvent::ForegroundProcessChanged { + process: Some(process), + }) if process == "sleep" => break process, + Ok(_) | Err(crossbeam_channel::RecvTimeoutError::Timeout) => {} + Err(crossbeam_channel::RecvTimeoutError::Disconnected) => { + panic!("controller disconnected before foreground process change") + } + } + }; + + assert_eq!(process, "sleep"); + assert!(service.list().is_ok_and(|items| { + items.iter().any(|item| { + item.id == terminal.id && item.foreground_process.as_deref() == Some("sleep") + }) + })); + + service.terminate(terminal.id).expect("terminal removed"); +} + +#[test] +fn bursty_output_does_not_disconnect_a_healthy_observer() { + let service = TerminalService::new(2 * 1024 * 1024); + let terminal = service + .create(command("sleep 0.05; head -c 1048576 /dev/zero; sleep 0.2")) + .expect("terminal created"); + let observer = service + .attach( + terminal.id, + 0, + "observer".to_string(), + AttachmentRole::Observer, + false, + ) + .expect("observer attached"); + + wait_for(|| { + service.list().is_ok_and(|items| { + items + .iter() + .any(|item| item.id == terminal.id && item.output_tail >= 1024 * 1024) + }) + }); + let deadline = Instant::now() + Duration::from_secs(2); + let mut received = 0; + let mut output_events = 0; + while received < 1024 * 1024 && Instant::now() < deadline { + match observer.events.recv_timeout(Duration::from_millis(50)) { + Ok(StreamEvent::Output { bytes, .. }) => { + received += bytes.len(); + output_events += 1; + } + Ok(_) | Err(crossbeam_channel::RecvTimeoutError::Timeout) => {} + Err(crossbeam_channel::RecvTimeoutError::Disconnected) => break, + } + } + assert_eq!(received, 1024 * 1024); + assert!( + output_events <= 256, + "received {output_events} output events" + ); + + service.terminate(terminal.id).expect("terminal removed"); +} + +#[test] +fn controller_switches_without_disconnecting_observers() { + let service = TerminalService::new(64 * 1024); + let terminal = service + .create(command("sleep 0.05; printf 'streamed\\n'; sleep 0.2")) + .expect("terminal created"); + let controller = service + .attach( + terminal.id, + 0, + "controller-a".to_string(), + AttachmentRole::Controller, + false, + ) + .expect("controller attached"); + let observer = service + .attach( + terminal.id, + 0, + "observer".to_string(), + AttachmentRole::Observer, + false, + ) + .expect("observer attached"); + assert!( + service + .attach( + terminal.id, + 0, + "controller-b".to_string(), + AttachmentRole::Controller, + false, + ) + .is_err() + ); + assert!( + service + .write_for( + terminal.id, + Some("observer".to_string()), + b"not allowed".to_vec(), + ) + .is_err() + ); + assert!(service.write(terminal.id, b"not allowed".to_vec()).is_err()); + assert!(service.resize(terminal.id, 90, 25).is_err()); + service + .resize_for(terminal.id, Some("controller-a".to_string()), 90, 25) + .expect("controller resizes"); + + let saw_output = |events: &crossbeam_channel::Receiver| { + let deadline = Instant::now() + Duration::from_secs(2); + while Instant::now() < deadline { + match events.recv_timeout(Duration::from_millis(50)) { + Ok(StreamEvent::Output { bytes, .. }) + if bytes.windows(8).any(|value| value == b"streamed") => + { + return true; + } + Ok(_) => {} + Err(_) => {} + } + } + false + }; + assert!(saw_output(&controller.events)); + assert!(saw_output(&observer.events)); + + let takeover = service + .attach( + terminal.id, + 0, + "controller-b".to_string(), + AttachmentRole::Controller, + true, + ) + .expect("controller takeover"); + assert!(takeover.generation > controller.generation); + assert!( + service + .write_for( + terminal.id, + Some("controller-a".to_string()), + b"not allowed".to_vec(), + ) + .is_err() + ); + service + .input( + terminal.id, + "controller-a".to_string(), + 91, + 26, + b"reclaimed\n".to_vec(), + ) + .expect("former controller reclaims control and writes"); + assert!( + service + .write_for( + terminal.id, + Some("controller-b".to_string()), + b"not allowed".to_vec(), + ) + .is_err() + ); + service + .control(terminal.id, "observer".to_string(), 92, 27) + .expect("observer claims control"); + service + .control(terminal.id, "controller-b".to_string(), 93, 28) + .expect("takeover controller remains subscribed"); + drop(takeover); + service + .write_for( + terminal.id, + Some("observer".to_string()), + b"fallback\n".to_vec(), + ) + .expect("latest remaining controller is promoted on detach"); + service.terminate(terminal.id).expect("terminal removed"); +} diff --git a/packages/process-daemon/tests/support/resize_fixture.rs b/packages/process-daemon/tests/support/resize_fixture.rs new file mode 100644 index 000000000000..e09ef8ab36ce --- /dev/null +++ b/packages/process-daemon/tests/support/resize_fixture.rs @@ -0,0 +1,136 @@ +use std::env; +use std::io::{self, Read, Write}; +use std::net::{TcpListener, TcpStream}; +use std::thread; +use std::time::{Duration, Instant}; + +use nix::sys::termios::{SetArg, cfmakeraw, tcgetattr, tcsetattr}; +use opencode_pty::service::{CreateTerminal, TerminalId, TerminalService}; + +const CHILD: &str = "OPENCODE_PTY_RESIZE_TEST_CHILD"; + +pub struct ResizeFixture { + pub service: TerminalService, + pub id: TerminalId, + received: TcpStream, +} + +impl ResizeFixture { + pub fn new(test: &str) -> Self { + if let Ok(address) = env::var(CHILD) { + child(&address); + // Do not let the test harness print completion into the PTY. + std::process::exit(0); + } + + // A side channel observes child stdin without generating more PTY output, + // which would accidentally drain the replies and hide the regression. + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let service = TerminalService::default(); + let terminal = service + .create(CreateTerminal { + program: env::current_exe().unwrap().to_string_lossy().into_owned(), + args: vec![ + "--exact".into(), + test.into(), + "--nocapture".into(), + "--test-threads=1".into(), + ], + cwd: env::current_dir().unwrap(), + title: "resize-fixture".into(), + group_id: "resize-fixture".into(), + env: [(CHILD.into(), listener.local_addr().unwrap().to_string())].into(), + cols: 80, + rows: 24, + }) + .unwrap(); + + let deadline = Instant::now() + Duration::from_secs(5); + let mut received = loop { + match listener.accept() { + Ok((stream, _)) => break stream, + Err(error) if error.kind() == io::ErrorKind::WouldBlock => { + assert!(Instant::now() < deadline, "resize fixture did not connect"); + thread::sleep(Duration::from_millis(10)); + } + Err(error) => panic!("resize fixture connection failed: {error}"), + } + }; + received.set_nonblocking(false).unwrap(); + received + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + let mut ready = [0]; + received.read_exact(&mut ready).unwrap(); + assert_eq!(ready, *b"R"); + Self { + service, + id: terminal.id, + received, + } + } + + pub fn expect_input(&mut self, expected: &[u8]) { + for (index, expected_byte) in expected.iter().enumerate() { + let mut byte = [0]; + self.received.read_exact(&mut byte).unwrap_or_else(|error| { + panic!("missing PTY input byte {index} of {expected:?}: {error}") + }); + assert_eq!( + byte[0], *expected_byte, + "out-of-order PTY input at byte {index} of {expected:?}" + ); + } + } +} + +impl Drop for ResizeFixture { + fn drop(&mut self) { + let _ = self.service.terminate(self.id); + } +} + +fn child(address: &str) { + let mut received = TcpStream::connect(address).unwrap(); + received.set_nodelay(true).unwrap(); + let stdin = io::stdin(); + let mut attributes = tcgetattr(&stdin).unwrap(); + cfmakeraw(&mut attributes); + tcsetattr(&stdin, SetArg::TCSANOW, &attributes).unwrap(); + // SAFETY: only this dedicated child changes its handler. OS resize signals + // must not make it print anything and conceal a missing in-band notification. + assert_ne!( + unsafe { libc::signal(libc::SIGWINCH, libc::SIG_IGN) }, + libc::SIG_ERR + ); + + let mut stdin = stdin.lock(); + let mut stdout = io::stdout().lock(); + stdout.write_all(b"\x1b[?2048h\x1b[5n").unwrap(); + stdout.flush().unwrap(); + let mut initial = Vec::new(); + while !initial.ends_with(b"\x1b[0n") { + let mut byte = [0]; + stdin.read_exact(&mut byte).unwrap(); + initial.push(byte[0]); + assert!(initial.len() < 256, "unexpected initial terminal replies"); + } + // The DSR response proves Ghostty has processed the preceding mode change; + // any initial size report has also been consumed. Remain silent from here. + received.write_all(b"R").unwrap(); + let mut buffer = [0; 1024]; + loop { + let len = stdin.read(&mut buffer).unwrap(); + if len == 0 { + return; + } + received.write_all(&buffer[..len]).unwrap(); + if buffer[..len].contains(&b'!') { + // An explicit final probe lets the parent check that later output + // does not cause already-delivered resize replies to be sent again. + stdout.write_all(b"\x1b[5n").unwrap(); + stdout.flush().unwrap(); + } + } +} diff --git a/packages/process-daemon/tests/support/terminal_fixture.rs b/packages/process-daemon/tests/support/terminal_fixture.rs new file mode 100644 index 000000000000..cdea300d2988 --- /dev/null +++ b/packages/process-daemon/tests/support/terminal_fixture.rs @@ -0,0 +1,283 @@ +//! A real PTY child shared by direct-service and daemon tests. +//! +//! Include this module as `terminal_fixture`. `Fixture::request` launches this +//! test executable's ignored `terminal_fixture::child` test. The private TCP +//! channel controls the fixture and observes stdin without echoing it back into +//! the PTY (which could conceal lost or duplicated terminal replies). + +use std::env; +use std::io::{self, BufRead, BufReader, Read, Write}; +use std::net::{TcpListener, TcpStream}; +use std::path::PathBuf; +use std::sync::mpsc; +use std::thread; +use std::time::{Duration, Instant}; + +use opencode_pty::service::CreateTerminal; +use serde::{Deserialize, Serialize}; + +const ADDRESS: &str = "OPENCODE_PTY_FIXTURE_ADDRESS"; +const TIMEOUT: Duration = Duration::from_secs(15); + +#[derive(Serialize, Deserialize)] +pub enum Command { + Output(String), + Read(usize), + Size, + Context, + Exit(i32), + Flood, +} + +pub struct Fixture { + listener: TcpListener, +} + +impl Fixture { + pub fn new() -> Self { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + Self { listener } + } + + pub fn request(&self) -> CreateTerminal { + CreateTerminal { + program: env::current_exe().unwrap().to_str().unwrap().to_owned(), + args: [ + "--ignored", + "--exact", + "terminal_fixture::child", + "--nocapture", + "--test-threads=1", + ] + .map(str::to_owned) + .into(), + cwd: env::current_dir().unwrap(), + title: "terminal-fixture".into(), + group_id: "terminal-fixture".into(), + env: [( + ADDRESS.into(), + self.listener.local_addr().unwrap().to_string(), + )] + .into(), + cols: 80, + rows: 24, + } + } + + pub fn connect(&self) -> Connection { + let deadline = Instant::now() + TIMEOUT; + let stream = loop { + match self.listener.accept() { + Ok((stream, _)) => break stream, + Err(error) if error.kind() == io::ErrorKind::WouldBlock => { + assert!(Instant::now() < deadline, "PTY child did not connect"); + thread::sleep(Duration::from_millis(10)); + } + Err(error) => panic!("PTY child connection: {error}"), + } + }; + // BSD sockets inherit the listener's nonblocking mode on accept. + stream.set_nonblocking(false).unwrap(); + stream.set_read_timeout(Some(TIMEOUT)).unwrap(); + stream.set_write_timeout(Some(TIMEOUT)).unwrap(); + let mut connection = Connection(BufReader::new(stream)); + assert_eq!(connection.receive(), serde_json::json!("ready")); + connection + } +} + +pub struct Connection(BufReader); + +impl Connection { + pub fn command(&mut self, command: Command) -> serde_json::Value { + send(self.0.get_mut(), &command); + self.receive() + } + + fn receive(&mut self) -> serde_json::Value { + let mut line = String::new(); + assert_ne!( + self.0.read_line(&mut line).unwrap(), + 0, + "PTY child disconnected" + ); + serde_json::from_str(&line).unwrap() + } +} + +fn send(stream: &mut TcpStream, value: &impl Serialize) { + serde_json::to_writer(&mut *stream, value).unwrap(); + stream.write_all(b"\n").unwrap(); +} + +/// Fail a hung runtime test instead of leaving the native CI worker stuck in a +/// destructor. Successful tests cancel this watchdog; it is not runtime cleanup. +pub struct Deadline(mpsc::Sender<()>); + +impl Deadline { + pub fn new() -> Self { + let (send, receive) = mpsc::channel(); + let test = thread::current().name().unwrap_or("unknown").to_owned(); + thread::spawn(move || { + if receive.recv_timeout(Duration::from_secs(45)).is_err() { + eprintln!("PTY runtime test {test} exceeded 45 seconds"); + std::process::exit(124); + } + }); + Self(send) + } +} + +impl Drop for Deadline { + fn drop(&mut self) { + let _ = self.0.send(()); + } +} + +#[test] +#[ignore = "subprocess fixture, launched with a private control channel"] +fn child() { + // `cargo test -- --include-ignored` runs this entry directly; only a test + // that launched it provides the control channel. + let Ok(address) = env::var(ADDRESS) else { + return; + }; + configure_console(); + let stream = TcpStream::connect(address).unwrap(); + stream.set_read_timeout(Some(TIMEOUT)).unwrap(); + stream.set_write_timeout(Some(TIMEOUT)).unwrap(); + let mut channel = BufReader::new(stream); + send(channel.get_mut(), &"ready"); + loop { + let mut line = String::new(); + if channel.read_line(&mut line).unwrap() == 0 { + std::process::exit(0); + } + let result = match serde_json::from_str(&line).unwrap() { + Command::Output(text) => { + let mut stdout = io::stdout().lock(); + stdout.write_all(text.as_bytes()).unwrap(); + stdout.flush().unwrap(); + serde_json::Value::Null + } + Command::Read(len) => { + let mut bytes = vec![0; len]; + io::stdin().read_exact(&mut bytes).unwrap(); + serde_json::json!(bytes) + } + Command::Size => serde_json::json!(console_size()), + Command::Context => serde_json::json!({ + "pid": std::process::id(), + "cwd": env::current_dir().unwrap(), + "args": env::args().skip(1).collect::>(), + "value": env::var("PTY_FIXTURE_VALUE").ok(), + "term": env::var("TERM").ok(), + "colorterm": env::var("COLORTERM").ok(), + }), + Command::Exit(code) => { + send(channel.get_mut(), &serde_json::Value::Null); + std::process::exit(code); + } + Command::Flood => { + send(channel.get_mut(), &serde_json::Value::Null); + let mut stdout = io::stdout().lock(); + let chunk = "FLOOD_OUTPUT\r\n".repeat(256); + loop { + stdout.write_all(chunk.as_bytes()).unwrap(); + stdout.flush().unwrap(); + } + } + }; + send(channel.get_mut(), &result); + } +} + +#[cfg(unix)] +fn configure_console() { + use nix::sys::termios::{SetArg, cfmakeraw, tcgetattr, tcsetattr}; + let stdin = io::stdin(); + let mut attributes = tcgetattr(&stdin).unwrap(); + cfmakeraw(&mut attributes); + tcsetattr(&stdin, SetArg::TCSANOW, &attributes).unwrap(); +} + +#[cfg(unix)] +fn console_size() -> (u16, u16) { + let mut size = std::mem::MaybeUninit::::uninit(); + // SAFETY: ioctl initializes this correctly sized winsize on success. + assert_eq!( + unsafe { libc::ioctl(0, libc::TIOCGWINSZ, size.as_mut_ptr()) }, + 0 + ); + let size = unsafe { size.assume_init() }; + (size.ws_col, size.ws_row) +} + +#[cfg(windows)] +fn configure_console() { + use windows_sys::Win32::System::Console::*; + // SAFETY: these are this dedicated child's console handles. No handle is + // retained or closed; all pointers refer to initialized stack storage. + unsafe { + let input = GetStdHandle(STD_INPUT_HANDLE); + let output = GetStdHandle(STD_OUTPUT_HANDLE); + assert_ne!(SetConsoleCP(65001), 0); + assert_ne!(SetConsoleOutputCP(65001), 0); + assert_ne!(SetConsoleMode(input, ENABLE_VIRTUAL_TERMINAL_INPUT), 0); + assert_ne!( + SetConsoleMode( + output, + ENABLE_PROCESSED_OUTPUT + | ENABLE_WRAP_AT_EOL_OUTPUT + | ENABLE_VIRTUAL_TERMINAL_PROCESSING + | DISABLE_NEWLINE_AUTO_RETURN, + ), + 0 + ); + } +} + +#[cfg(windows)] +fn console_size() -> (u16, u16) { + use windows_sys::Win32::System::Console::*; + let mut info = std::mem::MaybeUninit::::uninit(); + // SAFETY: the console API initializes the complete output on success. + assert_ne!( + unsafe { GetConsoleScreenBufferInfo(GetStdHandle(STD_OUTPUT_HANDLE), info.as_mut_ptr()) }, + 0 + ); + let info = unsafe { info.assume_init() }; + ( + (info.srWindow.Right - info.srWindow.Left + 1) as u16, + (info.srWindow.Bottom - info.srWindow.Top + 1) as u16, + ) +} + +pub struct TempDir(pub PathBuf); + +impl TempDir { + pub fn new() -> Self { + let path = env::temp_dir().join(format!("pty fixture 界 {:016x}", rand::random::())); + std::fs::create_dir(&path).unwrap(); + Self(path) + } + + pub fn executable(&self, name: &str) -> PathBuf { + let path = self.0.join(name); + let executable = env::current_exe().unwrap(); + // Avoid an open executable write descriptor being inherited by another + // concurrent Unix fork and causing ETXTBSY. Windows has no fork race. + #[cfg(unix)] + std::os::unix::fs::symlink(executable, &path).unwrap(); + #[cfg(windows)] + std::fs::copy(executable, &path).unwrap(); + path + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} diff --git a/packages/process-daemon/tests/transport-retained.rs b/packages/process-daemon/tests/transport-retained.rs new file mode 100644 index 000000000000..f050d67c9572 --- /dev/null +++ b/packages/process-daemon/tests/transport-retained.rs @@ -0,0 +1,49 @@ +#[path = "../src/transport/retained.rs"] +mod retained; + +use std::cell::Cell; +use std::rc::Rc; + +use retained::Retained; + +struct Completion { + bytes: usize, + drops: Rc>, +} + +impl Drop for Completion { + fn drop(&mut self) { + self.drops.set(self.drops.get() + 1); + } +} + +#[test] +fn retained_native_pointer_survives_owner_moves_and_repeated_access() { + let drops = Rc::new(Cell::new(0)); + let operation = Retained::new(Completion { + bytes: 0, + drops: Rc::clone(&drops), + }); + let native = operation.as_ptr(); + let mut pending = vec![operation]; + pending.reserve(100); // Move the owner while native code retains its pointer. + for count in 1..=10 { + let polled = pending[0].as_ptr(); + assert_eq!(native, polled); + // SAFETY: stand-in for native mutation and a completion query. No Rust + // reference to the allocation is created while its raw pointer is retained. + unsafe { + (*native).bytes = count; + assert_eq!((*polled).bytes, count); + } + } + let completed = pending.pop().unwrap(); + assert_eq!(drops.get(), 0); + // Reclaim only after the native operation has completed (or been cancelled + // and reaped). Miri checks that reclamation and the final access are valid. + unsafe { + assert_eq!((*native).bytes, 10); + } + drop(completed); + assert_eq!(drops.get(), 1); +} diff --git a/packages/process-daemon/tests/transport-retained/.gitignore b/packages/process-daemon/tests/transport-retained/.gitignore new file mode 100644 index 000000000000..b83d22266ac8 --- /dev/null +++ b/packages/process-daemon/tests/transport-retained/.gitignore @@ -0,0 +1 @@ +/target/ diff --git a/packages/process-daemon/tests/transport-retained/Cargo.lock b/packages/process-daemon/tests/transport-retained/Cargo.lock new file mode 100644 index 000000000000..e98adc0ff6b8 --- /dev/null +++ b/packages/process-daemon/tests/transport-retained/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "transport-retained-miri" +version = "0.0.0" diff --git a/packages/process-daemon/tests/transport-retained/Cargo.toml b/packages/process-daemon/tests/transport-retained/Cargo.toml new file mode 100644 index 000000000000..a077a418024a --- /dev/null +++ b/packages/process-daemon/tests/transport-retained/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "transport-retained-miri" +version = "0.0.0" +edition = "2024" +publish = false + +[lib] +path = "../transport-retained.rs" +doctest = false + +[workspace] diff --git a/packages/process-daemon/tests/windows-daemon.rs b/packages/process-daemon/tests/windows-daemon.rs new file mode 100644 index 000000000000..281f0faafcfd --- /dev/null +++ b/packages/process-daemon/tests/windows-daemon.rs @@ -0,0 +1,696 @@ +#![cfg(windows)] + +// Direct-service and daemon tests share the runtime lane's real child fixture, +// rather than maintaining separate console setup. +#[path = "support/terminal_fixture.rs"] +mod terminal_fixture; + +use std::io::Write; +use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::thread; +use std::time::{Duration, Instant}; + +use base64::Engine; +use opencode_pty::daemon::{PipeConnection, Registration}; +use opencode_pty::protocol::{ + AttachmentRole, Envelope, Request, Response, SubscriptionEvent, read_frame, + read_subscription_event, write_frame, +}; +use opencode_pty::service::{CreateTerminal, TerminalInfo, TerminalLifecycle}; +use terminal_fixture::{Command as FixtureCommand, Deadline, Fixture, TempDir}; +use windows_sys::Win32::Foundation::WAIT_OBJECT_0; +use windows_sys::Win32::System::Threading::{ + GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_SYNCHRONIZE, + WaitForSingleObject, +}; + +struct Daemon { + child: Child, + registration: Registration, + directory: PathBuf, + root: TempDir, +} + +impl Daemon { + fn start() -> Self { + let root = TempDir::new(); + let directory = root.0.join("runtime"); + let mut child = spawn(&directory); + let registration = registration(&mut child, &directory); + Self { + child, + registration, + directory, + root, + } + } + + fn connect(&self) -> PipeConnection { + let mut stream = + PipeConnection::connect_to(&self.registration.socket, self.registration.pid).unwrap(); + stream.set_read_timeout(Some(Duration::from_secs(5))); + stream.set_write_timeout(Some(Duration::from_secs(5))); + stream + } + + fn send(&self, stream: &mut PipeConnection, request: Request) -> Response { + write_frame( + &mut *stream, + &Envelope { + token: self.registration.token.clone(), + request, + }, + ) + .unwrap(); + read_frame(stream).unwrap() + } + + fn request(&self, request: Request) -> Response { + self.send(&mut self.connect(), request) + } + + fn own(&self, ticket: Option) -> (PipeConnection, Response) { + let mut stream = self.connect(); + let response = self.send( + &mut stream, + Request::Own { + instance_id: self.registration.instance_id.clone(), + ticket, + }, + ); + (stream, response) + } + + fn create(&self, request: CreateTerminal) -> TerminalInfo { + let CreateTerminal { + program, + args, + cwd, + title, + group_id, + env, + cols, + rows, + } = request; + match self.request(Request::Create { + program, + args, + cwd, + title, + group_id, + env, + cols, + rows, + }) { + Response::Created { terminal } => terminal, + response => panic!("create: {response:?}"), + } + } + + fn subscribe(&self, id: u64, role: AttachmentRole) -> PipeConnection { + let (stream, response) = self.attach(id, "daemon-test", role); + assert!(matches!(response, Response::Attached { .. })); + stream + } + + fn attach( + &self, + id: u64, + attachment_id: &str, + role: AttachmentRole, + ) -> (PipeConnection, Response) { + let mut stream = self.connect(); + let response = self.send( + &mut stream, + Request::Subscribe { + id, + offset: 0, + attachment_id: attachment_id.into(), + role, + takeover: false, + }, + ); + (stream, response) + } + + fn snapshot(&self, id: u64) -> (TerminalInfo, String) { + match self.request(Request::Snapshot { id }) { + Response::Snapshot { terminal, text, .. } => (terminal, text), + response => panic!("snapshot: {response:?}"), + } + } + + fn wait_text(&self, id: u64, expected: &str) -> (TerminalInfo, String) { + let deadline = Instant::now() + Duration::from_secs(5); + loop { + let snapshot = self.snapshot(id); + if snapshot.1.contains(expected) { + return snapshot; + } + assert!( + Instant::now() < deadline, + "missing {expected:?}: {snapshot:?}" + ); + thread::sleep(Duration::from_millis(10)); + } + } + + fn wait(&mut self) { + let status = wait(&mut self.child); + assert!(status.success(), "daemon failed: {status}"); + assert!( + !self.directory.exists(), + "runtime directory was not removed" + ); + assert!(PipeConnection::connect(&self.registration.socket).is_err()); + } +} + +impl Drop for Daemon { + fn drop(&mut self) { + if self.child.try_wait().ok().flatten().is_none() { + let _ = self.child.kill(); + let _ = self.child.wait(); + } + } +} + +fn spawn(directory: &Path) -> Child { + Command::new(env!("CARGO_BIN_EXE_opencode-pty")) + .args(["daemon", "--name"]) + .arg(directory.file_name().unwrap()) + .arg("--runtime-dir") + .arg(directory.parent().unwrap()) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .spawn() + .unwrap() +} + +fn registration(child: &mut Child, directory: &Path) -> Registration { + let deadline = Instant::now() + Duration::from_secs(4); + loop { + // The checked reader also proves the published file is private. + if let Ok(registration) = opencode_pty::daemon::read_registration(directory) + && registration.pid == child.id() + { + assert_eq!(registration.protocol, 7); + assert_eq!( + registration.socket, + PathBuf::from(format!( + r"\\.\pipe\opencode-pty-{}", + registration.instance_id + )) + ); + return registration; + } + assert!( + child.try_wait().unwrap().is_none(), + "daemon exited before registering" + ); + assert!(Instant::now() < deadline, "daemon did not register"); + thread::sleep(Duration::from_millis(10)); + } +} + +/// ConPTY applies a resize asynchronously in conhost, so the child can briefly +/// observe the old size after the daemon has acknowledged the request. +fn wait_size(child: &mut terminal_fixture::Connection, cols: u16, rows: u16) { + let expected = serde_json::json!([cols, rows]); + let deadline = Instant::now() + Duration::from_secs(15); + loop { + let size = child.command(FixtureCommand::Size); + if size == expected { + return; + } + assert!(Instant::now() < deadline, "console size stayed {size}"); + thread::sleep(Duration::from_millis(10)); + } +} + +fn wait(child: &mut Child) -> std::process::ExitStatus { + let deadline = Instant::now() + Duration::from_secs(8); + loop { + if let Some(status) = child.try_wait().unwrap() { + return status; + } + assert!(Instant::now() < deadline, "daemon did not exit"); + thread::sleep(Duration::from_millis(10)); + } +} + +#[test] +fn authenticated_ownership_handoff_and_shutdown_use_real_pipes() { + let _deadline = Deadline::new(); + let mut daemon = Daemon::start(); + let mut invalid = daemon.connect(); + write_frame( + &mut invalid, + &Envelope { + token: "wrong".into(), + request: Request::Ping, + }, + ) + .unwrap(); + assert!( + matches!(read_frame(&mut invalid).unwrap(), Response::Error { message } if message == "authentication failed") + ); + drop(invalid); + assert!(matches!( + daemon.request(Request::Own { + instance_id: "wrong".into(), + ticket: None + }), + Response::Error { .. } + )); + let (mut owner, response) = daemon.own(None); + assert!(matches!(response, Response::Owned)); + assert!(matches!(daemon.own(None).1, Response::Error { .. })); + let Response::Handoff { ticket, expires_at } = daemon.send(&mut owner, Request::PrepareHandoff) + else { + panic!("handoff response"); + }; + assert!( + matches!(daemon.send(&mut owner, Request::PrepareHandoff), Response::Handoff { ticket: repeated, expires_at: deadline } if repeated == ticket && deadline == expires_at) + ); + // A valid ticket takes over even while the previous owner is connected. + let (mut successor, response) = daemon.own(Some(ticket.clone())); + assert!(matches!(response, Response::Owned)); + assert!(matches!( + daemon.send(&mut owner, Request::Shutdown), + Response::Error { message } if message == "owner connection has been superseded" + )); + drop(owner); + assert!(matches!(daemon.own(Some(ticket)).1, Response::Error { .. })); + assert!(matches!( + daemon.request(Request::Ping), + Response::Pong { .. } + )); + assert!(matches!( + daemon.send(&mut successor, Request::Shutdown), + Response::Ok + )); + drop(successor); // Protocol completion, not Unix half-close. + daemon.wait(); +} + +#[test] +fn service_lock_and_stale_registration_are_instance_scoped() { + let _deadline = Deadline::new(); + let mut daemon = Daemon::start(); + let mut duplicate = spawn(&daemon.directory); + assert!(!wait(&mut duplicate).success()); + let old = daemon.registration.clone(); + daemon.child.kill().unwrap(); + daemon.child.wait().unwrap(); + assert!(daemon.directory.join("service.json").exists()); + daemon.child = spawn(&daemon.directory); + daemon.registration = registration(&mut daemon.child, &daemon.directory); + assert_ne!(old.instance_id, daemon.registration.instance_id); + assert_ne!(old.token, daemon.registration.token); + assert_ne!(old.socket, daemon.registration.socket); + let (owner, response) = daemon.own(None); + assert!(matches!(response, Response::Owned)); + assert!(matches!(daemon.request(Request::Shutdown), Response::Ok)); + drop(owner); + daemon.wait(); +} + +#[test] +fn unclaimed_daemon_cancels_a_partial_request() { + let _deadline = Deadline::new(); + let mut daemon = Daemon::start(); + let mut partial = daemon.connect(); + partial.write_all(&[0, 0]).unwrap(); + daemon.wait(); +} + +#[test] +fn named_pipe_daemon_create_input_output_resize_and_shutdown() { + let _deadline = Deadline::new(); + let mut daemon = Daemon::start(); + let (owner, response) = daemon.own(None); + assert!(matches!(response, Response::Owned)); + let fixture = Fixture::new(); + // Exercise a path containing spaces/Unicode using the shared child fixture. + let executable = daemon.root.executable("daemon fixture.exe"); + let mut request = fixture.request(); + request.program = executable.to_str().unwrap().into(); + let terminal = daemon.create(request); + let mut child = fixture.connect(); + let mut subscription = daemon.subscribe(terminal.id, AttachmentRole::Observer); + child.command(FixtureCommand::Output("\x1b[2J\x1b[Hdaemon-output".into())); + let mut bytes = Vec::new(); + while !String::from_utf8_lossy(&bytes).contains("daemon-output") { + if let SubscriptionEvent::Output { bytes: output, .. } = + read_subscription_event(&mut subscription).unwrap() + { + bytes.extend(output); + } + } + let input = b"real-input"; + assert!(matches!( + daemon.request(Request::Write { + id: terminal.id, + attachment_id: None, + data_base64: base64::engine::general_purpose::STANDARD.encode(input) + }), + Response::Ok + )); + assert_eq!( + child.command(FixtureCommand::Read(input.len())), + serde_json::json!(input) + ); + assert!(matches!( + daemon.request(Request::Resize { + id: terminal.id, + attachment_id: None, + cols: 93, + rows: 31 + }), + Response::Ok + )); + wait_size(&mut child, 93, 31); + assert!( + matches!(daemon.request(Request::Snapshot { id: terminal.id }), Response::Snapshot { text, .. } if text.contains("daemon-output")) + ); + // Natural-exit/EOF ordering belongs to the runtime cleanup milestone. This + // basic daemon test deliberately exercises live-child operations + shutdown. + drop(subscription); + assert!(matches!(daemon.request(Request::Shutdown), Response::Ok)); + drop(owner); + daemon.wait(); +} + +#[test] +fn owner_loss_cancels_blocked_subscriber_and_partial_request() { + let _deadline = Deadline::new(); + let mut daemon = Daemon::start(); + let (owner, response) = daemon.own(None); + assert!(matches!(response, Response::Owned)); + let fixture = Fixture::new(); + let terminal = daemon.create(fixture.request()); + let mut child = fixture.connect(); + // SAFETY: open the live child for waiting; PID is only used to obtain a + // stable process handle for this assertion, not as terminal identity. + let process = unsafe { OpenProcess(PROCESS_SYNCHRONIZE, 0, terminal.pid.unwrap()) }; + assert!(!process.is_null()); + let process = unsafe { OwnedHandle::from_raw_handle(process) }; + let _blocked = daemon.subscribe(terminal.id, AttachmentRole::Controller); + let mut partial = daemon.connect(); + partial.write_all(&[0, 0]).unwrap(); + child.command(FixtureCommand::Output("x".repeat(256 * 1024))); + thread::sleep(Duration::from_millis(100)); + drop(owner); + daemon.wait(); + assert_eq!( + unsafe { WaitForSingleObject(process.as_raw_handle(), 3000) }, + WAIT_OBJECT_0 + ); +} + +#[test] +fn natural_exit_delivers_contiguous_output_and_retains_final_state() { + let _deadline = Deadline::new(); + let mut daemon = Daemon::start(); + let (owner, response) = daemon.own(None); + assert!(matches!(response, Response::Owned)); + let fixture = Fixture::new(); + let terminal = daemon.create(fixture.request()); + let mut child = fixture.connect(); + // Capture a stable process handle before exit; the PID is not terminal identity. + let process = unsafe { + OpenProcess( + PROCESS_SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION, + 0, + terminal.pid.unwrap(), + ) + }; + assert!(!process.is_null()); + let process = unsafe { OwnedHandle::from_raw_handle(process) }; + + child.command(FixtureCommand::Output( + "\x1b[2J\x1b[HBEFORE_ATTACH_MARKER\r\n".into(), + )); + daemon.wait_text(terminal.id, "BEFORE_ATTACH_MARKER"); + let (mut subscription, response) = + daemon.attach(terminal.id, "exit-observer", AttachmentRole::Observer); + let Response::Attached { + requested_offset, + available_offset, + end_offset, + truncated, + replay_base64, + .. + } = response + else { + panic!("attach: {response:?}"); + }; + assert_eq!(requested_offset, 0); + assert_eq!(available_offset, 0); + assert!(!truncated); + let mut bytes = base64::engine::general_purpose::STANDARD + .decode(replay_base64) + .unwrap(); + assert_eq!(end_offset, bytes.len() as u64); + assert!(String::from_utf8_lossy(&bytes).contains("BEFORE_ATTACH_MARKER")); + let mut offset = end_offset; + + child.command(FixtureCommand::Output("\r\nFINAL_DAEMON_MARKER\r\n".into())); + child.command(FixtureCommand::Exit(23)); + let exit_code = loop { + match read_subscription_event(&mut subscription).unwrap() { + SubscriptionEvent::Output { + start, + end, + bytes: output, + } => { + assert_eq!( + start, offset, + "output must be contiguous after attachment replay" + ); + assert_eq!(end - start, output.len() as u64); + bytes.extend(output); + offset = end; + } + SubscriptionEvent::Response(response) => match *response { + Response::Exited { + exit_code, + final_offset, + } => { + assert_eq!(final_offset, offset); + break exit_code; + } + Response::Error { message } => panic!("subscription: {message}"), + _ => {} + }, + } + }; + drop(subscription); + assert!(String::from_utf8_lossy(&bytes).contains("FINAL_DAEMON_MARKER")); + // SAFETY: the live owned process handle permits waiting and exit-code queries. + assert_eq!( + unsafe { WaitForSingleObject(process.as_raw_handle(), 3000) }, + WAIT_OBJECT_0 + ); + let mut actual_exit = 0; + assert_ne!( + unsafe { GetExitCodeProcess(process.as_raw_handle(), &mut actual_exit) }, + 0 + ); + assert_eq!(actual_exit, 23); + assert_eq!(exit_code, Some(actual_exit)); + + let (info, text) = daemon.snapshot(terminal.id); + assert_eq!(info.lifecycle, TerminalLifecycle::Exited { exit_code }); + assert_eq!(info.output_tail, offset); + assert!(text.contains("FINAL_DAEMON_MARKER"), "{text:?}"); + let Response::Rows { + terminal: rows_info, + lines, + .. + } = daemon.request(Request::ReadRows { + id: terminal.id, + rows: None, + }) + else { + panic!("rows response"); + }; + assert_eq!(rows_info.lifecycle, info.lifecycle); + assert_eq!(rows_info.output_tail, offset); + assert!( + lines + .iter() + .any(|line| line.contains("FINAL_DAEMON_MARKER")) + ); + let Response::Replay { + requested_offset, + available_offset, + end_offset, + truncated, + data_base64, + } = daemon.request(Request::Replay { + id: terminal.id, + offset: 0, + }) + else { + panic!("replay response"); + }; + assert_eq!(requested_offset, 0); + assert_eq!(available_offset, 0); + assert_eq!(end_offset, offset); + assert!(!truncated); + assert_eq!( + base64::engine::general_purpose::STANDARD + .decode(data_base64) + .unwrap(), + bytes + ); + assert!( + matches!(daemon.own(None).1, Response::Error { .. }), + "terminal exit must not lose the live owner" + ); + assert!(matches!( + daemon.request(Request::Terminate { id: terminal.id }), + Response::Ok + )); + assert!( + matches!(daemon.request(Request::List), Response::Terminals { terminals } if terminals.is_empty()) + ); + assert!(matches!(daemon.request(Request::Shutdown), Response::Ok)); + drop(owner); + daemon.wait(); +} + +#[test] +fn observer_disconnect_and_control_input_preserve_independent_terminals() { + let _deadline = Deadline::new(); + let mut daemon = Daemon::start(); + let (owner, response) = daemon.own(None); + assert!(matches!(response, Response::Owned)); + let first_fixture = Fixture::new(); + let second_fixture = Fixture::new(); + let first = daemon.create(first_fixture.request()); + let second = daemon.create(second_fixture.request()); + assert_ne!(first.id, second.id); + let mut first_child = first_fixture.connect(); + let mut second_child = second_fixture.connect(); + let (first_control, response) = + daemon.attach(first.id, "first-controller", AttachmentRole::Controller); + assert!(matches!(response, Response::Attached { .. })); + let (second_control, response) = + daemon.attach(second.id, "second-controller", AttachmentRole::Observer); + assert!(matches!(response, Response::Attached { .. })); + let (observer, response) = + daemon.attach(first.id, "disposable-observer", AttachmentRole::Observer); + assert!(matches!(response, Response::Attached { .. })); + drop(observer); + + let first_input = b"first-directed"; + assert!(matches!( + daemon.request(Request::Input { + id: first.id, + attachment_id: "first-controller".into(), + cols: 91, + rows: 27, + data_base64: base64::engine::general_purpose::STANDARD.encode(first_input), + }), + Response::Ok + )); + assert_eq!( + first_child.command(FixtureCommand::Read(first_input.len())), + serde_json::json!(first_input) + ); + wait_size(&mut first_child, 91, 27); + + assert!(matches!( + daemon.request(Request::Control { + id: second.id, + attachment_id: "second-controller".into(), + cols: 73, + rows: 29, + }), + Response::Ok + )); + wait_size(&mut second_child, 73, 29); + let second_input = b"second-directed"; + assert!(matches!( + daemon.request(Request::Input { + id: second.id, + attachment_id: "second-controller".into(), + cols: 74, + rows: 30, + data_base64: base64::engine::general_purpose::STANDARD.encode(second_input), + }), + Response::Ok + )); + assert_eq!( + second_child.command(FixtureCommand::Read(second_input.len())), + serde_json::json!(second_input) + ); + wait_size(&mut second_child, 74, 30); + assert_eq!( + first_child.command(FixtureCommand::Size), + serde_json::json!([91, 27]) + ); + first_child.command(FixtureCommand::Output( + "\x1b[2J\x1b[HFIRST_ONLY_MARKER".into(), + )); + second_child.command(FixtureCommand::Output( + "\x1b[2J\x1b[HSECOND_ONLY_MARKER".into(), + )); + let (first_info, first_text) = daemon.wait_text(first.id, "FIRST_ONLY_MARKER"); + let (second_info, second_text) = daemon.wait_text(second.id, "SECOND_ONLY_MARKER"); + assert_eq!(first_info.lifecycle, TerminalLifecycle::Running); + assert_eq!(second_info.lifecycle, TerminalLifecycle::Running); + assert!(!first_text.contains("SECOND_ONLY_MARKER")); + assert!(!second_text.contains("FIRST_ONLY_MARKER")); + assert!( + matches!(daemon.request(Request::List), Response::Terminals { terminals } if terminals.len() == 2) + ); + assert!( + matches!(daemon.request(Request::Ping), Response::Pong { instance_id, pid, protocol: 7 } + if instance_id == daemon.registration.instance_id && pid == daemon.registration.pid) + ); + assert!(matches!(daemon.own(None).1, Response::Error { .. })); + + assert!(matches!( + daemon.request(Request::Terminate { id: first.id }), + Response::Ok + )); + drop(first_control); + assert!( + matches!(daemon.request(Request::List), Response::Terminals { terminals } + if terminals.len() == 1 && terminals[0].id == second.id) + ); + let input = b"after-first-exit"; + assert!(matches!( + daemon.request(Request::Input { + id: second.id, + attachment_id: "second-controller".into(), + cols: 74, + rows: 30, + data_base64: base64::engine::general_purpose::STANDARD.encode(input), + }), + Response::Ok + )); + assert_eq!( + second_child.command(FixtureCommand::Read(input.len())), + serde_json::json!(input) + ); + second_child.command(FixtureCommand::Output("\r\nSECOND_SURVIVES\r\n".into())); + assert_eq!( + daemon.wait_text(second.id, "SECOND_SURVIVES").0.lifecycle, + TerminalLifecycle::Running + ); + drop(second_control); + assert!(matches!( + daemon.request(Request::Terminate { id: second.id }), + Response::Ok + )); + assert!(matches!(daemon.request(Request::Shutdown), Response::Ok)); + drop(owner); + daemon.wait(); +}