mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-09 05:49:04 +00:00
chore(process-daemon): move opencode-pty into the monorepo (#59694)
This commit is contained in:
parent
92db5e1024
commit
76e1f5b0cc
58 changed files with 12790 additions and 0 deletions
221
.github/workflows/check-process-daemon.yml
vendored
Normal file
221
.github/workflows/check-process-daemon.yml
vendored
Normal 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
2
packages/process-daemon/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
/target
|
||||
/tests/*/target
|
||||
39
packages/process-daemon/AGENTS.md
Normal file
39
packages/process-daemon/AGENTS.md
Normal 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
513
packages/process-daemon/Cargo.lock
generated
Normal 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"
|
||||
45
packages/process-daemon/Cargo.toml
Normal file
45
packages/process-daemon/Cargo.toml
Normal 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"
|
||||
21
packages/process-daemon/LICENSE
Normal file
21
packages/process-daemon/LICENSE
Normal 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.
|
||||
27
packages/process-daemon/PLAN.md
Normal file
27
packages/process-daemon/PLAN.md
Normal 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.
|
||||
388
packages/process-daemon/README.md
Normal file
388
packages/process-daemon/README.md
Normal 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.
|
||||
146
packages/process-daemon/SPEC.md
Normal file
146
packages/process-daemon/SPEC.md
Normal 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.
|
||||
151
packages/process-daemon/build.rs
Normal file
151
packages/process-daemon/build.rs
Normal 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}");
|
||||
}
|
||||
1
packages/process-daemon/ghostty-revision
Normal file
1
packages/process-daemon/ghostty-revision
Normal file
|
|
@ -0,0 +1 @@
|
|||
ab0b9da9e88fcb4b0533a1854e84628f663930af
|
||||
14
packages/process-daemon/npm/bin/opencode-pty.js
Normal file
14
packages/process-daemon/npm/bin/opencode-pty.js
Normal 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)
|
||||
1
packages/process-daemon/npm/index.d.ts
vendored
Normal file
1
packages/process-daemon/npm/index.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
export declare const binaryPath: string | undefined
|
||||
17
packages/process-daemon/npm/index.js
Normal file
17
packages/process-daemon/npm/index.js
Normal 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
|
||||
39
packages/process-daemon/npm/package.json
Normal file
39
packages/process-daemon/npm/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
3
packages/process-daemon/rust-toolchain.toml
Normal file
3
packages/process-daemon/rust-toolchain.toml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
[toolchain]
|
||||
channel = "1.90.0"
|
||||
profile = "minimal"
|
||||
26
packages/process-daemon/script/ghostty-bindings
Executable file
26
packages/process-daemon/script/ghostty-bindings
Executable 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"
|
||||
69
packages/process-daemon/script/npm-packages.mjs
Normal file
69
packages/process-daemon/script/npm-packages.mjs
Normal 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)
|
||||
13
packages/process-daemon/script/release-bin/zig
Executable file
13
packages/process-daemon/script/release-bin/zig
Executable 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" "$@"
|
||||
810
packages/process-daemon/src/client.rs
Normal file
810
packages/process-daemon/src/client.rs
Normal 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(®istration.socket)?;
|
||||
stream.set_read_timeout(Some(START_TIMEOUT))?;
|
||||
stream.set_write_timeout(Some(START_TIMEOUT))?;
|
||||
write_frame(
|
||||
&mut stream,
|
||||
&Envelope {
|
||||
token: registration.token.clone(),
|
||||
request: Request::Own {
|
||||
instance_id: registration.instance_id.clone(),
|
||||
ticket: None,
|
||||
},
|
||||
},
|
||||
)?;
|
||||
return match read_frame(&mut stream)? {
|
||||
Response::Owned => Ok(Self {
|
||||
registration,
|
||||
owner: Some((stream, child)),
|
||||
}),
|
||||
response => unexpected(response),
|
||||
};
|
||||
}
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
bail!("opencode-pty did not become ready")
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
pub fn start(_directory: &Path) -> Result<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)
|
||||
}
|
||||
}
|
||||
129
packages/process-daemon/src/daemon.rs
Normal file
129
packages/process-daemon/src/daemon.rs
Normal 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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
461
packages/process-daemon/src/daemon/server.rs
Normal file
461
packages/process-daemon/src/daemon/server.rs
Normal 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 = ®istration;
|
||||
let ownership = Arc::new(Mutex::new(Ownership::new(Instant::now())));
|
||||
|
||||
let service = Arc::new(TerminalService::default());
|
||||
let shutdown = Arc::new(AtomicBool::new(false));
|
||||
let mut handlers = Vec::<(Cancellation, thread::JoinHandle<()>)>::new();
|
||||
let result = (|| -> Result<()> {
|
||||
while !shutdown.load(Ordering::Acquire) {
|
||||
if ownership
|
||||
.lock()
|
||||
.map_err(|_| anyhow!("ownership lock poisoned"))?
|
||||
.tick(Instant::now())
|
||||
{
|
||||
shutdown.store(true, Ordering::Release);
|
||||
break;
|
||||
}
|
||||
for (_, handler) in handlers.extract_if(.., |(_, handler)| handler.is_finished()) {
|
||||
let _ = handler.join();
|
||||
}
|
||||
match listener.accept() {
|
||||
Ok(Some(stream)) => {
|
||||
let control = stream.cancellation()?;
|
||||
let service = Arc::clone(&service);
|
||||
let shutdown = Arc::clone(&shutdown);
|
||||
let ownership = Arc::clone(&ownership);
|
||||
let registration = registration.clone();
|
||||
let handle = thread::spawn(move || {
|
||||
if let Err(error) = handle_connection(
|
||||
stream,
|
||||
&service,
|
||||
®istration,
|
||||
&shutdown,
|
||||
&ownership,
|
||||
) {
|
||||
eprintln!("opencode-pty request failed: {error:#}");
|
||||
}
|
||||
});
|
||||
handlers.push((control, handle));
|
||||
}
|
||||
Ok(None) => {
|
||||
thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
Err(error) => return Err(error.into()),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})();
|
||||
|
||||
shutdown.store(true, Ordering::Release);
|
||||
listener.stop();
|
||||
// Unblock partial requests, owner reads, and backpressured subscriptions
|
||||
// before joining. PTY workers still use their existing termination path.
|
||||
for (cancellation, _) in &handlers {
|
||||
cancellation.cancel();
|
||||
}
|
||||
let (cleanup_tx, cleanup_rx) = crossbeam_channel::bounded::<()>(1);
|
||||
let cleanup = runtime.cleanup.clone();
|
||||
let watchdog = thread::spawn(move || {
|
||||
if cleanup_rx.recv_timeout(Duration::from_secs(5)).is_err() {
|
||||
eprintln!("opencode-pty cleanup timed out; forcing exit");
|
||||
let _ = cleanup.run();
|
||||
std::process::exit(1);
|
||||
}
|
||||
});
|
||||
service.shutdown();
|
||||
for (_, handler) in handlers {
|
||||
let _ = handler.join();
|
||||
}
|
||||
drop(service);
|
||||
let cleanup = runtime.finish();
|
||||
drop(listener);
|
||||
let _ = cleanup_tx.send(());
|
||||
let _ = watchdog.join();
|
||||
result.and(cleanup)
|
||||
}
|
||||
|
||||
fn handle_connection(
|
||||
mut stream: Connection,
|
||||
service: &TerminalService,
|
||||
registration: &Registration,
|
||||
shutdown: &AtomicBool,
|
||||
ownership: &Mutex<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(),
|
||||
},
|
||||
})
|
||||
}
|
||||
222
packages/process-daemon/src/daemon/sweep.rs
Normal file
222
packages/process-daemon/src/daemon/sweep.rs
Normal 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(®istration).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
directory
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Root {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn later() -> SystemTime {
|
||||
SystemTime::now() + STALE_RUNTIME_AGE + Duration::from_secs(1)
|
||||
}
|
||||
|
||||
fn dead_pid() -> u32 {
|
||||
#[cfg(unix)]
|
||||
let mut child = std::process::Command::new("/usr/bin/true").spawn().unwrap();
|
||||
#[cfg(windows)]
|
||||
let mut child = std::process::Command::new("cmd.exe")
|
||||
.args(["/C", "exit 0"])
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let pid = child.id();
|
||||
child.wait().unwrap();
|
||||
pid
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sweep_removes_only_abandoned_old_runtimes() {
|
||||
let root = Root::new();
|
||||
let abandoned = root.runtime(&[LOCK_FILE, REGISTRATION_FILE], None);
|
||||
let young = root.runtime(&[LOCK_FILE], None);
|
||||
let foreign = root.runtime(&[LOCK_FILE, "notes.txt"], None);
|
||||
let locked = root.runtime(&[LOCK_FILE], None);
|
||||
let held = fs::File::open(locked.join(LOCK_FILE)).unwrap();
|
||||
held.try_lock_exclusive().unwrap();
|
||||
|
||||
runtime(&abandoned, later()).unwrap();
|
||||
runtime(&young, SystemTime::now()).unwrap();
|
||||
runtime(&foreign, later()).unwrap();
|
||||
runtime(&locked, later()).unwrap();
|
||||
|
||||
assert!(!abandoned.exists());
|
||||
assert!(young.join(LOCK_FILE).exists());
|
||||
assert!(foreign.join("notes.txt").exists());
|
||||
assert!(locked.join(LOCK_FILE).exists());
|
||||
drop(held);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sweep_without_lock_file_checks_registered_pid() {
|
||||
let root = Root::new();
|
||||
let live = root.runtime(&[], Some(std::process::id()));
|
||||
let dead = root.runtime(&[], Some(dead_pid()));
|
||||
|
||||
runtime(&live, later()).unwrap();
|
||||
runtime(&dead, later()).unwrap();
|
||||
|
||||
assert!(live.join(REGISTRATION_FILE).exists());
|
||||
assert!(!dead.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sweep_keeps_directories_without_daemon_evidence() {
|
||||
let root = Root::new();
|
||||
let empty = root.runtime(&[], None);
|
||||
let foreign = root.runtime(&[], None);
|
||||
fs::write(foreign.join(REGISTRATION_FILE), br#"{"not":"ours"}"#).unwrap();
|
||||
let temporary = root.runtime(&["service.important.tmp"], None);
|
||||
|
||||
for directory in [&empty, &foreign, &temporary] {
|
||||
runtime(directory, later()).unwrap();
|
||||
assert!(directory.exists(), "{}", directory.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
261
packages/process-daemon/src/daemon/unix.rs
Normal file
261
packages/process-daemon/src/daemon/unix.rs
Normal 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, ®istration)?;
|
||||
let sweeper = {
|
||||
let directory = directory.clone();
|
||||
thread::spawn(move || super::sweep::stale_runtimes(&directory))
|
||||
};
|
||||
Ok((
|
||||
Self {
|
||||
cleanup: Cleanup {
|
||||
directory,
|
||||
registration: registration.clone(),
|
||||
socket,
|
||||
},
|
||||
registration,
|
||||
sweeper,
|
||||
lock,
|
||||
},
|
||||
listener,
|
||||
))
|
||||
}
|
||||
|
||||
/// Removes this daemon's runtime state after the server has joined its handlers.
|
||||
pub fn finish(self) -> Result<()> {
|
||||
let _ = self.sweeper.join();
|
||||
let result = self.cleanup.run();
|
||||
drop(self.lock);
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes only state that still belongs to this daemon instance.
|
||||
#[derive(Clone)]
|
||||
pub(super) struct Cleanup {
|
||||
directory: PathBuf,
|
||||
registration: Registration,
|
||||
socket: SocketFile,
|
||||
}
|
||||
|
||||
impl Cleanup {
|
||||
pub fn run(&self) -> Result<()> {
|
||||
let result = remove_if_current(&self.directory, &self.registration);
|
||||
self.socket.remove_if_current();
|
||||
// Remove the lock file before releasing it: a daemon that already opened it
|
||||
// fails to lock it, and a later one creates a new lock file.
|
||||
let _ = fs::remove_file(self.directory.join(LOCK_FILE));
|
||||
let _ = fs::remove_dir(&self.directory);
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
/// The bound socket's inode, so cleanup never unlinks a successor's socket.
|
||||
#[derive(Clone)]
|
||||
struct SocketFile {
|
||||
path: PathBuf,
|
||||
device: u64,
|
||||
inode: u64,
|
||||
}
|
||||
|
||||
impl SocketFile {
|
||||
fn bound(path: PathBuf) -> Result<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();
|
||||
}
|
||||
}
|
||||
403
packages/process-daemon/src/daemon/windows.rs
Normal file
403
packages/process-daemon/src/daemon/windows.rs
Normal 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(
|
||||
®istration_path(directory),
|
||||
GENERIC_READ | READ_CONTROL,
|
||||
OPEN_EXISTING,
|
||||
FILE_SHARE_READ | FILE_SHARE_DELETE,
|
||||
&security,
|
||||
)
|
||||
.context("opencode-pty registration is unavailable")?;
|
||||
check_kind(&file, false)?;
|
||||
security.check_private(file.as_raw_handle())?;
|
||||
let mut data = Vec::new();
|
||||
// Registration is tiny; do not allocate an unbounded stale/corrupt file.
|
||||
Read::by_ref(&mut file)
|
||||
.take(16 * 1024 + 1)
|
||||
.read_to_end(&mut data)?;
|
||||
if data.len() > 16 * 1024 {
|
||||
bail!("PTY registration is too large");
|
||||
}
|
||||
serde_json::from_slice(&data).context("invalid opencode-pty registration")
|
||||
}
|
||||
|
||||
pub(super) struct Runtime {
|
||||
pub registration: Registration,
|
||||
pub cleanup: Cleanup,
|
||||
sweeper: JoinHandle<()>,
|
||||
// Neither handle shares delete access, so the private directory and lock
|
||||
// cannot be replaced while the daemon runs.
|
||||
lock: File,
|
||||
directory: File,
|
||||
}
|
||||
|
||||
impl Runtime {
|
||||
pub fn bind(directory: &Path) -> Result<(Self, Listener)> {
|
||||
if !directory.is_absolute() {
|
||||
bail!("PTY runtime directory must be an absolute path");
|
||||
}
|
||||
let security = PrivateSecurity::new()?;
|
||||
if let Some(parent) = directory.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
let wide = wide_path(directory)?;
|
||||
// SAFETY: wide path and private descriptor are valid through creation.
|
||||
if unsafe { CreateDirectoryW(wide.as_ptr(), &security.attributes()) } == 0 {
|
||||
let error = std::io::Error::last_os_error();
|
||||
if error.raw_os_error() != Some(ERROR_ALREADY_EXISTS as i32) {
|
||||
return Err(error.into());
|
||||
}
|
||||
}
|
||||
let directory_handle = open(
|
||||
directory,
|
||||
FILE_READ_ATTRIBUTES | FILE_TRAVERSE | READ_CONTROL | WRITE_DAC,
|
||||
OPEN_EXISTING,
|
||||
FILE_SHARE_READ | FILE_SHARE_WRITE,
|
||||
&security,
|
||||
)?;
|
||||
check_kind(&directory_handle, true)?;
|
||||
security.make_private(directory_handle.as_raw_handle())?;
|
||||
// All later paths come from the held handle, never the caller's path.
|
||||
let directory = held_directory_path(&directory_handle)?;
|
||||
let lock = open(
|
||||
&directory.join(LOCK_FILE),
|
||||
GENERIC_READ | GENERIC_WRITE | READ_CONTROL | WRITE_DAC,
|
||||
OPEN_ALWAYS,
|
||||
FILE_SHARE_READ | FILE_SHARE_WRITE,
|
||||
&security,
|
||||
)?;
|
||||
check_kind(&lock, false)?;
|
||||
security.make_private(lock.as_raw_handle())?;
|
||||
lock.try_lock_exclusive()
|
||||
.context("another opencode-pty process already owns the service lock")?;
|
||||
|
||||
let instance_id = format!("{:032x}", rand::random::<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(®istration.socket)?;
|
||||
write_registration(&directory, &directory_handle, ®istration, &security)?;
|
||||
let sweeper = {
|
||||
let directory = directory.clone();
|
||||
thread::spawn(move || sweep::stale_runtimes(&directory))
|
||||
};
|
||||
Ok((
|
||||
Self {
|
||||
cleanup: Cleanup {
|
||||
directory,
|
||||
instance_id: registration.instance_id.clone(),
|
||||
},
|
||||
registration,
|
||||
sweeper,
|
||||
lock,
|
||||
directory: directory_handle,
|
||||
},
|
||||
listener,
|
||||
))
|
||||
}
|
||||
|
||||
/// Removes this daemon's runtime state after the server has joined its
|
||||
/// handlers. The caller keeps the pipe namespace until this returns.
|
||||
pub fn finish(self) -> Result<()> {
|
||||
let _ = self.sweeper.join();
|
||||
let result = self.cleanup.run();
|
||||
let directory = self.cleanup.directory;
|
||||
// The protected handles deny deletion; release them first. A daemon that
|
||||
// races in and locks a new lock file simply keeps the directory.
|
||||
drop(self.lock);
|
||||
drop(self.directory);
|
||||
let _ = fs::remove_file(directory.join(LOCK_FILE));
|
||||
let _ = fs::remove_dir(&directory);
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes the registration only while it still belongs to this instance.
|
||||
#[derive(Clone)]
|
||||
pub(super) struct Cleanup {
|
||||
directory: PathBuf,
|
||||
instance_id: String,
|
||||
}
|
||||
|
||||
impl Cleanup {
|
||||
pub fn run(&self) -> Result<()> {
|
||||
if read_registration(&self.directory)
|
||||
.is_ok_and(|current| current.instance_id == self.instance_id)
|
||||
{
|
||||
fs::remove_file(registration_path(&self.directory))?;
|
||||
}
|
||||
// A named pipe is not a filesystem entry. Its listener remains held until
|
||||
// this registration cleanup has completed, then all handles are closed.
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn process_exists(pid: u32) -> bool {
|
||||
// SAFETY: a successful handle is adopted immediately; it is only waited on.
|
||||
let handle = unsafe { OpenProcess(PROCESS_SYNCHRONIZE, 0, pid) };
|
||||
if handle.is_null() {
|
||||
// Another user's live process cannot be opened; treat it as live.
|
||||
return std::io::Error::last_os_error().raw_os_error() == Some(ERROR_ACCESS_DENIED as i32);
|
||||
}
|
||||
// SAFETY: OpenProcess returned a new, uniquely owned handle.
|
||||
let handle = unsafe { OwnedHandle::from_raw_handle(handle) };
|
||||
// A failed wait is treated as live, so the sweep never deletes on doubt.
|
||||
// SAFETY: the owned process handle stays valid through this zero-timeout wait.
|
||||
unsafe { WaitForSingleObject(handle.as_raw_handle(), 0) != WAIT_OBJECT_0 }
|
||||
}
|
||||
|
||||
/// Named pipes vanish with their server process; there is no endpoint to remove.
|
||||
pub(super) fn remove_stale_endpoint(_canonical_directory: &Path) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_registration(
|
||||
directory: &Path,
|
||||
directory_handle: &File,
|
||||
registration: &Registration,
|
||||
security: &PrivateSecurity,
|
||||
) -> Result<()> {
|
||||
let temporary = directory.join(format!("service.{}.tmp", registration.instance_id));
|
||||
let result = (|| -> Result<()> {
|
||||
let mut file = open(
|
||||
&temporary,
|
||||
GENERIC_WRITE | READ_CONTROL | DELETE,
|
||||
CREATE_NEW,
|
||||
0,
|
||||
security,
|
||||
)?;
|
||||
file.write_all(&serde_json::to_vec_pretty(registration)?)?;
|
||||
file.sync_all()?;
|
||||
publish(&file, directory_handle)
|
||||
})();
|
||||
if result.is_err() {
|
||||
let _ = fs::remove_file(temporary);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn publish(file: &File, directory: &File) -> Result<()> {
|
||||
// The Win32 wrapper rejects a non-null RootDirectory, unlike the native
|
||||
// NtSetInformationFile interface. Resolve the full destination from the held
|
||||
// directory handle, never from the unvalidated caller path.
|
||||
let name = held_directory_path(directory)?
|
||||
.join(REGISTRATION_FILE)
|
||||
.as_os_str()
|
||||
.encode_wide()
|
||||
.collect::<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, ®istration, &security).unwrap();
|
||||
let path = directory.join(REGISTRATION_FILE);
|
||||
let mut old_reader = open(
|
||||
&path,
|
||||
GENERIC_READ | READ_CONTROL,
|
||||
OPEN_EXISTING,
|
||||
FILE_SHARE_READ | FILE_SHARE_DELETE,
|
||||
&security,
|
||||
)
|
||||
.unwrap();
|
||||
security.check_private(old_reader.as_raw_handle()).unwrap();
|
||||
registration.instance_id = "second".into();
|
||||
write_registration(&directory, &directory_handle, ®istration, &security).unwrap();
|
||||
assert_eq!(read_registration(&directory).unwrap().instance_id, "second");
|
||||
let mut old = String::new();
|
||||
old_reader.read_to_string(&mut old).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_str::<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));
|
||||
}
|
||||
}
|
||||
21
packages/process-daemon/src/ghostty/LICENSE
Normal file
21
packages/process-daemon/src/ghostty/LICENSE
Normal 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.
|
||||
90
packages/process-daemon/src/ghostty/effects.rs
Normal file
90
packages/process-daemon/src/ghostty/effects.rs
Normal 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);
|
||||
655
packages/process-daemon/src/ghostty/ffi.rs
Normal file
655
packages/process-daemon/src/ghostty/ffi.rs
Normal 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);
|
||||
}
|
||||
416
packages/process-daemon/src/ghostty/mod.rs
Normal file
416
packages/process-daemon/src/ghostty/mod.rs
Normal 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;
|
||||
98
packages/process-daemon/src/ghostty/tests.rs
Normal file
98
packages/process-daemon/src/ghostty/tests.rs
Normal 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()])
|
||||
);
|
||||
}
|
||||
11
packages/process-daemon/src/lib.rs
Normal file
11
packages/process-daemon/src/lib.rs
Normal 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;
|
||||
6
packages/process-daemon/src/main.rs
Normal file
6
packages/process-daemon/src/main.rs
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
fn main() {
|
||||
if let Err(error) = opencode_pty::client::run_cli() {
|
||||
eprintln!("error: {error:#}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
59
packages/process-daemon/src/musl.rs
Normal file
59
packages/process-daemon/src/musl.rs
Normal 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
198
packages/process-daemon/src/ownership.rs
Normal file
198
packages/process-daemon/src/ownership.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
287
packages/process-daemon/src/protocol.rs
Normal file
287
packages/process-daemon/src/protocol.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
2496
packages/process-daemon/src/service.rs
Normal file
2496
packages/process-daemon/src/service.rs
Normal file
File diff suppressed because it is too large
Load diff
77
packages/process-daemon/src/service/windows.rs
Normal file
77
packages/process-daemon/src/service/windows.rs
Normal 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()),
|
||||
));
|
||||
}
|
||||
24
packages/process-daemon/src/transport/mod.rs
Normal file
24
packages/process-daemon/src/transport/mod.rs
Normal 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};
|
||||
26
packages/process-daemon/src/transport/retained.rs
Normal file
26
packages/process-daemon/src/transport/retained.rs
Normal 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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
206
packages/process-daemon/src/transport/unix.rs
Normal file
206
packages/process-daemon/src/transport/unix.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
742
packages/process-daemon/src/transport/windows.rs
Normal file
742
packages/process-daemon/src/transport/windows.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
287
packages/process-daemon/src/transport/windows/security.rs
Normal file
287
packages/process-daemon/src/transport/windows/security.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
107
packages/process-daemon/tests/ghostty-effects.rs
Normal file
107
packages/process-daemon/tests/ghostty-effects.rs
Normal 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());
|
||||
}
|
||||
1
packages/process-daemon/tests/ghostty-effects/.gitignore
vendored
Normal file
1
packages/process-daemon/tests/ghostty-effects/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/target
|
||||
7
packages/process-daemon/tests/ghostty-effects/Cargo.lock
generated
Normal file
7
packages/process-daemon/tests/ghostty-effects/Cargo.lock
generated
Normal 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"
|
||||
13
packages/process-daemon/tests/ghostty-effects/Cargo.toml
Normal file
13
packages/process-daemon/tests/ghostty-effects/Cargo.toml
Normal 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]
|
||||
447
packages/process-daemon/tests/ownership.rs
Normal file
447
packages/process-daemon/tests/ownership.rs
Normal 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();
|
||||
}
|
||||
220
packages/process-daemon/tests/playground.rs
Normal file
220
packages/process-daemon/tests/playground.rs
Normal 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"));
|
||||
}
|
||||
124
packages/process-daemon/tests/rows.rs
Normal file
124
packages/process-daemon/tests/rows.rs
Normal 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(®istration.socket).unwrap();
|
||||
write_frame(
|
||||
&mut owner,
|
||||
&Envelope {
|
||||
token: registration.token,
|
||||
request: Request::Own {
|
||||
instance_id: registration.instance_id,
|
||||
ticket: None,
|
||||
},
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
read_frame::<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)
|
||||
);
|
||||
}
|
||||
500
packages/process-daemon/tests/runtime.rs
Normal file
500
packages/process-daemon/tests/runtime.rs
Normal 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();
|
||||
}
|
||||
467
packages/process-daemon/tests/service.rs
Normal file
467
packages/process-daemon/tests/service.rs
Normal 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");
|
||||
}
|
||||
136
packages/process-daemon/tests/support/resize_fixture.rs
Normal file
136
packages/process-daemon/tests/support/resize_fixture.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
283
packages/process-daemon/tests/support/terminal_fixture.rs
Normal file
283
packages/process-daemon/tests/support/terminal_fixture.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
49
packages/process-daemon/tests/transport-retained.rs
Normal file
49
packages/process-daemon/tests/transport-retained.rs
Normal 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);
|
||||
}
|
||||
1
packages/process-daemon/tests/transport-retained/.gitignore
vendored
Normal file
1
packages/process-daemon/tests/transport-retained/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/target/
|
||||
7
packages/process-daemon/tests/transport-retained/Cargo.lock
generated
Normal file
7
packages/process-daemon/tests/transport-retained/Cargo.lock
generated
Normal 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"
|
||||
11
packages/process-daemon/tests/transport-retained/Cargo.toml
Normal file
11
packages/process-daemon/tests/transport-retained/Cargo.toml
Normal 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]
|
||||
696
packages/process-daemon/tests/windows-daemon.rs
Normal file
696
packages/process-daemon/tests/windows-daemon.rs
Normal 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();
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue