Skip to content
Draft
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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,7 @@ jobs:
-ExePath $exe `
-Session "ci-conpty-invalid-windows-2022-$env:GITHUB_RUN_ID-$env:GITHUB_RUN_ATTEMPT"
} catch {
if ($_.Exception.Message -notlike "workspace create failed with exit code*") {
if ($_.Exception.Message -notlike "*Herdr's app-local ConPTY bundle is invalid:*") {
throw
}
$rejected = $true
Expand Down
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ windows-sys = { version = "0.61.2", features = [
"Win32_System_Memory",
"Win32_System_Ole",
"Win32_System_Pipes",
"Win32_System_RemoteDesktop",
"Win32_System_Threading",
"Win32_UI_Input_Ime",
"Win32_UI_Input_KeyboardAndMouse",
Expand Down
2 changes: 2 additions & 0 deletions docs/next/website/src/content/docs/session-state.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ Unlike snapshot restore, pane history replay, and native agent session restore,

A successful handoff preserves long-lived server-owned session state: pane PTYs and processes, agent identity and durable metadata, and plugin/session state needed by the replacement server. It does not preserve transient coordination across the replacement boundary. In-flight CLI or API requests, waits, subscription streams, client sockets, and pane-to-pane messages may be interrupted; clients should reconnect and retry them.

On Windows, live handoff requires the bundled ConPTY runtime. It is unavailable when Herdr uses the system runtime, including servers started with `HERDR_WINDOWS_CONPTY=system`.

Live handoff is experimental and opt-in:

```bash
Expand Down
4 changes: 2 additions & 2 deletions docs/next/website/src/content/docs/windows-beta.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ For internal testing, `HERDR_MANIFEST_URL` can point the installer at a custom m
| Capability | Status |
| --- | --- |
| Local persistent sessions | supported |
| Live server handoff | experimental; requires the bundled ConPTY runtime |
| Native panes through ConPTY | supported |
| Windows Terminal / PowerShell app attach | supported |
| `herdr --remote` and saved SSH machines to Linux/macOS/Windows hosts | supported; interactive attach and saved-machine setup can install or update Windows packages after confirmation; background reconnect only discovers installed packages |
Expand Down Expand Up @@ -118,7 +119,6 @@ For text paste, use `ctrl+shift+v` in Windows Terminal. Multiline text paste is
| Capability | Status |
| --- | --- |
| Direct terminal attach (`herdr terminal attach`) | unsupported |
| Live server handoff | unsupported |
| Unix file-descriptor handoff | unsupported |
| Unix foreground process groups | unsupported |
| Herdr clipboard image bridge in local native panes | unsupported |
Expand All @@ -132,7 +132,7 @@ herdr --remote workbox

The target host can run Linux, macOS, or Windows. On Windows hosts, remote attach reuses a compatible package from `PATH` or the active managed release. Interactive direct attach and saved-machine setup prompt before installing or updating the complete package when needed. Background saved reconnect only discovers installed packages and cannot prompt for installation or updates. Herdr uses the installed Windows OpenSSH client and your SSH configuration. Windows OpenSSH does not use Herdr's Unix control-socket reuse, so key authentication through Windows `ssh-agent` is recommended to avoid repeated prompts during remote setup.

Windows updates run through the Windows installer and update the active versioned release path. New terminals and reconnected SSH sessions receive that path; start Herdr there to use the updated client. Compatible running servers keep their panes alive. Restart a server later only when you need server-side changes from the release. Live handoff is Unix-only.
Windows updates run through the Windows installer and update the active versioned release path. New terminals and reconnected SSH sessions receive that path; start Herdr there to use the updated client. Compatible running servers keep their panes alive. Restart a server later only when you need server-side changes from the release, or use experimental live handoff to replace it while preserving pane processes. Live handoff requires the bundled ConPTY runtime; servers started with `HERDR_WINDOWS_CONPTY=system` cannot use it.

## Reporting Windows issues

Expand Down
10 changes: 8 additions & 2 deletions scripts/windows_smoke_conpty_path.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,15 @@ try {
throw "command failed with exit code $LASTEXITCODE`: $exe --default-config"
}

