Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions .github/workflows/core.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,15 @@ jobs:
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
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
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 }})
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 11 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,17 @@ 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",
] }

[profile.release]
codegen-units = 1
lto = "thin"
Expand Down
32 changes: 23 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,15 +40,28 @@ 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 planned Windows backend keeps protocol 7 and the registration fields
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 will be `\\.\pipe\opencode-pty-<instance_id>`, not a
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.
Named-pipe completion will use explicit bounded, cancellable delivery rather than
pretending to support Unix half-close. This boundary alone does not implement
Windows transport or daemon support.
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. The backend has native Windows tests, but the Windows
daemon entrypoint/registration lifecycle is not enabled yet.

## Architecture

Expand Down Expand Up @@ -235,8 +248,9 @@ 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.

The current service, ownership, playground, and rows integration suites are
Unix-only. A green Windows job verifies compilation and the enabled parser and
protocol tests, not working Windows transport or ConPTY lifecycle support.
Unix-only. Windows library tests also exercise real named-pipe roundtrips,
multiple connections, namespace ownership, cancellation, and final-frame
completion. They do not yet verify Windows daemon or ConPTY lifecycle support.

Once the workflow is on `master`, it can also be run manually against a branch:

Expand Down Expand Up @@ -274,8 +288,8 @@ uses named pipes. Platform signing will be added later.

## Current Limits

- Persistent transport currently uses Unix sockets; Windows named pipes are not
implemented yet.
- The Windows named-pipe backend is tested independently; persistent daemon
startup and private registration storage are still Unix-only.
- 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
Expand Down
5 changes: 3 additions & 2 deletions src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,11 @@ use anyhow::{Context, Result, anyhow, bail};
use base64::Engine;

use crate::daemon::{Registration, read_registration};
#[cfg(unix)]
use crate::protocol::{
AttachmentRole, Envelope, PROTOCOL_VERSION, Request, Response, SubscriptionEvent, read_frame,
read_subscription_event, write_frame,
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;
Expand Down
3 changes: 2 additions & 1 deletion src/daemon/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ pub fn run() -> Result<()> {
}
}

drop(listener);
listener.stop();
// Unblock partial requests, owner reads, and backpressured subscriptions
// before joining. PTY workers still use their existing termination path.
for (cancellation, _) in &handlers {
Expand All @@ -76,6 +76,7 @@ pub fn run() -> Result<()> {
}
drop(service);
platform::cleanup(registration)?;
drop(listener);
let _ = cleanup_tx.send(());
let _ = watchdog.join();
drop(runtime);
Expand Down
2 changes: 1 addition & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@ mod ghostty;
mod ownership;
pub mod protocol;
pub mod service;
#[cfg(unix)]
#[cfg(any(unix, windows))]
mod transport;
8 changes: 8 additions & 0 deletions src/transport/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,11 @@
mod unix;
#[cfg(unix)]
pub(crate) use unix::{Cancellation, Connection, Listener};

#[cfg(windows)]
mod retained;

// The backend is exercised natively before the daemon entrypoint is enabled.
#[cfg(windows)]
#[cfg_attr(not(test), allow(dead_code))]
pub(crate) mod windows;
26 changes: 26 additions & 0 deletions src/transport/retained.rs
Original file line number Diff line number Diff line change
@@ -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()));
}
}
}
13 changes: 10 additions & 3 deletions src/transport/unix.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,21 @@ use std::time::Duration;

use crossbeam_channel::{Receiver, bounded};

pub(crate) struct Listener(UnixListener);
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(listener))
Ok(Self(Some(listener)))
}

/// Poll for a connection without blocking the daemon control path.
pub fn accept(&mut self) -> io::Result<Option<Connection>> {
match self.0.accept() {
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)?;
Expand All @@ -28,6 +31,10 @@ impl Listener {
Err(error) => Err(error),
}
}

pub fn stop(&mut self) {
self.0.take();
}
}

pub(crate) struct Connection(UnixStream);
Expand Down
Loading
Loading