Skip to content
Merged
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 daemon support 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
58 changes: 44 additions & 14 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 --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 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. A successor
claims the same instance with the ticket in its `own` request, even while the old
Expand Down Expand Up @@ -74,8 +75,33 @@ 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 --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

Expand Down Expand Up @@ -278,11 +304,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 @@ -332,13 +362,13 @@ git tag v0.1.0
push origin v0.1.0
```

Windows artifacts are intentionally excluded until the persistent transport
uses named pipes. Platform signing will be added later.
Release artifacts do not yet include Windows builds. 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
10 changes: 10 additions & 0 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,16 @@ 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
Expand Down
31 changes: 24 additions & 7 deletions src/daemon.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use std::path::{Path, PathBuf};

use anyhow::{Context, Result, bail};
use anyhow::{Result, bail};
use serde::{Deserialize, Serialize};

pub const REGISTRATION_FILE: &str = "service.json";
Expand Down Expand Up @@ -57,21 +57,38 @@ pub fn registration_path(directory: &Path) -> PathBuf {
}

pub fn read_registration(directory: &Path) -> Result<Registration> {
let data = std::fs::read(registration_path(directory))
.context("opencode-pty registration is unavailable")?;
serde_json::from_slice(&data).context("invalid opencode-pty 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(unix)]
#[cfg(windows)]
#[path = "daemon/windows.rs"]
mod platform;
#[cfg(any(unix, windows))]
mod server;
#[cfg(any(unix, windows))]
mod sweep;

#[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(_directory: &Path) -> Result<()> {
anyhow::bail!("persistent opencode-pty transport is not implemented on this platform")
}
Expand Down
74 changes: 41 additions & 33 deletions src/daemon/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,41 +22,49 @@ pub fn run(directory: &Path) -> 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;
}
Ok(None) => {
thread::sleep(Duration::from_millis(10));
for (_, handler) in handlers.extract_if(.., |(_, handler)| handler.is_finished()) {
let _ = handler.join();
}
match listener.accept() {
Ok(Some(stream)) => {
let control = stream.cancellation()?;
let service = Arc::clone(&service);
let shutdown = Arc::clone(&shutdown);
let ownership = Arc::clone(&ownership);
let registration = registration.clone();
let handle = thread::spawn(move || {
if let Err(error) = handle_connection(
stream,
&service,
&registration,
&shutdown,
&ownership,
) {
eprintln!("opencode-pty request failed: {error:#}");
}
});
handlers.push((control, handle));
}
Ok(None) => {
thread::sleep(Duration::from_millis(10));
}
Err(error) => return Err(error.into()),
}
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 @@ -77,11 +85,11 @@ pub fn run(directory: &Path) -> Result<()> {
let _ = handler.join();
}
drop(service);
let result = runtime.finish();
let cleanup = runtime.finish();
drop(listener);
let _ = cleanup_tx.send(());
let _ = watchdog.join();
result
result.and(cleanup)
}

fn handle_connection(
Expand Down
Loading
Loading