chore(process-daemon): move opencode-pty into the monorepo (#59694)

This commit is contained in:
James Long 2026-10-27 16:04:03 -04:00 • committed by GitHub
parent 92db5e1024
commit 76e1f5b0cc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
58 changed files with 12790 additions and 0 deletions

View file

@ -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

2
packages/process-daemon/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
/target
/tests/*/target

View file

@ -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
```

513
packages/process-daemon/Cargo.lock generated Normal file
View file

@ -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"

View file

@ -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"

View file

@ -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.

View file

@ -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.

View file

@ -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-<uid>/` 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-<instance_id>`, 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<u16>` 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.

View file

@ -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<u16> }` (`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.

View file

@ -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}");
}

View file

@ -0,0 +1 @@
ab0b9da9e88fcb4b0533a1854e84628f663930af

View file

@ -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)

View file

@ -0,0 +1 @@
export declare const binaryPath: string | undefined

View file

@ -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

View file

@ -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"
}
}

View file

@ -0,0 +1,3 @@
[toolchain]
channel = "1.90.0"
profile = "minimal"

View file

@ -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"

View file

@ -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)

View file

@ -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" "$@"

View file

@ -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<u8>,
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<u8>,
}
#[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<SubscriptionEvent> {
read_subscription_event(&mut self.stream)
}
}
impl TerminalClient {
#[cfg(unix)]
pub fn start(directory: &Path) -> Result<Self> {
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(&registration.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<Self> {
bail!("opencode-pty client transport is not implemented on this platform")
}
pub fn discover(directory: &Path) -> Result<Self> {
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<TerminalInfo> {
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<Vec<TerminalInfo>> {
match self.request(Request::List)? {
Response::Terminals { terminals } => Ok(terminals),
response => unexpected(response),
}
}
pub fn write(&self, id: TerminalId, data: Vec<u8>) -> Result<()> {
self.write_for(id, None, data)
}
pub fn write_for(
&self,
id: TerminalId,
attachment_id: Option<String>,
data: Vec<u8>,
) -> 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<String>,
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<u8>,
) -> 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<RemoteSnapshot> {
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<u16>) -> Result<TerminalRows> {
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<RemoteReplay> {
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<TerminalSubscription> {
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<Response> {
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<Response> {
bail!("opencode-pty client transport is not implemented on this platform")
}
}
fn unexpected<T>(response: Response) -> Result<T> {
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<PathBuf> {
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::<u64>()
);
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<TerminalId> {
value.trim().parse().context("expected terminal ID")
}
fn require_active(active: Option<TerminalId>) -> Result<TerminalId> {
active.ok_or_else(|| anyhow!("no active terminal; use `new` or `use ID`"))
}
fn optional_id(value: &str, active: Option<TerminalId>) -> Result<TerminalId> {
if value.trim().is_empty() {
require_active(active)
} else {
parse_id(value)
}
}

View file

@ -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<PathBuf>, home: Option<PathBuf>) -> 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 `<root>/<name>`, where the name is a single plain path component.
pub fn runtime_dir(root: Option<&Path>, name: &str) -> Result<PathBuf> {
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<Registration> {
// 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:?}");
}
}
}

View file

@ -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 = &registration;
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,
&registration,
&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<Ownership>,
) -> 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<Ownership>,
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<Response> {
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(),
},
})
}

View file

@ -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::<Registration>(&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::<u128>())
}
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<u32>) -> 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(&registration).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());
}
}
}

View file

@ -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, &registration)?;
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<Self> {
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<PathBuf> {
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::<String>();
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::<u128>())
}
#[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();
}
}

View file

@ -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<Registration> {
let security = PrivateSecurity::new()?;
let mut file = open(
&registration_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::<u128>());
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::<u128>()),
};
let listener = Listener::bind(&registration.socket)?;
write_registration(&directory, &directory_handle, &registration, &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::<Vec<_>>();
let name_bytes = u32::try_from(size_of_val(name.as_slice()))?;
let bytes = size_of::<FILE_RENAME_INFO>()
.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::<FILE_RENAME_INFO>() <= align_of::<usize>());
}
let mut buffer = vec![0_usize; bytes.div_ceil(size_of::<usize>())];
let allocation = buffer.as_mut_ptr().cast::<u8>();
let info = allocation.cast::<FILE_RENAME_INFO>();
// 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::<u16>();
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<PathBuf> {
// 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<File> {
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<Vec<u16>> {
let mut wide = path.as_os_str().encode_wide().collect::<Vec<_>>();
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::<u128>()));
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, &registration, &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, &registration, &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::<Registration>(&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));
}
}

View file

@ -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.

View file

@ -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<VecDeque<Vec<u8>>>,
title_changed: Cell<bool>,
panic: Cell<Option<Box<dyn Any + Send>>>,
}
#[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<State>,
}
impl Effects {
pub(super) fn userdata(&self) -> *const c_void {
Rc::as_ptr(&self.state).cast()
}
pub(super) fn take_writes(&self) -> VecDeque<Vec<u8>> {
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::<State>() };
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::<State>() }
.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);

View file

@ -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::<GhosttyString>() - 16usize];
["Alignment of GhosttyString"][::std::mem::align_of::<GhosttyString>() - 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::<Self>::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::<GhosttyAllocatorVtable>() - 32usize];
["Alignment of GhosttyAllocatorVtable"]
[::std::mem::align_of::<GhosttyAllocatorVtable>() - 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::<GhosttyAllocator>() - 16usize];
["Alignment of GhosttyAllocator"][::std::mem::align_of::<GhosttyAllocator>() - 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::<Self>::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::<GhosttyGridRef>() - 24usize];
["Alignment of GhosttyGridRef"][::std::mem::align_of::<GhosttyGridRef>() - 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::<Self>::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::<GhosttyPointCoordinate>() - 8usize];
["Alignment of GhosttyPointCoordinate"]
[::std::mem::align_of::<GhosttyPointCoordinate>() - 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::<GhosttyPointValue>() - 16usize];
["Alignment of GhosttyPointValue"][::std::mem::align_of::<GhosttyPointValue>() - 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::<Self>::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::<GhosttyPoint>() - 24usize];
["Alignment of GhosttyPoint"][::std::mem::align_of::<GhosttyPoint>() - 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::<Self>::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::<GhosttySelection>() - 64usize];
["Alignment of GhosttySelection"][::std::mem::align_of::<GhosttySelection>() - 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::<Self>::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::<GhosttyTerminalSelectionFormatOptions>() - 24usize];
["Alignment of GhosttyTerminalSelectionFormatOptions"]
[::std::mem::align_of::<GhosttyTerminalSelectionFormatOptions>() - 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::<Self>::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::<GhosttyTerminalOptions>() - 16usize];
["Alignment of GhosttyTerminalOptions"]
[::std::mem::align_of::<GhosttyTerminalOptions>() - 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::<GhosttyTerminalScrollViewportValue>() - 16usize];
["Alignment of GhosttyTerminalScrollViewportValue"]
[::std::mem::align_of::<GhosttyTerminalScrollViewportValue>() - 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::<Self>::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::<GhosttyTerminalScrollViewport>() - 24usize];
["Alignment of GhosttyTerminalScrollViewport"]
[::std::mem::align_of::<GhosttyTerminalScrollViewport>() - 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::<Self>::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::<GhosttyFormatterScreenExtra>() - 16usize];
["Alignment of GhosttyFormatterScreenExtra"]
[::std::mem::align_of::<GhosttyFormatterScreenExtra>() - 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::<GhosttyFormatterTerminalExtra>() - 32usize];
["Alignment of GhosttyFormatterTerminalExtra"]
[::std::mem::align_of::<GhosttyFormatterTerminalExtra>() - 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::<GhosttyFormatterTerminalOptions>() - 56usize];
["Alignment of GhosttyFormatterTerminalOptions"]
[::std::mem::align_of::<GhosttyFormatterTerminalOptions>() - 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::<Self>::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);
}

View file

@ -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<ffi::GhosttyTerminalImpl>,
// 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<Rc<()>>,
}
impl Terminal {
pub fn new(options: TerminalOptions) -> Result<Self> {
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<Vec<u8>> {
self.effects.take_writes()
}
pub fn take_title(&mut self) -> Option<String> {
if !self.effects.take_title_changed() {
return None;
}
self.title().ok()
}
fn title(&self) -> Result<String> {
// 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<u16> {
// SAFETY: COLS writes uint16_t.
unsafe { self.get(ffi::GHOSTTY_TERMINAL_DATA_COLS) }
}
pub fn rows(&self) -> Result<u16> {
// SAFETY: ROWS writes uint16_t.
unsafe { self.get(ffi::GHOSTTY_TERMINAL_DATA_ROWS) }
}
pub fn cursor_x(&self) -> Result<u16> {
// SAFETY: CURSOR_X writes uint16_t.
unsafe { self.get(ffi::GHOSTTY_TERMINAL_DATA_CURSOR_X) }
}
pub fn cursor_y(&self) -> Result<u16> {
// SAFETY: CURSOR_Y writes uint16_t.
unsafe { self.get(ffi::GHOSTTY_TERMINAL_DATA_CURSOR_Y) }
}
pub fn total_rows(&self) -> Result<usize> {
// 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<T>(&self, data: ffi::GhosttyTerminalData) -> Result<T> {
let mut value = MaybeUninit::<T>::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<Vec<u8>> {
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<usize> {
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<ffi::GhosttyGridRef> {
let mut value = ffi::GhosttyGridRef {
size: size_of::<ffi::GhosttyGridRef>(),
..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<ffi::GhosttySelection> {
Ok(ffi::GhosttySelection {
size: size_of::<ffi::GhosttySelection>(),
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<u32> {
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<Option<usize>> {
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::<ffi::GhosttyTerminalSelectionFormatOptions>(),
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<ffi::GhosttyFormatterImpl>,
_borrow: PhantomData<(&'a Terminal, &'a ffi::GhosttySelection)>,
}
impl<'a> Formatter<'a> {
fn new(
terminal: &'a Terminal,
format: Format,
selection: Option<&'a ffi::GhosttySelection>,
) -> Result<Self> {
let extras = selection.is_none();
let options = ffi::GhosttyFormatterTerminalOptions {
size: size_of::<ffi::GhosttyFormatterTerminalOptions>(),
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::<ffi::GhosttyFormatterTerminalExtra>(),
palette: false,
modes: extras,
scrolling_region: extras,
tabstops: extras,
pwd: extras,
keyboard: extras,
screen: ffi::GhosttyFormatterScreenExtra {
size: size_of::<ffi::GhosttyFormatterScreenExtra>(),
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;

View file

@ -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()])
);
}

View file

@ -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;

View file

@ -0,0 +1,6 @@
fn main() {
if let Err(error) = opencode_pty::client::run_cli() {
eprintln!("error: {error:#}");
std::process::exit(1);
}
}

View file

@ -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)
);
}
}

View file

@ -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<Handoff>),
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<u64> {
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<Handoff> {
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::<u128>()),
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));
}
}

View file

@ -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<String>,
},
PrepareHandoff,
Create {
program: String,
args: Vec<String>,
cwd: PathBuf,
title: String,
group_id: String,
env: std::collections::HashMap<String, String>,
cols: u16,
rows: u16,
},
List,
Write {
id: TerminalId,
attachment_id: Option<String>,
data_base64: String,
},
Resize {
id: TerminalId,
attachment_id: Option<String>,
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<u16>,
},
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<TerminalInfo>,
},
Ok,
Snapshot {
terminal: TerminalInfo,
text: String,
checkpoint_base64: String,
cursor_x: u16,
cursor_y: u16,
},
Rows {
terminal: TerminalInfo,
lines: Vec<String>,
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<u32>,
final_offset: u64,
},
ControllerChanged {
attachment_id: Option<String>,
generation: u64,
},
TitleChanged {
title: String,
},
ForegroundProcessChanged {
process: Option<String>,
},
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<u8>,
},
Response(Box<Response>),
}
pub fn read_subscription_event(mut reader: impl Read) -> Result<SubscriptionEvent> {
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<T: for<'de> Deserialize<'de>>(mut reader: impl Read) -> Result<T> {
serde_json::from_slice(&read_payload(&mut reader)?).context("invalid protocol JSON")
}
fn read_payload(mut reader: impl Read) -> Result<Vec<u8>> {
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::<Request>(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::<Request>(value).unwrap(), Request::ReadRows { rows: Some(count), .. } if count == rows)
);
}
for rows in [-1, 65536] {
assert!(
serde_json::from_value::<Request>(
serde_json::json!({"op": "read_rows", "id": 1, "rows": rows})
)
.is_err()
);
}
assert_eq!(PROTOCOL_VERSION, 7);
}
}

File diff suppressed because it is too large Load diff

View file

@ -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<dyn Child + Send + Sync>,
events: Sender<ActorMessage>,
close: Receiver<Box<dyn MasterPty + Send>>,
) {
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<ActorMessage>) {
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()),
));
}

View file

@ -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};

View file

@ -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<T>(NonNull<T>);
impl<T> Retained<T> {
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<T> Drop for Retained<T> {
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()));
}
}
}

View file

@ -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<UnixListener>);
impl Listener {
pub fn bind(endpoint: &Path) -> io::Result<Self> {
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<Option<Connection>> {
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<Self> {
UnixStream::connect(endpoint).map(Self)
}
pub fn set_read_timeout(&self, timeout: Option<Duration>) -> io::Result<()> {
self.0.set_read_timeout(timeout)
}
pub fn set_write_timeout(&self, timeout: Option<Duration>) -> io::Result<()> {
self.0.set_write_timeout(timeout)
}
pub fn cancellation(&self) -> io::Result<Cancellation> {
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<DisconnectMonitor> {
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<usize> {
self.0.read(bytes)
}
}
impl Write for Connection {
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
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::<u128>()));
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::<Response>(&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();
}
}

View file

@ -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<u16>,
security: PrivateSecurity,
pending: PendingConnection,
stopped: bool,
}
impl Listener {
pub fn bind(endpoint: &Path) -> io::Result<Self> {
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<Option<Connection>> {
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<Self> {
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<bool> {
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<Self> {
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<Pipe>,
read_timeout: Option<Duration>,
write_timeout: Option<Duration>,
}
impl Connection {
pub fn connect(endpoint: &Path) -> io::Result<Self> {
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<Self> {
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<Duration>) {
self.read_timeout = timeout;
}
pub fn set_write_timeout(&mut self, timeout: Option<Duration>) {
self.write_timeout = timeout;
}
pub(crate) fn cancellation(&self) -> io::Result<Cancellation> {
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<DisconnectMonitor> {
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<Duration>,
) -> io::Result<usize> {
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<usize> {
self.read_with_timeout(bytes, self.read_timeout)
}
}
impl Write for Connection {
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
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<Pipe>);
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<Pipe>,
overlapped: Retained<OVERLAPPED>,
event: OwnedHandle,
pending: bool,
}
impl Operation {
fn new(pipe: Arc<Pipe>) -> io::Result<Self> {
// 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<Option<u32>> {
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<Option<u32>> {
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<Duration>) -> io::Result<u32> {
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<OwnedHandle> {
// 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<OwnedHandle> {
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<Vec<u16>> {
let value = endpoint.as_os_str().encode_wide().collect::<Vec<_>>();
let prefix = r"\\.\pipe\opencode-pty-".encode_utf16().collect::<Vec<_>>();
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::<u128>()
))
}
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::<Response>(&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());
}
}
}

View file

@ -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<Self> {
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::<Vec<_>>();
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::<SECURITY_ATTRIBUTES>() 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::<ACE_HEADER>()).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::<ACCESS_ALLOWED_ACE>()).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<Self> {
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<bool> {
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<String> {
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<u8>.
let mut buffer = vec![0_usize; (length as usize).div_ceil(size_of::<usize>())];
// 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::<TOKEN_USER>() };
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::<u128>()));
let wide = path
.as_os_str()
.encode_wide()
.chain([0])
.collect::<Vec<_>>();
let sid = current_user_sid().unwrap();
let public = format!("O:{sid}D:P(A;;GA;;;WD)\0")
.encode_utf16()
.collect::<Vec<_>>();
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();
}
}

View file

@ -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<Vec<u8>> {
// 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());
}

View file

@ -0,0 +1 @@
/target

View file

@ -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"

View file

@ -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]

View file

@ -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::<u128>()
));
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::<Registration>(&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<String>) -> (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();
}

View file

@ -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::<i32>()
.unwrap()
})
.collect::<Vec<_>>();
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"));
}

View file

@ -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::<u128>()));
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(&registration.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::<Response>(&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)
);
}

View file

@ -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::<Vec<_>>();
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();
}

View file

@ -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<StreamEvent>| {
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");
}

View file

@ -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();
}
}
}

View file

@ -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<TcpStream>);
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::<Vec<_>>(),
"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::<libc::winsize>::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::<CONSOLE_SCREEN_BUFFER_INFO>::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::<u64>()));
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);
}
}

View file

@ -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<Cell<usize>>,
}
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);
}

View file

@ -0,0 +1 @@
/target/

View file

@ -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"

View file

@ -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]

View file

@ -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<String>) -> (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();
}