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
9 changes: 7 additions & 2 deletions .github/workflows/windows.yml
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,10 @@ jobs:
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: |
Expand All @@ -145,9 +149,10 @@ jobs:

Target: $env:CARGO_BUILD_TARGET

This job builds the executable and runs all enabled tests natively, including libghostty parser, protocol, and direct TerminalService ConPTY runtime tests.
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.
A passing run does not imply Windows named-pipe transport or complete ConPTY shutdown coverage is implemented.
Basic tests do not establish exhaustive lifecycle coverage or Windows interactive CLI parity.
No packages or releases are published.
"@ >> $env:GITHUB_STEP_SUMMARY
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ windows-sys = { version = "0.61.2", features = [
"Win32_System_IO",
"Win32_System_Pipes",
"Win32_System_Threading",
"Win32_System_WindowsProgramming",
] }

[target.'cfg(windows)'.dev-dependencies]
Expand Down
43 changes: 31 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,19 @@ 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 Unix socket and atomic registration file.
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` (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 their existing separate sockets.
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
socket. The response is `{"type":"handoff","ticket":"...","expires_at":123}`,
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. After the
old owner disconnects, a successor claims the same instance with the ticket in
Expand Down Expand Up @@ -60,8 +61,22 @@ 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.
squatting during cleanup.

On Windows, `opencode-pty daemon` stores `service.json` and `service.lock` in
`%LOCALAPPDATA%\opencode-pty`, or an absolute `OPENCODE_PTY_RUNTIME_DIR` override.
Missing `LOCALAPPDATA` without an override is an error. Storage entries must not
be reparse points or owned by another user; the directory, lock, and registration
use protected current-user-only ACLs. 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.

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 plus optional read/write timeouts). On Windows, `service_dir()`
and `registration_path()` are fallible because runtime storage must be resolved
without an insecure fallback.

## Architecture

Expand Down Expand Up @@ -263,11 +278,15 @@ 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 older service, ownership, playground, and rows integration suites remain
Unix-only. Windows library tests also exercise real named-pipe roundtrips,
multiple connections, namespace ownership, cancellation, and final-frame
completion. These checks do not yet establish Windows daemon support or
complete ConPTY shutdown behavior.
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
Expand Down Expand Up @@ -322,8 +341,8 @@ uses named pipes. Platform signing will be added later.

## Current Limits

- The Windows named-pipe backend is tested independently; persistent daemon
startup and private registration storage are still Unix-only.
- 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
Expand Down
20 changes: 14 additions & 6 deletions src/daemon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,25 +18,33 @@ pub struct Registration {
#[cfg(unix)]
#[path = "daemon/unix.rs"]
mod platform;
#[cfg(unix)]
#[cfg(windows)]
#[path = "daemon/windows.rs"]
mod platform;
#[cfg(any(unix, windows))]
mod server;

#[cfg(unix)]
#[cfg(any(unix, windows))]
pub use platform::{read_registration, registration_path, service_dir};
#[cfg(unix)]
#[cfg(any(unix, windows))]
pub use server::run;

#[cfg(not(unix))]
/// 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() -> anyhow::Result<()> {
anyhow::bail!("persistent opencode-pty transport is not implemented on this platform")
}

#[cfg(not(unix))]
#[cfg(not(any(unix, windows)))]
pub fn read_registration() -> anyhow::Result<Registration> {
anyhow::bail!("persistent opencode-pty transport is not implemented on this platform")
}

#[cfg(not(unix))]
#[cfg(not(any(unix, windows)))]
pub fn registration_path() -> PathBuf {
PathBuf::from("opencode-pty-service.json")
}
74 changes: 41 additions & 33 deletions src/daemon/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,41 +20,49 @@ pub fn run() -> Result<()> {
let service = Arc::new(TerminalService::default());
let shutdown = Arc::new(AtomicBool::new(false));
let mut handlers = Vec::<(Cancellation, thread::JoinHandle<()>)>::new();
while !shutdown.load(Ordering::Acquire) {
if ownership
.lock()
.map_err(|_| anyhow!("ownership lock poisoned"))?
.tick(Instant::now())
{
shutdown.store(true, Ordering::Release);
break;
}
for (_, handler) in handlers.extract_if(.., |(_, handler)| handler.is_finished()) {
let _ = handler.join();
}
match listener.accept() {
Ok(Some(stream)) => {
let control = stream.cancellation()?;
let service = Arc::clone(&service);
let shutdown = Arc::clone(&shutdown);
let ownership = Arc::clone(&ownership);
let registration = registration.clone();
let handle = thread::spawn(move || {
if let Err(error) =
handle_connection(stream, &service, &registration, &shutdown, &ownership)
{
eprintln!("opencode-pty request failed: {error:#}");
}
});
handlers.push((control, handle));
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();
}
Ok(None) => {
thread::sleep(Duration::from_millis(10));
match listener.accept() {
Ok(Some(stream)) => {
let control = stream.cancellation()?;
let service = Arc::clone(&service);
let shutdown = Arc::clone(&shutdown);
let ownership = Arc::clone(&ownership);
let registration = registration.clone();
let handle = thread::spawn(move || {
if let Err(error) = handle_connection(
stream,
&service,
&registration,
&shutdown,
&ownership,
) {
eprintln!("opencode-pty request failed: {error:#}");
}
});
handlers.push((control, handle));
}
Ok(None) => {
thread::sleep(Duration::from_millis(10));
}
Err(error) => return Err(error.into()),
}
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.
Expand All @@ -75,12 +83,12 @@ pub fn run() -> Result<()> {
let _ = handler.join();
}
drop(service);
platform::cleanup(registration)?;
let cleanup = platform::cleanup(registration);
drop(listener);
let _ = cleanup_tx.send(());
let _ = watchdog.join();
drop(runtime);
Ok(())
result.and(cleanup)
}

fn handle_connection(
Expand Down
Loading
Loading