$server = Start-Process -FilePath $exe -ArgumentList "server" -PassThru -WindowStyle Hidden
$serverErrorPath = Join-Path $fakeDir "server-stderr.log"
$server = Start-Process -FilePath $exe -ArgumentList "server" -PassThru -WindowStyle Hidden -RedirectStandardError $serverErrorPath
$deadline = (Get-Date).AddSeconds(10)
do {
Start-Sleep -Milliseconds 250
if ($server.HasExited -and $server.ExitCode -ne 0) {
$serverError = Get-Content -LiteralPath $serverErrorPath -Raw
throw "server exited with exit code $($server.ExitCode) before becoming ready: $serverError"
}
$status = & $exe status server 2>&1
if ($LASTEXITCODE -eq 0 -and (($status -join "`n") -match "status: running")) {
break
Expand All @@ -96,7 +101,8 @@ try {
$ErrorActionPreference = $savedErrorActionPreference
}
if ($createdExitCode -ne 0) {
throw "workspace create failed with exit code $createdExitCode`: $($created -join "`n")"
$serverError = Get-Content -LiteralPath $serverErrorPath -Raw
throw "workspace create failed with exit code $createdExitCode`: $($created -join "`n")`n$serverError"
}
$paneId = (($created -join "`n") | ConvertFrom-Json).result.root_pane.pane_id
if ([string]::IsNullOrWhiteSpace($paneId)) {
Expand Down
2 changes: 2 additions & 0 deletions src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ mod subscriptions;
mod wait;

pub use event_hub::EventHub;
#[cfg(windows)]
pub(crate) use server::start_server_from_handoff;
pub use server::ServerHandle;
pub(crate) use server::{api_method_name, start_server_with_stop_control};
pub use status::{read_runtime_status_at, RuntimeStatus};
Expand Down
195 changes: 157 additions & 38 deletions src/api/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,12 @@ use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};

use interprocess::local_socket::traits::{ListenerExt as _, Stream as _};
use tracing::{debug, error, info, warn};
#[cfg(unix)]
use interprocess::local_socket::traits::ListenerExt as _;
use interprocess::local_socket::traits::Stream as _;
#[cfg(unix)]
use tracing::error;
use tracing::{debug, info, warn};

#[cfg(all(test, unix))]
use std::fs;
Expand Down Expand Up @@ -34,8 +38,10 @@ const MAX_INITIAL_REQUEST_BYTES: usize = 1024 * 1024;
pub struct ServerHandle {
_thread: std::thread::JoinHandle<()>,
path: PathBuf,
identity: SocketFileIdentity,
identity: Option<SocketFileIdentity>,
running: Arc<AtomicBool>,
#[cfg(windows)]
listener_control: crate::platform::WindowsListenerControl,
}

impl Drop for ServerHandle {
Expand All @@ -52,7 +58,33 @@ impl Drop for ServerHandle {

impl ServerHandle {
pub(crate) fn remove_socket_file_if_owned(&self) -> std::io::Result<()> {
remove_socket_file_if_owned(&self.path, &self.identity)
self.identity.as_ref().map_or(Ok(()), |identity| {
remove_socket_file_if_owned(&self.path, identity)
})
}

#[cfg(windows)]
pub(crate) fn pause_listener_for_handoff(&self) -> std::io::Result<()> {
self.listener_control.pause()
}

#[cfg(windows)]
pub(crate) fn duplicate_listener_for_handoff(
&self,
target: &std::process::Child,
) -> std::io::Result<crate::platform::WindowsListenerHandoff> {
self.listener_control.duplicate_for_handoff(target)
}

#[cfg(windows)]
pub(crate) fn resume_listener_after_handoff(&self) -> std::io::Result<()> {
self.listener_control.resume()
}

#[cfg(windows)]
pub(crate) fn release_listener_after_handoff(&mut self) -> std::io::Result<()> {
self.identity = None;
self.listener_control.release_after_commit()
}
}

Expand All @@ -65,8 +97,11 @@ pub(crate) fn start_server_with_stop_control(
}

fn default_capabilities() -> Option<ServerCapabilities> {
let live_handoff = crate::platform::capabilities().live_handoff;
#[cfg(windows)]
let live_handoff = live_handoff && crate::pty::backend::windows_handoff_available();
Some(ServerCapabilities {
live_handoff: crate::platform::capabilities().live_handoff,
live_handoff,
detached_server_daemon: crate::platform::current_process_is_detached_server_daemon(),
endpoint_protocol_generation: Some(crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION),
surface_interest: true,
Expand All @@ -88,45 +123,129 @@ fn start_server_inner(
let identity = socket_file_identity(&path)?;
info!(path = %path.display(), "api server listening");

let running = Arc::new(AtomicBool::new(true));
let listener_running = Arc::clone(&running);
let thread = std::thread::spawn(move || {
for stream in listener.incoming() {
match stream {
Ok(stream) => {
let api_tx = api_tx.clone();
let event_hub = event_hub.clone();
let capabilities = capabilities.clone();
let server_stop = server_stop.clone();
let connection_running = Arc::clone(&listener_running);
std::thread::spawn(move || {
if let Err(err) = handle_connection_with_stop(
stream,
&api_tx,
&event_hub,
&connection_running,
capabilities,
server_stop.as_ref(),
) {
warn!(err = %err, "api connection failed");
}
});
}
Err(err) => {
error!(err = %err, "api listener accept failed");
break;
#[cfg(windows)]
{
let listener = crate::platform::TransferableLocalListener::bound(listener)?;
Ok(start_windows_server_on_listener(
listener,
path,
identity,
api_tx,
event_hub,
capabilities,
server_stop,
))
}

#[cfg(unix)]
{
let running = Arc::new(AtomicBool::new(true));
let listener_running = Arc::clone(&running);
let thread = std::thread::spawn(move || {
for stream in listener.incoming() {
match stream {
Ok(stream) => {
let api_tx = api_tx.clone();
let event_hub = event_hub.clone();
let capabilities = capabilities.clone();
let server_stop = server_stop.clone();
let connection_running = Arc::clone(&listener_running);
std::thread::spawn(move || {
if let Err(err) = handle_connection_with_stop(
stream,
&api_tx,
&event_hub,
&connection_running,
capabilities,
server_stop.as_ref(),
) {
warn!(err = %err, "api connection failed");
}
});
}
Err(err) => {
error!(err = %err, "api listener accept failed");
break;
}
}
}
}
debug!("api server thread exiting");
});
debug!("api server thread exiting");
});

Ok(ServerHandle {
_thread: thread,
Ok(ServerHandle {
_thread: thread,
path,
identity: Some(identity),
running,
})
}
}

#[cfg(windows)]
pub(crate) fn start_server_from_handoff(
listener: crate::platform::TransferableLocalListener,
identity: SocketFileIdentity,
api_tx: ApiRequestSender,
event_hub: EventHub,
server_stop: Arc<AtomicBool>,
) -> ServerHandle {
let path = socket_path();
info!(path = %path.display(), "api server listener adopted after handoff");
start_windows_server_on_listener(
listener,
path,
identity,
api_tx,
event_hub,
default_capabilities(),
Some(server_stop),
)
}

#[cfg(windows)]
fn start_windows_server_on_listener(
listener: crate::platform::TransferableLocalListener,
path: PathBuf,
identity: SocketFileIdentity,
api_tx: ApiRequestSender,
event_hub: EventHub,
capabilities: Option<ServerCapabilities>,
server_stop: Option<Arc<AtomicBool>>,
) -> ServerHandle {
let running = Arc::new(AtomicBool::new(true));
let listener_running = Arc::clone(&running);
let should_stop = Arc::clone(&running);
let (thread, listener_control) = crate::platform::spawn_transferable_listener(
listener,
"api listener",
move || !should_stop.load(Ordering::Acquire),
move |stream| {
let api_tx = api_tx.clone();
let event_hub = event_hub.clone();
let capabilities = capabilities.clone();
let server_stop = server_stop.clone();
let connection_running = Arc::clone(&listener_running);
std::thread::spawn(move || {
if let Err(err) = handle_connection_with_stop(
stream,
&api_tx,
&event_hub,
&connection_running,
capabilities,
server_stop.as_ref(),
) {
warn!(err = %err, "api connection failed");
}
});
},
);
ServerHandle {
_thread: thread,
path,
identity: Some(identity),
running,
})
listener_control,
}
}

fn prepare_socket_path(path: &Path) -> std::io::Result<()> {
Expand Down
13 changes: 9 additions & 4 deletions src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ mod window_title;
mod worktrees;

use std::collections::HashMap;
#[cfg(unix)]
#[cfg(any(unix, windows))]
use std::io;
use std::sync::Arc;
use std::time::{Duration, Instant};
Expand Down Expand Up @@ -92,7 +92,7 @@ impl AppPolicy {
background_updates: false,
};

#[cfg(unix)]
#[cfg(any(unix, windows))]
pub(crate) const HANDOFF_REPLACEMENT: Self = Self {
restore_session: false,
persist_session: true,
Expand Down Expand Up @@ -630,7 +630,7 @@ impl App {
app
}

#[cfg(unix)]
#[cfg(any(unix, windows))]
pub fn new_from_handoff(
config: &Config,
config_diagnostic: Option<String>,
Expand Down Expand Up @@ -690,11 +690,16 @@ impl App {
self.terminal_runtimes.set_handoff_readers_paused(false);
}

#[cfg(unix)]
#[cfg(any(unix, windows))]
pub fn assume_handoff_ownership(&mut self) {
self.terminal_runtimes.assume_handoff_ownership();
}

#[cfg(windows)]
pub fn activate_handoff_runtimes(&self) {
self.terminal_runtimes.activate_after_handoff()
}

pub(crate) fn ensure_default_workspace(&mut self) -> bool {
if !self.state.workspaces.is_empty() {
return false;
Expand Down
Loading