feat: gate live handoff behind explicit flag
This commit is contained in:
parent
58bcd76bfd
commit
2ea4878bae
|
|
@ -6,6 +6,7 @@
|
|||
- Added a session navigator at `prefix+g` with a searchable workspace/tab/pane tree, agent state filters, mouse switching, and keyboard navigation. (#157)
|
||||
- Added scrollback support to direct agent terminal attaches. Mouse wheel and plain PageUp/PageDown now scroll the attached terminal viewport, while terminal apps that request mouse or alternate-scroll input still receive those events. The client/server protocol is now version 11.
|
||||
- Added `ui.redraw_on_focus_gained` to keep the existing full redraw on outer-terminal focus gain by default while allowing users to opt out of the visible refresh. (#282)
|
||||
- Added `--handoff` for `herdr update` and `herdr --remote` to opt into live server handoff for supported running servers. Plain update and remote attach use the normal restart/stop flow by default.
|
||||
|
||||
## [0.6.2] - 2026-05-23
|
||||
|
||||
|
|
|
|||
|
|
@ -41,6 +41,10 @@ herdr notifies you when a new version is available. run manually to update:
|
|||
herdr update
|
||||
```
|
||||
|
||||
By default, updating installs the new binary and leaves compatible running sessions alone, or asks before stopping sessions that must restart. To opt into live server handoff for supported running sessions, run `herdr update --handoff`.
|
||||
|
||||
`herdr update` is disabled for Homebrew and Nix installs. Update those through `brew upgrade herdr` or your Nix workflow; live handoff does not apply to package-manager updates.
|
||||
|
||||
## quick start
|
||||
|
||||
```bash
|
||||
|
|
@ -85,9 +89,9 @@ tmux gives you persistence and panes, but it was built before agents existed. gu
|
|||
|
||||
## persistence
|
||||
|
||||
start herdr where the work lives. locally, run `herdr`. it starts or attaches to the background session automatically, with no socket setup. run your agents, split panes, do your work. press `ctrl+b q` to detach. close your terminal, close your laptop; your agents keep running. open a new terminal, run `herdr`, you're back. same session, same panes, same agents. if you stop the server and later start herdr again, restored panes bring back workspaces, tabs, cwd, layout, and focus.
|
||||
start herdr where the work lives. locally, run `herdr`. it starts or attaches to the background session automatically, with no socket setup. run your agents, split panes, do your work. press `ctrl+b q` to detach. close your terminal, close your laptop; your agents keep running. open a new terminal, run `herdr`, you're back. same session, same panes, same agents.
|
||||
|
||||
pane screen history is off by default because pane output can include secrets, tokens, prompts, and command output. enable it with `[experimental] pane_history = true` or settings > experiments > pane screen history. when enabled, herdr writes saved pane history to `session-history.json` next to `session.json`; treat the herdr config/session directory like terminal history. if native agent session restore is enabled for a pane, herdr resumes that agent session instead of replaying saved pane history.
|
||||
if you stop the server and later start herdr again, herdr restores the saved session shape. pane screen history, native agent session restore, and live handoff cover different restart/update cases; see [session state and restore](https://herdr.dev/docs/session-state/).
|
||||
|
||||
### from anywhere
|
||||
|
||||
|
|
@ -98,7 +102,7 @@ ssh you@yourserver
|
|||
herdr
|
||||
```
|
||||
|
||||
or attach from your local terminal through ssh without opening a shell first. your local herdr acts as a thin client, connects over ssh, starts or attaches to the remote herdr server, and streams the ui back to your terminal. remote attach uses your local keybindings by default; pass `--remote-keybindings server` to use the remote server config instead.
|
||||
or attach from your local terminal through ssh without opening a shell first. your local herdr acts as a thin client, connects over ssh, starts or attaches to the remote herdr server, and streams the ui back to your terminal. remote attach uses your local keybindings by default; pass `--remote-keybindings server` to use the remote server config instead. pass `--handoff` to opt into live handoff if remote attach needs to replace a supported running remote server.
|
||||
|
||||
```bash
|
||||
herdr --remote workbox
|
||||
|
|
|
|||
|
|
@ -14,9 +14,11 @@ herdr # launch or attach to the default session
|
|||
herdr --session work # launch or attach to a named session
|
||||
herdr --remote workbox # attach through SSH, using local keybindings
|
||||
herdr --remote workbox --remote-keybindings server
|
||||
herdr --remote workbox --handoff
|
||||
herdr --no-session # single-process escape hatch
|
||||
herdr --default-config # print default config
|
||||
herdr update # download and install the latest version
|
||||
herdr update --handoff # opt into live handoff for supported running servers
|
||||
herdr --version # print version
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -322,6 +322,8 @@ You can also toggle it from Settings > Experiments > pane screen history.
|
|||
|
||||
When enabled, Herdr stores saved pane history in `session-history.json` next to `session.json`.
|
||||
|
||||
For how pane screen history differs from live persistence, snapshot restore, native agent session restore, and live handoff, see [Session state and restore](/docs/session-state/).
|
||||
|
||||
## Nested launches
|
||||
|
||||
Herdr normally protects you from launching Herdr inside Herdr.
|
||||
|
|
@ -357,6 +359,8 @@ When enabled, Herdr only resumes panes that reported a native session reference
|
|||
|
||||
Session references are stored in the local Herdr session snapshot. They are not shown in normal pane, agent, status, or event output.
|
||||
|
||||
For how native agent session restore differs from pane screen history and live handoff, see [Session state and restore](/docs/session-state/).
|
||||
|
||||
## IME cursor tracking
|
||||
|
||||
When the focused pane hides its cursor and paints its own — common in AI-agent TUIs like Claude Code, pi, and codex — macOS native input methods stop tracking the candidate window position because the outer terminal stops reporting the cursor.
|
||||
|
|
|
|||
|
|
@ -46,6 +46,11 @@ herdr
|
|||
|
||||
[Understand agents →](/docs/agents/)
|
||||
</Card>
|
||||
<Card title="Session state">
|
||||
Understand detach, restart restore, pane history replay, native agent resume, and live handoff.
|
||||
|
||||
[Compare state paths →](/docs/session-state/)
|
||||
</Card>
|
||||
<Card title="Configuration">
|
||||
Configure keybindings, themes, sidebar behavior, notifications, scrollback, and advanced options.
|
||||
|
||||
|
|
|
|||
|
|
@ -41,6 +41,16 @@ Herdr checks for new releases and notifies you in the app. You can update manual
|
|||
herdr update
|
||||
```
|
||||
|
||||
`herdr update` is for installs managed by Herdr's own installer. Homebrew and Nix installs are updated through those package managers instead.
|
||||
|
||||
By default, `herdr update` installs the new binary and leaves compatible running sessions alone, or asks before stopping sessions that must restart. To opt into live server handoff for supported running sessions, run:
|
||||
|
||||
```bash
|
||||
herdr update --handoff
|
||||
```
|
||||
|
||||
Live handoff does not apply to Homebrew or Nix package-manager updates. For those installs, update with the package manager, then restart any running Herdr servers that need the new binary.
|
||||
|
||||
## Install with Nix
|
||||
|
||||
If you already use Nix, Herdr provides a flake that builds Herdr from source:
|
||||
|
|
|
|||
|
|
@ -45,6 +45,8 @@ Integrations enrich state reporting. They do not replace process detection.
|
|||
|
||||
Some integrations also report native agent session references. If `[session] resume_agents_on_restore = true` is enabled, Herdr uses official session references to resume Claude Code, Codex, Pi, Hermes Agent, and OpenCode panes after a Herdr server restart.
|
||||
|
||||
Native session restore requires current Herdr integrations: Pi integration version `2`, Claude Code version `4`, Codex version `4`, OpenCode version `2`, or Hermes Agent version `2`. OMP integration version `1` reports agent state only. Check installed versions with `herdr integration status`.
|
||||
|
||||
## Pi
|
||||
|
||||
Install the Pi integration:
|
||||
|
|
|
|||
|
|
@ -29,9 +29,7 @@ If you want to end the session and stop its panes, stop the default server:
|
|||
herdr server stop
|
||||
```
|
||||
|
||||
When Herdr starts again, it restores the saved workspaces, tabs, panes, cwd, layout, and focus.
|
||||
|
||||
Pane screen history is off by default because pane output can include secrets, tokens, prompts, and command output. Enable it from Settings > Experiments > pane screen history or with `[experimental] pane_history = true`. Herdr stores saved pane history in `session-history.json` next to `session.json`; treat the Herdr config/session directory like terminal history. When native agent session restore is enabled and a pane has a resumable agent session reference, Herdr resumes the agent session instead of replaying saved pane history for that pane.
|
||||
When Herdr starts again after a full server stop, it restores the saved session shape. For what survives detach, server restart, screen history replay, native agent session restore, and live handoff, see [Session state and restore](/docs/session-state/).
|
||||
|
||||
## Named sessions
|
||||
|
||||
|
|
@ -94,6 +92,12 @@ herdr --remote workbox
|
|||
|
||||
Remote attach supports Linux and macOS hosts on x86_64 and aarch64. Herdr checks the remote platform, prefers a matching `herdr` already on the remote `PATH`, then checks `~/.local/bin/herdr`. If no matching binary exists, interactive runs prompt to install one to `~/.local/bin/herdr`; non-interactive runs fail instead of modifying the host. If `~/.local/bin` is not on the remote `PATH`, Herdr warns after install.
|
||||
|
||||
By default, remote attach uses the normal restart/stop flow if it needs to replace or restart a running remote server. To opt into live handoff for a supported running remote server, pass `--handoff`:
|
||||
|
||||
```bash
|
||||
herdr --remote workbox --handoff
|
||||
```
|
||||
|
||||
If you SSH into the server first and run `herdr` there, Herdr runs entirely on the server. That mode is useful and simple, but it cannot access your local desktop clipboard beyond normal terminal text paste.
|
||||
|
||||
When your local and remote platforms match, Herdr can copy the current local binary. Otherwise it downloads the matching release asset from `https://herdr.dev/latest.json`.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,94 @@
|
|||
---
|
||||
title: Session state and restore
|
||||
description: Understand what Herdr keeps live, restores after restart, replays from history, resumes through agent integrations, and hands off during updates.
|
||||
---
|
||||
|
||||
Herdr has several state paths. They solve different problems.
|
||||
|
||||
## Live persistence
|
||||
|
||||
Normal detach keeps the Herdr server running. Panes, shells, agents, servers, tests, and command processes keep running inside that server.
|
||||
|
||||
Detach the client with `ctrl+b q`. Reattach later:
|
||||
|
||||
```bash
|
||||
herdr
|
||||
```
|
||||
|
||||
This is the strongest persistence path because the original processes never stop.
|
||||
|
||||
## Snapshot restore
|
||||
|
||||
If the Herdr server stops and starts again, the original pane processes are gone. Herdr restores the saved session shape: workspaces, tabs, panes, cwd, layout, and focus.
|
||||
|
||||
Snapshot restore does not preserve running shells, servers, tests, or arbitrary processes. Panes that cannot use a stronger restore path come back as new shells in their saved directories.
|
||||
|
||||
## Pane screen history replay
|
||||
|
||||
Pane screen history restores recent terminal contents after a full server restart. It restores what Herdr can show, not the old process.
|
||||
|
||||
This is off by default because pane output can include secrets, tokens, prompts, and command output. Enable it from Settings > Experiments > pane screen history or with:
|
||||
|
||||
```toml
|
||||
[experimental]
|
||||
pane_history = true
|
||||
```
|
||||
|
||||
When enabled, Herdr stores saved pane history in `session-history.json` next to `session.json`. Treat the Herdr config/session directory like terminal history.
|
||||
|
||||
## Native agent session restore
|
||||
|
||||
Some agents can resume their own conversation sessions. Herdr can use official integration-reported session references to restart supported agent panes after a Herdr server restart.
|
||||
|
||||
Enable it with:
|
||||
|
||||
```toml
|
||||
[session]
|
||||
resume_agents_on_restore = true
|
||||
```
|
||||
|
||||
When enabled, Herdr only resumes panes that reported a native session reference through a current official Herdr integration.
|
||||
|
||||
Native session restore requires these Herdr integration versions or newer:
|
||||
|
||||
| Agent | Minimum Herdr integration version | Resume command |
|
||||
| --- | --- | --- |
|
||||
| Pi | `2` | `pi --session <path-or-id>` |
|
||||
| Claude Code | `4` | `claude --resume <id>` |
|
||||
| Codex | `4` | `codex resume <id>` |
|
||||
| OpenCode | `2` | `opencode --session <id>` |
|
||||
| Hermes Agent | `2` | `hermes --resume <id>` |
|
||||
|
||||
OMP integration version `1` reports agent state, but does not report native session references for restore.
|
||||
|
||||
Run `herdr integration status` to check installed integration versions. Reinstall outdated integrations with `herdr integration install <agent>`.
|
||||
|
||||
Unsupported, missing, invalid, duplicated, or stale session references restore as normal shells in the saved pane directory.
|
||||
|
||||
If native agent session restore applies to a pane, Herdr resumes the agent session instead of replaying saved pane history for that pane.
|
||||
|
||||
## Live handoff
|
||||
|
||||
Live handoff is for update and remote attach flows that need to replace a running Herdr server. It asks the old server to transfer live panes to the new server, so pane processes can keep running across the server replacement.
|
||||
|
||||
This is different from snapshot restore, pane history replay, and native agent session restore. Handoff tries to keep the current processes alive. The others reconstruct state after the old server has already stopped.
|
||||
|
||||
Live handoff is opt-in:
|
||||
|
||||
```bash
|
||||
herdr update --handoff
|
||||
herdr --remote workbox --handoff
|
||||
```
|
||||
|
||||
Plain `herdr update` and plain `herdr --remote workbox` use the normal restart/stop flow by default.
|
||||
|
||||
`herdr update --handoff` only applies to installs managed by Herdr's own updater. Homebrew and Nix installs are updated through their package managers, so `herdr update` is disabled there and cannot perform live handoff.
|
||||
|
||||
## What Survives
|
||||
|
||||
| Case | Processes keep running | Layout returns | Recent screen returns | Agent conversation resumes |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Detach and reattach | Yes | Yes | Yes, from the live terminal | Yes, because the process never stopped |
|
||||
| Server restart | No | Yes | Only with pane screen history | Only with native agent session restore |
|
||||
| Update without `--handoff` | Compatible servers keep running; restart-required servers may need stop/restart | Yes after restart | Only with pane screen history | Only with native agent session restore |
|
||||
| Update with `--handoff` | Best effort for supported running servers | Yes | Yes, from the live terminal if handoff succeeds | Yes, because the process keeps running if handoff succeeds |
|
||||
17
src/main.rs
17
src/main.rs
|
|
@ -345,7 +345,19 @@ fn main() -> io::Result<()> {
|
|||
}
|
||||
|
||||
if args.get(1).map(|s| s.as_str()) == Some("update") {
|
||||
match update::self_update() {
|
||||
let options = match update::parse_self_update_args(&args[2..]) {
|
||||
Ok(options) => options,
|
||||
Err(err) if err.starts_with("usage:") => {
|
||||
eprintln!("{err}");
|
||||
std::process::exit(0);
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("{err}");
|
||||
eprintln!("usage: herdr update [--handoff]");
|
||||
std::process::exit(2);
|
||||
}
|
||||
};
|
||||
match update::self_update(options) {
|
||||
Ok(_) => return Ok(()),
|
||||
Err(e) => {
|
||||
if e.starts_with("self-update is disabled") {
|
||||
|
|
@ -365,7 +377,7 @@ fn main() -> io::Result<()> {
|
|||
println!(" herdr --session <name> [options]");
|
||||
println!(" herdr --remote <ssh-target> [--session <name>]");
|
||||
println!(" herdr session attach <name>");
|
||||
println!(" herdr update");
|
||||
println!(" herdr update [--handoff]");
|
||||
println!(" herdr server stop");
|
||||
println!(" herdr server reload-config");
|
||||
println!(" herdr config <subcommand> ...");
|
||||
|
|
@ -440,6 +452,7 @@ fn main() -> io::Result<()> {
|
|||
println!(" --remote <target> Attach through SSH to a remote Herdr server");
|
||||
println!(" --remote-keybindings <local|server>");
|
||||
println!(" Keybindings for --remote app attach (default: local)");
|
||||
println!(" --handoff Opt into live handoff for update or remote attach");
|
||||
println!(" --default-config Print default configuration and exit");
|
||||
println!(" --version, -V Print version and exit");
|
||||
println!(" --help, -h Show this help");
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ impl RemoteKeybindings {
|
|||
pub(crate) struct RemoteLaunch {
|
||||
pub(crate) target: String,
|
||||
pub(crate) keybindings: RemoteKeybindings,
|
||||
pub(crate) live_handoff: bool,
|
||||
}
|
||||
|
||||
pub(crate) fn extract_remote_args(
|
||||
|
|
@ -67,9 +68,15 @@ pub(crate) fn extract_remote_args(
|
|||
let mut remote_target = None;
|
||||
let mut keybindings = RemoteKeybindings::Local;
|
||||
let mut keybindings_seen = false;
|
||||
let mut live_handoff = false;
|
||||
let mut index = 1;
|
||||
while index < args.len() {
|
||||
let arg = &args[index];
|
||||
if arg == "--handoff" {
|
||||
live_handoff = true;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if arg == "--remote" {
|
||||
if remote_target.is_some() {
|
||||
return Err("--remote can only be specified once".to_string());
|
||||
|
|
@ -118,10 +125,14 @@ pub(crate) fn extract_remote_args(
|
|||
let remote = remote_target.map(|target| RemoteLaunch {
|
||||
target,
|
||||
keybindings,
|
||||
live_handoff,
|
||||
});
|
||||
if remote.is_none() && keybindings_seen {
|
||||
return Err("--remote-keybindings requires --remote".to_string());
|
||||
}
|
||||
if remote.is_none() && live_handoff {
|
||||
cleaned.push("--handoff".to_string());
|
||||
}
|
||||
|
||||
Ok((cleaned, remote))
|
||||
}
|
||||
|
|
@ -143,13 +154,19 @@ pub(crate) fn run_remote(remote: RemoteLaunch) -> io::Result<()> {
|
|||
let program = std::env::args()
|
||||
.next()
|
||||
.unwrap_or_else(|| "herdr".to_string());
|
||||
let reattach_command =
|
||||
reattach_command(&program, &remote.target, &session_name, remote.keybindings);
|
||||
let prepared_remote = prepare_remote_herdr(&remote.target)?;
|
||||
let reattach_command = reattach_command(
|
||||
&program,
|
||||
&remote.target,
|
||||
&session_name,
|
||||
remote.keybindings,
|
||||
remote.live_handoff,
|
||||
);
|
||||
let prepared_remote = prepare_remote_herdr(&remote.target, remote.live_handoff)?;
|
||||
ensure_remote_server_ready(
|
||||
&remote.target,
|
||||
&prepared_remote.remote_herdr,
|
||||
prepared_remote.installed_or_replaced,
|
||||
remote.live_handoff,
|
||||
)?;
|
||||
|
||||
let _bridge = SshStdioBridge::start(
|
||||
|
|
@ -318,7 +335,10 @@ impl InstallSource {
|
|||
}
|
||||
}
|
||||
|
||||
fn prepare_remote_herdr(target: &str) -> io::Result<PreparedRemoteHerdr> {
|
||||
fn prepare_remote_herdr(
|
||||
target: &str,
|
||||
live_handoff_enabled: bool,
|
||||
) -> io::Result<PreparedRemoteHerdr> {
|
||||
let platform = detect_remote_platform(target)?;
|
||||
let remote_herdr = RemoteHerdr::for_platform(platform);
|
||||
let override_binary = remote_binary_override_path()?;
|
||||
|
|
@ -347,7 +367,11 @@ fn prepare_remote_herdr(target: &str) -> io::Result<PreparedRemoteHerdr> {
|
|||
.ok()
|
||||
.and_then(|exists| exists.then_some(&remote_herdr))
|
||||
}) {
|
||||
confirm_remote_install_with_running_server(target, status_probe_herdr)?;
|
||||
confirm_remote_install_with_running_server(
|
||||
target,
|
||||
status_probe_herdr,
|
||||
live_handoff_enabled,
|
||||
)?;
|
||||
}
|
||||
confirm_remote_install(
|
||||
target,
|
||||
|
|
@ -552,6 +576,7 @@ fn ensure_remote_server_ready(
|
|||
target: &str,
|
||||
remote_herdr: &RemoteHerdr,
|
||||
remote_binary_changed: bool,
|
||||
live_handoff_enabled: bool,
|
||||
) -> io::Result<()> {
|
||||
let status = remote_server_status(target, remote_herdr)?;
|
||||
let RemoteServerStatus::Running {
|
||||
|
|
@ -569,7 +594,9 @@ fn ensure_remote_server_ready(
|
|||
return Ok(());
|
||||
};
|
||||
|
||||
if live_handoff && confirm_remote_server_handoff(target, version.as_deref(), protocol, reason)?
|
||||
if live_handoff_enabled
|
||||
&& live_handoff
|
||||
&& confirm_remote_server_handoff(target, version.as_deref(), protocol, reason)?
|
||||
{
|
||||
match live_handoff_remote_server(target, remote_herdr) {
|
||||
Ok(()) => return Ok(()),
|
||||
|
|
@ -606,6 +633,7 @@ fn remote_server_restart_reason(
|
|||
fn confirm_remote_install_with_running_server(
|
||||
target: &str,
|
||||
remote_herdr: &RemoteHerdr,
|
||||
live_handoff_enabled: bool,
|
||||
) -> io::Result<()> {
|
||||
let status = match remote_server_status(target, remote_herdr) {
|
||||
Ok(status) => status,
|
||||
|
|
@ -641,13 +669,13 @@ fn confirm_remote_install_with_running_server(
|
|||
else {
|
||||
return Ok(());
|
||||
};
|
||||
if live_handoff {
|
||||
if live_handoff_enabled && live_handoff {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if !io::stdin().is_terminal() {
|
||||
return Err(io::Error::other(format!(
|
||||
"remote herdr server on {target} is running v{} protocol {}, but it does not advertise live handoff; run from an interactive terminal to approve updating the remote binary",
|
||||
"remote herdr server on {target} is running v{} protocol {}; run from an interactive terminal to approve updating the remote binary",
|
||||
version_label(version.as_deref()),
|
||||
protocol_label(protocol)
|
||||
)));
|
||||
|
|
@ -660,10 +688,7 @@ fn confirm_remote_install_with_running_server(
|
|||
protocol_label(protocol)
|
||||
);
|
||||
eprintln!(
|
||||
"this server does not advertise live handoff, so this attach cannot preserve its running panes during the update."
|
||||
);
|
||||
eprintln!(
|
||||
"future remote updates can preserve panes after the remote server has run a handoff-capable version once."
|
||||
"this attach will not preserve running panes unless you pass --handoff and the remote server supports live handoff."
|
||||
);
|
||||
eprintln!();
|
||||
eprint!("continue installing the remote herdr binary? [Y/n] ");
|
||||
|
|
@ -1118,6 +1143,7 @@ fn reattach_command(
|
|||
target: &str,
|
||||
session_name: &str,
|
||||
keybindings: RemoteKeybindings,
|
||||
live_handoff: bool,
|
||||
) -> String {
|
||||
let program = if program.is_empty() { "herdr" } else { program };
|
||||
let mut command = format!("{} --remote {}", shell_quote(program), shell_quote(target));
|
||||
|
|
@ -1125,6 +1151,9 @@ fn reattach_command(
|
|||
command.push_str(" --remote-keybindings ");
|
||||
command.push_str(keybindings.as_str());
|
||||
}
|
||||
if live_handoff {
|
||||
command.push_str(" --handoff");
|
||||
}
|
||||
if session_name != crate::session::DEFAULT_SESSION_NAME {
|
||||
command.push_str(" --session ");
|
||||
command.push_str(&shell_quote(session_name));
|
||||
|
|
@ -1470,6 +1499,28 @@ mod tests {
|
|||
assert_eq!(remote.unwrap().keybindings, RemoteKeybindings::Server);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_remote_args_accepts_explicit_handoff() {
|
||||
let args = vec!["herdr".into(), "--remote=dev".into(), "--handoff".into()];
|
||||
|
||||
let (cleaned, remote) = extract_remote_args(&args).unwrap();
|
||||
|
||||
assert_eq!(cleaned, vec!["herdr"]);
|
||||
let remote = remote.unwrap();
|
||||
assert_eq!(remote.target, "dev");
|
||||
assert!(remote.live_handoff);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_remote_args_preserves_handoff_without_remote() {
|
||||
let args = vec!["herdr".into(), "update".into(), "--handoff".into()];
|
||||
|
||||
let (cleaned, remote) = extract_remote_args(&args).unwrap();
|
||||
|
||||
assert_eq!(cleaned, args);
|
||||
assert!(remote.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_remote_args_rejects_remote_keybindings_without_remote() {
|
||||
let args = vec!["herdr".into(), "--remote-keybindings=server".into()];
|
||||
|
|
@ -1551,6 +1602,7 @@ mod tests {
|
|||
"user@host",
|
||||
"work",
|
||||
RemoteKeybindings::Local,
|
||||
false,
|
||||
),
|
||||
"target/release/herdr --remote user@host --session work"
|
||||
);
|
||||
|
|
@ -1560,6 +1612,7 @@ mod tests {
|
|||
"host name",
|
||||
crate::session::DEFAULT_SESSION_NAME,
|
||||
RemoteKeybindings::Local,
|
||||
false,
|
||||
),
|
||||
"herdr --remote 'host name'"
|
||||
);
|
||||
|
|
@ -1569,9 +1622,20 @@ mod tests {
|
|||
"host",
|
||||
crate::session::DEFAULT_SESSION_NAME,
|
||||
RemoteKeybindings::Server,
|
||||
false,
|
||||
),
|
||||
"herdr --remote host --remote-keybindings server"
|
||||
);
|
||||
assert_eq!(
|
||||
reattach_command(
|
||||
"herdr",
|
||||
"host",
|
||||
crate::session::DEFAULT_SESSION_NAME,
|
||||
RemoteKeybindings::Local,
|
||||
true,
|
||||
),
|
||||
"herdr --remote host --handoff"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ use std::os::unix::process::CommandExt;
|
|||
#[cfg(unix)]
|
||||
use std::path::{Path, PathBuf};
|
||||
#[cfg(unix)]
|
||||
use std::process::Command;
|
||||
use std::process::{Child, Command};
|
||||
#[cfg(unix)]
|
||||
use std::time::Duration;
|
||||
|
||||
|
|
@ -71,7 +71,7 @@ pub(crate) fn spawn_handoff_import(
|
|||
import_exe: Option<&Path>,
|
||||
socket_path: &Path,
|
||||
token: &str,
|
||||
) -> io::Result<u32> {
|
||||
) -> io::Result<Child> {
|
||||
let fallback_exe;
|
||||
let exe = if let Some(import_exe) = import_exe {
|
||||
import_exe
|
||||
|
|
@ -94,7 +94,7 @@ pub(crate) fn spawn_handoff_import(
|
|||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null());
|
||||
let child = command.spawn().map_err(|err| {
|
||||
command.spawn().map_err(|err| {
|
||||
io::Error::new(
|
||||
err.kind(),
|
||||
format!(
|
||||
|
|
@ -102,8 +102,34 @@ pub(crate) fn spawn_handoff_import(
|
|||
exe.display()
|
||||
),
|
||||
)
|
||||
})?;
|
||||
Ok(child.id())
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub(crate) fn cleanup_failed_import_child(child: &mut Child) {
|
||||
let pid = child.id();
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => {
|
||||
info!(pid, status = %status, "handoff import server exited during rollback");
|
||||
return;
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(err) => {
|
||||
warn!(pid, err = %err, "failed to inspect handoff import server before rollback");
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(err) = child.kill() {
|
||||
warn!(pid, err = %err, "failed to kill handoff import server during rollback");
|
||||
}
|
||||
match child.wait() {
|
||||
Ok(status) => {
|
||||
info!(pid, status = %status, "handoff import server reaped during rollback");
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(pid, err = %err, "failed to reap handoff import server during rollback");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
|
|
|
|||
|
|
@ -655,17 +655,18 @@ impl HeadlessServer {
|
|||
params.expected_protocol,
|
||||
params.expected_version,
|
||||
);
|
||||
let child_pid = match crate::server::handoff::spawn_handoff_import(
|
||||
let mut import_child = match crate::server::handoff::spawn_handoff_import(
|
||||
import_exe.as_deref(),
|
||||
&socket_path,
|
||||
&token,
|
||||
) {
|
||||
Ok(child_pid) => child_pid,
|
||||
Ok(child) => child,
|
||||
Err(err) => {
|
||||
self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids);
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
let child_pid = import_child.id();
|
||||
info!(pid = child_pid, socket = %socket_path.display(), "spawned handoff import server");
|
||||
|
||||
let mut fds = Vec::new();
|
||||
|
|
@ -682,6 +683,7 @@ impl HeadlessServer {
|
|||
for fd in fds {
|
||||
let _ = unsafe { libc::close(fd) };
|
||||
}
|
||||
crate::server::handoff::cleanup_failed_import_child(&mut import_child);
|
||||
self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids);
|
||||
return Err(err);
|
||||
}
|
||||
|
|
@ -697,6 +699,7 @@ impl HeadlessServer {
|
|||
for fd in fds {
|
||||
let _ = unsafe { libc::close(fd) };
|
||||
}
|
||||
crate::server::handoff::cleanup_failed_import_child(&mut import_child);
|
||||
self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids);
|
||||
return Err(err);
|
||||
}
|
||||
|
|
@ -707,6 +710,7 @@ impl HeadlessServer {
|
|||
let _ = unsafe { libc::close(fd) };
|
||||
}
|
||||
if let Err(err) = send_result {
|
||||
crate::server::handoff::cleanup_failed_import_child(&mut import_child);
|
||||
self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids);
|
||||
return Err(err);
|
||||
}
|
||||
|
|
@ -718,6 +722,7 @@ impl HeadlessServer {
|
|||
}
|
||||
let _ = remove_socket_file_if_owned(&self.client_socket_path, self.client_socket_identity);
|
||||
if let Err(err) = crate::server::handoff::wait_ready(&mut stream) {
|
||||
crate::server::handoff::cleanup_failed_import_child(&mut import_child);
|
||||
match self.wait_then_restore_public_sockets_after_failed_handoff() {
|
||||
Ok(()) => {
|
||||
self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids);
|
||||
|
|
@ -734,6 +739,7 @@ impl HeadlessServer {
|
|||
)));
|
||||
}
|
||||
if let Err(err) = crate::server::handoff::report_committed(&mut stream) {
|
||||
crate::server::handoff::cleanup_failed_import_child(&mut import_child);
|
||||
match self.wait_then_restore_public_sockets_after_failed_handoff() {
|
||||
Ok(()) => {
|
||||
self.rollback_handoff_before_commit(&socket_path, &paused_terminal_ids);
|
||||
|
|
|
|||
208
src/update.rs
208
src/update.rs
|
|
@ -27,6 +27,7 @@ const FAKE_UPDATE_VERSION_ENV: &str = "HERDR_FAKE_UPDATE_VERSION";
|
|||
const FAKE_UPDATE_NOTES_VERSION_ENV: &str = "HERDR_FAKE_UPDATE_NOTES_VERSION";
|
||||
const DEFAULT_FAKE_UPDATE_NOTES_VERSION: &str = "0.3.0";
|
||||
const SERVER_STOP_RESPONSE_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const SERVER_HANDOFF_REQUEST_TIMEOUT: Duration = Duration::from_secs(240);
|
||||
const SERVER_HANDOFF_CONFIRM_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
const SERVER_SHUTDOWN_POLL_INTERVAL: Duration = Duration::from_millis(100);
|
||||
const STAR_PROMPT_REPO: &str = "ogulcancelik/herdr";
|
||||
|
|
@ -422,7 +423,10 @@ fn version_label(version: Option<&str>) -> &str {
|
|||
version.unwrap_or("unknown")
|
||||
}
|
||||
|
||||
fn update_requires_live_handoff(server: &crate::api::RuntimeStatus, release: &ReleaseInfo) -> bool {
|
||||
fn update_requires_server_restart(
|
||||
server: &crate::api::RuntimeStatus,
|
||||
release: &ReleaseInfo,
|
||||
) -> bool {
|
||||
match (server.protocol, release.target_protocol) {
|
||||
(Some(server_protocol), Some(target_protocol)) => server_protocol != target_protocol,
|
||||
_ => true,
|
||||
|
|
@ -449,7 +453,7 @@ fn parse_live_handoff_before_update_response(input: &str) -> Option<bool> {
|
|||
struct RunningServerUpdatePlan {
|
||||
target: RunningUpdateTarget,
|
||||
server: crate::api::RuntimeStatus,
|
||||
requires_live_handoff: bool,
|
||||
requires_server_restart: bool,
|
||||
}
|
||||
|
||||
impl RunningServerUpdatePlan {
|
||||
|
|
@ -558,7 +562,7 @@ fn plan_running_server_updates(
|
|||
};
|
||||
|
||||
plans.push(RunningServerUpdatePlan {
|
||||
requires_live_handoff: update_requires_live_handoff(&server, release),
|
||||
requires_server_restart: update_requires_server_restart(&server, release),
|
||||
server,
|
||||
target,
|
||||
});
|
||||
|
|
@ -670,19 +674,38 @@ fn target_client_protocol_server_is_running() -> Result<bool, String> {
|
|||
}))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub(crate) struct SelfUpdateOptions {
|
||||
pub(crate) live_handoff: bool,
|
||||
}
|
||||
|
||||
pub(crate) fn parse_self_update_args(args: &[String]) -> Result<SelfUpdateOptions, String> {
|
||||
let mut options = SelfUpdateOptions::default();
|
||||
for arg in args {
|
||||
match arg.as_str() {
|
||||
"--handoff" => options.live_handoff = true,
|
||||
"--help" | "-h" => {
|
||||
return Err("usage: herdr update [--handoff]".to_string());
|
||||
}
|
||||
_ => return Err(format!("unknown update option: {arg}")),
|
||||
}
|
||||
}
|
||||
Ok(options)
|
||||
}
|
||||
|
||||
fn prompt_to_stop_old_servers_before_update(
|
||||
plans: &[RunningServerUpdatePlan],
|
||||
release: &ReleaseInfo,
|
||||
) -> Result<bool, String> {
|
||||
if !io::stdin().is_terminal() {
|
||||
return Err(
|
||||
"one or more herdr targets are running and cannot perform live handoff for this update; run `herdr update` from an interactive terminal, or stop those targets and run `herdr update` again"
|
||||
"one or more herdr targets must restart for this update; run `herdr update` from an interactive terminal, or stop those targets and run `herdr update` again"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
eprintln!(
|
||||
"these running herdr targets are too old to preserve panes during this update to v{}:",
|
||||
"these running herdr targets must restart to use v{}:",
|
||||
release.version
|
||||
);
|
||||
for plan in plans {
|
||||
|
|
@ -693,9 +716,8 @@ fn prompt_to_stop_old_servers_before_update(
|
|||
protocol_label(plan.server.protocol)
|
||||
);
|
||||
}
|
||||
eprintln!(
|
||||
"herdr can leave them running, or stop them after installing the update. stopping them will exit their pane processes."
|
||||
);
|
||||
eprintln!("herdr can leave them running, or stop them after installing the update.");
|
||||
eprintln!("stopping them will exit their pane processes.");
|
||||
|
||||
loop {
|
||||
eprint!("stop these old targets after updating? [y/N] ");
|
||||
|
|
@ -722,12 +744,37 @@ fn prompt_to_stop_old_servers_before_update(
|
|||
fn confirm_running_server_update_action(
|
||||
plans: Vec<RunningServerUpdatePlan>,
|
||||
release: &ReleaseInfo,
|
||||
options: SelfUpdateOptions,
|
||||
) -> Result<Vec<RunningServerUpdateDecision>, String> {
|
||||
if plans.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
print_running_session_update_summary(&plans, release);
|
||||
print_running_session_update_summary(&plans, release, options);
|
||||
|
||||
if !options.live_handoff {
|
||||
let restart_required: Vec<RunningServerUpdatePlan> = plans
|
||||
.iter()
|
||||
.filter(|plan| plan.requires_server_restart)
|
||||
.cloned()
|
||||
.collect();
|
||||
let stop_restart_required = if restart_required.is_empty() {
|
||||
false
|
||||
} else {
|
||||
prompt_to_stop_old_servers_before_update(&restart_required, release)?
|
||||
};
|
||||
return Ok(plans
|
||||
.into_iter()
|
||||
.map(|plan| {
|
||||
let action = if plan.requires_server_restart && stop_restart_required {
|
||||
RunningServerUpdateAction::StopOldServer
|
||||
} else {
|
||||
RunningServerUpdateAction::None
|
||||
};
|
||||
RunningServerUpdateDecision { plan, action }
|
||||
})
|
||||
.collect());
|
||||
}
|
||||
|
||||
let handoff_supported: Vec<&RunningServerUpdatePlan> = plans
|
||||
.iter()
|
||||
|
|
@ -735,7 +782,7 @@ fn confirm_running_server_update_action(
|
|||
.collect();
|
||||
let handoff_unsupported_requiring_update: Vec<&RunningServerUpdatePlan> = plans
|
||||
.iter()
|
||||
.filter(|plan| !server_supports_live_handoff(&plan.server) && plan.requires_live_handoff)
|
||||
.filter(|plan| !server_supports_live_handoff(&plan.server) && plan.requires_server_restart)
|
||||
.collect();
|
||||
|
||||
let live_handoff = if handoff_supported.is_empty() {
|
||||
|
|
@ -744,7 +791,7 @@ fn confirm_running_server_update_action(
|
|||
prompt_to_live_handoff_sessions_before_update(
|
||||
&handoff_supported,
|
||||
release,
|
||||
plans.iter().any(|plan| plan.requires_live_handoff),
|
||||
plans.iter().any(|plan| plan.requires_server_restart),
|
||||
)?
|
||||
};
|
||||
|
||||
|
|
@ -763,7 +810,7 @@ fn confirm_running_server_update_action(
|
|||
let action = if server_supports_live_handoff(&plan.server) && live_handoff {
|
||||
RunningServerUpdateAction::LiveHandoff
|
||||
} else if !server_supports_live_handoff(&plan.server)
|
||||
&& plan.requires_live_handoff
|
||||
&& plan.requires_server_restart
|
||||
&& stop_unsupported
|
||||
{
|
||||
RunningServerUpdateAction::StopOldServer
|
||||
|
|
@ -776,21 +823,34 @@ fn confirm_running_server_update_action(
|
|||
Ok(decisions)
|
||||
}
|
||||
|
||||
fn print_running_session_update_summary(plans: &[RunningServerUpdatePlan], release: &ReleaseInfo) {
|
||||
fn print_running_session_update_summary(
|
||||
plans: &[RunningServerUpdatePlan],
|
||||
release: &ReleaseInfo,
|
||||
options: SelfUpdateOptions,
|
||||
) {
|
||||
eprintln!("running herdr targets:");
|
||||
for plan in plans {
|
||||
let capability = if server_supports_live_handoff(&plan.server) {
|
||||
"handoff supported"
|
||||
if options.live_handoff {
|
||||
let capability = if server_supports_live_handoff(&plan.server) {
|
||||
"handoff supported"
|
||||
} else {
|
||||
"too old for handoff"
|
||||
};
|
||||
eprintln!(
|
||||
" {}: v{} protocol {} ({})",
|
||||
plan.label(),
|
||||
version_label(plan.server.version.as_deref()),
|
||||
protocol_label(plan.server.protocol),
|
||||
capability
|
||||
);
|
||||
} else {
|
||||
"too old for handoff"
|
||||
};
|
||||
eprintln!(
|
||||
" {}: v{} protocol {} ({})",
|
||||
plan.label(),
|
||||
version_label(plan.server.version.as_deref()),
|
||||
protocol_label(plan.server.protocol),
|
||||
capability
|
||||
);
|
||||
eprintln!(
|
||||
" {}: v{} protocol {}",
|
||||
plan.label(),
|
||||
version_label(plan.server.version.as_deref()),
|
||||
protocol_label(plan.server.protocol)
|
||||
);
|
||||
}
|
||||
}
|
||||
eprintln!(
|
||||
" update: v{} protocol {}",
|
||||
|
|
@ -1130,7 +1190,7 @@ fn live_handoff_server_via_api_for_update_at(
|
|||
) -> Result<(), String> {
|
||||
live_handoff_server_via_api_for_release_at(
|
||||
socket_path,
|
||||
SERVER_HANDOFF_CONFIRM_TIMEOUT,
|
||||
SERVER_HANDOFF_REQUEST_TIMEOUT,
|
||||
updated_exe,
|
||||
release,
|
||||
)
|
||||
|
|
@ -1679,7 +1739,7 @@ fn maybe_offer_star_after_successful_update() {
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Manual self-update command (`herdr update`).
|
||||
pub fn self_update() -> Result<Version, String> {
|
||||
pub fn self_update(options: SelfUpdateOptions) -> Result<Version, String> {
|
||||
if is_homebrew_managed_install() {
|
||||
return Err(format!(
|
||||
"self-update is disabled for Homebrew installs; run `{HOMEBREW_UPDATE_COMMAND}`"
|
||||
|
|
@ -1710,7 +1770,7 @@ pub fn self_update() -> Result<Version, String> {
|
|||
|
||||
let running_server_plans = plan_running_server_updates(&release)?;
|
||||
let server_update_decisions =
|
||||
confirm_running_server_update_action(running_server_plans, &release)?;
|
||||
confirm_running_server_update_action(running_server_plans, &release, options)?;
|
||||
|
||||
eprintln!("downloading v{}...", release.version);
|
||||
if let Err(e) =
|
||||
|
|
@ -2095,6 +2155,24 @@ mod tests {
|
|||
assert!(!running_inside_herdr_env(Some("0")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn self_update_args_gate_live_handoff() {
|
||||
assert_eq!(
|
||||
parse_self_update_args(&[]).unwrap(),
|
||||
SelfUpdateOptions {
|
||||
live_handoff: false
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
parse_self_update_args(&["--handoff".to_string()]).unwrap(),
|
||||
SelfUpdateOptions { live_handoff: true }
|
||||
);
|
||||
assert_eq!(
|
||||
parse_self_update_args(&["--unknown".to_string()]).unwrap_err(),
|
||||
"unknown update option: --unknown"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_live_handoff_before_update_response_defaults_yes_for_blank() {
|
||||
assert_eq!(parse_live_handoff_before_update_response(""), Some(true));
|
||||
|
|
@ -2107,7 +2185,7 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn update_requires_live_handoff_when_target_protocol_differs_or_unknown() {
|
||||
fn update_requires_server_restart_when_target_protocol_differs_or_unknown() {
|
||||
let server = crate::api::RuntimeStatus {
|
||||
version: Some("0.5.5".to_string()),
|
||||
protocol: Some(2),
|
||||
|
|
@ -2128,9 +2206,53 @@ mod tests {
|
|||
..compatible_release.clone()
|
||||
};
|
||||
|
||||
assert!(!update_requires_live_handoff(&server, &compatible_release));
|
||||
assert!(update_requires_live_handoff(&server, &incompatible_release));
|
||||
assert!(update_requires_live_handoff(&server, &unknown_release));
|
||||
assert!(!update_requires_server_restart(
|
||||
&server,
|
||||
&compatible_release
|
||||
));
|
||||
assert!(update_requires_server_restart(
|
||||
&server,
|
||||
&incompatible_release
|
||||
));
|
||||
assert!(update_requires_server_restart(&server, &unknown_release));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_update_requires_restart_for_supported_servers_without_handoff() {
|
||||
assert!(
|
||||
!io::stdin().is_terminal(),
|
||||
"this test relies on noninteractive test stdin"
|
||||
);
|
||||
let release = fake_release("9.8.7", Some(77));
|
||||
let plan = RunningServerUpdatePlan {
|
||||
target: RunningUpdateTarget {
|
||||
name: Some("work".to_string()),
|
||||
label: "work".to_string(),
|
||||
stop_command: "herdr session stop work".to_string(),
|
||||
attach_command: Some("herdr session attach work".to_string()),
|
||||
socket_path: crate::session::api_socket_path_for(Some("work")),
|
||||
client_socket_path: crate::session::client_socket_path_for(Some("work")),
|
||||
must_be_running: true,
|
||||
},
|
||||
requires_server_restart: true,
|
||||
server: crate::api::RuntimeStatus {
|
||||
version: Some("0.6.2".to_string()),
|
||||
protocol: Some(76),
|
||||
capabilities: Some(crate::api::schema::ServerCapabilities { live_handoff: true }),
|
||||
},
|
||||
};
|
||||
|
||||
let err = confirm_running_server_update_action(
|
||||
vec![plan],
|
||||
&release,
|
||||
SelfUpdateOptions {
|
||||
live_handoff: false,
|
||||
},
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert!(err.contains("must restart"), "unexpected error: {err}");
|
||||
assert!(!err.contains("live handoff"), "unexpected error: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -2289,7 +2411,7 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn noninteractive_update_requires_handoff_fails_before_install() {
|
||||
fn noninteractive_plain_update_requiring_restart_fails_without_handoff() {
|
||||
let _guard = env_lock().lock().unwrap();
|
||||
assert!(
|
||||
!io::stdin().is_terminal(),
|
||||
|
|
@ -2318,21 +2440,21 @@ mod tests {
|
|||
client_socket_path: crate::session::client_socket_path_for(Some("work")),
|
||||
must_be_running: true,
|
||||
},
|
||||
requires_live_handoff: true,
|
||||
requires_server_restart: true,
|
||||
server,
|
||||
};
|
||||
|
||||
let err =
|
||||
prompt_to_live_handoff_sessions_before_update(&[&plan], &release, true).unwrap_err();
|
||||
let err = confirm_running_server_update_action(
|
||||
vec![plan],
|
||||
&release,
|
||||
SelfUpdateOptions {
|
||||
live_handoff: false,
|
||||
},
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert!(
|
||||
err.contains("requires live server handoff"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
assert!(
|
||||
err.contains("run `herdr update` from an interactive terminal"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
assert!(err.contains("must restart"), "unexpected error: {err}");
|
||||
assert!(!err.contains("live handoff"), "unexpected error: {err}");
|
||||
std::env::remove_var(crate::session::SESSION_ENV_VAR);
|
||||
crate::session::clear_explicit_session_for_test();
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue