mirror of
https://github.com/herdrdev/herdr.git
synced 2026-09-21 16:01:04 +00:00
@@ -457,7 +457,7 @@ herdr plugin enable <plugin_id>
|
||||
herdr plugin disable <plugin_id>
|
||||
```
|
||||
|
||||
`plugin install` accepts GitHub shorthand only, such as `ogulcancelik/herdr-plugin-examples/worktree-bootstrap`. It uses `git`, shows a trust preview in interactive terminals, runs supported manifest build commands, and stores GitHub installs in a Herdr-managed directory. Use `--yes` for noninteractive installs. `plugin update` refreshes every GitHub-managed plugin when no targets are given, or only the listed plugin ids or GitHub sources. It preserves each install's requested ref, skips unchanged commits and ignores locally linked plugins during bulk updates. Reinstalling a GitHub-managed plugin replaces that managed checkout. Installing over a locally linked plugin is refused. Plugin manifests must declare `min_herdr_version`; install and link fail when the plugin requires a newer Herdr binary. `plugin list` is human-readable by default; pass `--json` for the raw API response.
|
||||
`plugin install` accepts GitHub shorthand only, such as `ogulcancelik/herdr-plugin-examples/worktree-bootstrap`. It uses `git`, shows a trust preview in interactive terminals, runs supported manifest build commands, and stores GitHub installs in a Herdr-managed directory. Use `--yes` for noninteractive installs. `plugin update` refreshes every GitHub-managed plugin when no targets are given, or only the listed plugin ids or GitHub sources. It preserves each install's requested ref, skips unchanged commits and ignores locally linked plugins during bulk updates. Reinstalling a GitHub-managed plugin creates a new installation directory. Running plugins keep their original files; new invocations use the new installation. Installing over a locally linked plugin is refused. Plugin manifests must declare `min_herdr_version`; install and link fail when the plugin requires a newer Herdr binary. `plugin list` is human-readable by default; pass `--json` for the raw API response.
|
||||
|
||||
Plugin installation and enabled state are global to the current user. A plugin installed, linked, enabled, or disabled through one Herdr session is immediately available with the same state in every session.
|
||||
|
||||
@@ -468,7 +468,7 @@ herdr plugin link <path> [--disabled]
|
||||
herdr plugin unlink <plugin_id>
|
||||
```
|
||||
|
||||
`plugin link` accepts a plugin directory containing `herdr-plugin.toml` or a direct manifest path. Use it while authoring or testing a plugin from a local checkout. `plugin unlink` and `plugin uninstall` unregister the plugin and leave files in place so running plugins can finish. Updates and reinstalls also retain previous installations; new invocations use the replacement. There is currently no automatic cleanup of retained installations. For GitHub installs, uninstall accepts either the plugin id or the same `owner/repo[/subdir...]` shorthand used by install. Actions, event hooks, panes, and link handlers are declared in the manifest; runtime action registration is not part of v1.
|
||||
`plugin link` accepts a plugin directory containing `herdr-plugin.toml` or a direct manifest path. Use it while authoring or testing a plugin from a local checkout. `plugin unlink` and `plugin uninstall` unregister the plugin and leave files in place so running plugins can finish. Updates and reinstalls also retain previous installations; new invocations use the replacement without a config reload. A later successful installation/update or server startup removes obsolete managed installations after the servers using them have exited. Config reload does not release old installations. Plugins must stop detached background processes before their parent command or pane exits; processes surviving server exit are not protected from cleanup. Legacy installations without cleanup tracking and locally linked files are retained. For GitHub installs, uninstall accepts either the plugin id or the same `owner/repo[/subdir...]` shorthand used by install. Actions, event hooks, panes, and link handlers are declared in the manifest; runtime action registration is not part of v1.
|
||||
|
||||
Config directory:
|
||||
|
||||
|
||||
@@ -194,8 +194,9 @@ herdr plugin log list --plugin example.layout
|
||||
terminals, builds in a new Herdr-managed installation directory, and registers
|
||||
it. Use `--yes` for noninteractive installs. Updates and reinstalls leave the
|
||||
previous installation in place: running commands and panes keep their original
|
||||
files, while new invocations use the new installation. Existing processes are
|
||||
not restarted, and startup hooks run at the next server startup as usual.
|
||||
files, while new invocations use the new installation without a config reload.
|
||||
Existing processes are not restarted, and startup hooks run at the next server
|
||||
startup as usual.
|
||||
`plugin update` refreshes every GitHub-managed plugin when called without a
|
||||
target, or only the listed plugin ids or GitHub sources. It refetches the ref
|
||||
recorded during install, skips unchanged commits, and ignores locally linked
|
||||
@@ -213,10 +214,13 @@ directory for setup docs and shell scripts.
|
||||
`plugin uninstall <id-or-source>` unregisters the plugin and accepts either the
|
||||
plugin id or the same `owner/repo[/subdir...]` shorthand used by install.
|
||||
Installation files are retained after updates, reinstalls, and uninstall so
|
||||
running plugins can finish. There is currently no automatic cleanup; retained
|
||||
installations consume disk space. Remove an old installation manually only
|
||||
after all commands and panes using it, including background processes and
|
||||
plugins in other Herdr sessions, have stopped. Config and state are preserved.
|
||||
running plugins can finish. A later successful installation/update or server
|
||||
startup automatically removes obsolete managed installations once the servers
|
||||
using them have exited. Reloading config does not release old installations.
|
||||
Stop plugin work before shutting down its server: processes surviving server
|
||||
exit are not protected from cleanup. Plugins must stop detached background
|
||||
processes before their parent command or pane exits. Legacy installations
|
||||
without cleanup tracking are retained. Config and state are preserved.
|
||||
`plugin unlink <id>` only unregisters a plugin and leaves files alone, which is
|
||||
useful for local development.
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ impl App {
|
||||
if !self.policy.persist_plugin_registry {
|
||||
return Ok(());
|
||||
}
|
||||
let entries = crate::persist::plugin_registry::try_load()?;
|
||||
let entries = crate::plugin_installations::load(&mut self.plugin_installation_leases)?;
|
||||
self.replace_installed_plugins(entries);
|
||||
Ok(())
|
||||
}
|
||||
@@ -52,7 +52,7 @@ impl App {
|
||||
if !self.policy.persist_plugin_registry {
|
||||
return Ok(mutation(&mut self.state.installed_plugins));
|
||||
}
|
||||
let (result, entries) = crate::persist::plugin_registry::update(|entries| {
|
||||
let (result, _) = crate::persist::plugin_registry::update(|entries| {
|
||||
let mut registry = entries
|
||||
.drain(..)
|
||||
.map(|plugin| (plugin.plugin_id.clone(), plugin))
|
||||
@@ -61,7 +61,7 @@ impl App {
|
||||
*entries = registry.into_values().collect();
|
||||
result
|
||||
})?;
|
||||
self.replace_installed_plugins(entries);
|
||||
self.refresh_installed_plugins()?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
@@ -2655,6 +2655,86 @@ command = ["sh", "-c", "printf %s ${{HERDR_PANE_ID-unset}} > '{}'; sleep 1"]
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn running_plugin_command_keeps_its_files_after_app_teardown() {
|
||||
let _guard = crate::config::test_config_env_lock().lock().unwrap();
|
||||
let base = unique_temp_path("plugin-worker-lease");
|
||||
let previous_config = std::env::var_os("XDG_CONFIG_HOME");
|
||||
let previous_state = std::env::var_os("XDG_STATE_HOME");
|
||||
std::env::set_var("XDG_CONFIG_HOME", &base);
|
||||
std::env::set_var("XDG_STATE_HOME", base.join("state"));
|
||||
let installation =
|
||||
crate::plugin_paths::create_managed_installation("example.worktree-bootstrap").unwrap();
|
||||
let root = installation.join("checkout");
|
||||
write_manifest(&root);
|
||||
std::fs::create_dir_all(crate::plugin_paths::managed_plugins_dir().join(".locks")).unwrap();
|
||||
std::fs::write(root.join("payload"), "original").unwrap();
|
||||
drop(crate::plugin_installations::create_lease(&installation).unwrap());
|
||||
let mut plugin = load_plugin_manifest(&root.to_string_lossy(), true).unwrap();
|
||||
plugin.source.managed_path = Some(root.display().to_string());
|
||||
crate::persist::plugin_registry::update(|entries| *entries = vec![plugin.clone()]).unwrap();
|
||||
let mut app = test_app();
|
||||
crate::plugin_installations::load(&mut app.plugin_installation_leases).unwrap();
|
||||
let command = if cfg!(windows) {
|
||||
vec![
|
||||
"powershell.exe",
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"Set-Content ready ready; $deadline = [DateTime]::UtcNow.AddSeconds(10); while (!(Test-Path release) -and [DateTime]::UtcNow -lt $deadline) { Start-Sleep -Milliseconds 10 }; Get-Content payload | Set-Content result",
|
||||
]
|
||||
} else {
|
||||
vec![
|
||||
"sh",
|
||||
"-c",
|
||||
"printf ready > ready; i=0; while [ ! -f release ] && [ $i -lt 500 ]; do sleep 0.02; i=$((i+1)); done; cat payload > result",
|
||||
]
|
||||
}
|
||||
.into_iter()
|
||||
.map(str::to_string)
|
||||
.collect();
|
||||
app.start_plugin_command(
|
||||
&plugin,
|
||||
None,
|
||||
None,
|
||||
command,
|
||||
&app.current_plugin_context("lease-test"),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
read_capture_when_ready(&root.join("ready"), || {}).trim(),
|
||||
"ready"
|
||||
);
|
||||
crate::persist::plugin_registry::update(Vec::clear).unwrap();
|
||||
drop(app);
|
||||
crate::plugin_installations::cleanup().unwrap();
|
||||
assert!(root.exists(), "the worker still owns the old installation");
|
||||
std::fs::write(root.join("release"), "").unwrap();
|
||||
assert_eq!(
|
||||
read_capture_when_ready(&root.join("result"), || {}).trim(),
|
||||
"original"
|
||||
);
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
|
||||
while installation.exists() {
|
||||
crate::plugin_installations::cleanup().unwrap();
|
||||
assert!(
|
||||
std::time::Instant::now() < deadline,
|
||||
"finished worker must release its installation"
|
||||
);
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
}
|
||||
for (key, previous) in [
|
||||
("XDG_CONFIG_HOME", previous_config),
|
||||
("XDG_STATE_HOME", previous_state),
|
||||
] {
|
||||
match previous {
|
||||
Some(value) => std::env::set_var(key, value),
|
||||
None => std::env::remove_var(key),
|
||||
}
|
||||
}
|
||||
std::fs::remove_dir_all(base).unwrap();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn manifest_action_invoke_runs_command_and_captures_log() {
|
||||
|
||||
@@ -118,7 +118,11 @@ impl App {
|
||||
self.push_plugin_command_log(log.clone());
|
||||
self.state.plugin_commands_in_flight += 1;
|
||||
let event_tx = self.event_tx.clone();
|
||||
let installation_lease =
|
||||
crate::plugin_installations::command_lease(&self.plugin_installation_leases, plugin);
|
||||
std::thread::spawn(move || {
|
||||
// The worker may outlive App during normal server teardown.
|
||||
let _installation_lease = installation_lease;
|
||||
let child =
|
||||
crate::plugin_command::command_for_argv_in_dir(&program, &args, &plugin_root)
|
||||
.envs(env)
|
||||
@@ -181,6 +185,11 @@ impl App {
|
||||
}
|
||||
|
||||
pub(crate) fn run_plugin_startup_hooks(&mut self) {
|
||||
if self.policy.persist_plugin_registry {
|
||||
if let Err(err) = crate::plugin_installations::cleanup() {
|
||||
tracing::warn!(%err, "plugin cleanup deferred");
|
||||
}
|
||||
}
|
||||
let mut context = self.current_plugin_context("plugin.startup");
|
||||
context.invocation_source = Some("startup".to_string());
|
||||
let mut plugins = self
|
||||
|
||||
+45
-5
@@ -159,6 +159,11 @@ pub struct App {
|
||||
pub(crate) config_reloaded_from_disk: bool,
|
||||
client_shell_keybindings_profile: Option<String>,
|
||||
endpoint_commands: custom_commands::EndpointCommandRegistry,
|
||||
// Drop after terminal runtimes have shut down their processes. Command
|
||||
// workers retain their own references until completion.
|
||||
// ponytail: server-lifetime pins defer reclamation until restart; use
|
||||
// per-pane leases only if reclamation during long-running sessions matters.
|
||||
pub(crate) plugin_installation_leases: crate::plugin_installations::Leases,
|
||||
}
|
||||
|
||||
pub(crate) const APP_EVENT_CHANNEL_CAPACITY: usize = 256;
|
||||
@@ -174,11 +179,15 @@ fn background_update_check_enabled(background_updates: bool, check_enabled: bool
|
||||
|
||||
fn load_plugin_registry(
|
||||
persist_plugin_registry: bool,
|
||||
leases: &mut crate::plugin_installations::Leases,
|
||||
) -> crate::app::state::InstalledPluginRegistry {
|
||||
if !persist_plugin_registry {
|
||||
return std::collections::HashMap::new();
|
||||
}
|
||||
let entries = crate::persist::plugin_registry::load();
|
||||
let entries = crate::plugin_installations::load(leases).unwrap_or_else(|err| {
|
||||
tracing::warn!(%err, "failed to load plugin installations");
|
||||
Vec::new()
|
||||
});
|
||||
let entries = crate::persist::plugin_registry::reload_manifests(entries, |path, enabled| {
|
||||
crate::app::api::plugins::load_plugin_manifest(path, enabled).map_err(|(_, msg)| msg)
|
||||
});
|
||||
@@ -355,6 +364,7 @@ pub(crate) fn client_palette_for_appearance(
|
||||
}
|
||||
|
||||
impl App {
|
||||
#[cfg(test)]
|
||||
pub fn new(
|
||||
config: &Config,
|
||||
policy: AppPolicy,
|
||||
@@ -362,6 +372,17 @@ impl App {
|
||||
api_rx: tokio::sync::mpsc::UnboundedReceiver<crate::api::ApiRequestMessage>,
|
||||
event_hub: crate::api::EventHub,
|
||||
) -> Self {
|
||||
Self::try_new(config, policy, config_diagnostic, api_rx, event_hub)
|
||||
.expect("test app startup")
|
||||
}
|
||||
|
||||
pub fn try_new(
|
||||
config: &Config,
|
||||
policy: AppPolicy,
|
||||
config_diagnostic: Option<String>,
|
||||
api_rx: tokio::sync::mpsc::UnboundedReceiver<crate::api::ApiRequestMessage>,
|
||||
event_hub: crate::api::EventHub,
|
||||
) -> std::io::Result<Self> {
|
||||
let (prefix_code, prefix_mods) = config.prefix_key();
|
||||
crate::kitty_graphics::set_enabled(config.kitty_graphics_enabled());
|
||||
let (event_tx, event_rx) = mpsc::channel::<AppEvent>(APP_EVENT_CHANNEL_CAPACITY);
|
||||
@@ -372,6 +393,21 @@ impl App {
|
||||
let mut restored_terminals = std::collections::HashMap::new();
|
||||
let mut restored_terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new();
|
||||
let snapshot = policy.restore_session.then(crate::persist::load).flatten();
|
||||
let mut plugin_installation_leases = crate::plugin_installations::Leases::new();
|
||||
if policy.persist_plugin_registry {
|
||||
let restored_cwds = snapshot
|
||||
.as_ref()
|
||||
.into_iter()
|
||||
.flat_map(|snapshot| &snapshot.workspaces)
|
||||
.flat_map(|workspace| &workspace.tabs)
|
||||
.flat_map(|tab| tab.panes.values())
|
||||
.map(|pane| pane.cwd.clone())
|
||||
.collect::<Vec<_>>();
|
||||
crate::plugin_installations::retain_startup(
|
||||
&mut plugin_installation_leases,
|
||||
&restored_cwds,
|
||||
)?;
|
||||
}
|
||||
let session_writer = Arc::new(std::sync::Mutex::new(crate::persist::SessionWriter::new(
|
||||
policy.restore_session && snapshot.is_none(),
|
||||
)));
|
||||
@@ -516,7 +552,10 @@ impl App {
|
||||
integration_recommendations: crate::integration::integration_recommendations(),
|
||||
agent_manifest_summaries,
|
||||
agent_manifest_update_status: crate::detect::manifest_update::load_status(),
|
||||
installed_plugins: load_plugin_registry(policy.persist_plugin_registry),
|
||||
installed_plugins: load_plugin_registry(
|
||||
policy.persist_plugin_registry,
|
||||
&mut plugin_installation_leases,
|
||||
),
|
||||
plugin_panes: std::collections::HashMap::new(),
|
||||
popup_pane: None,
|
||||
plugin_command_logs: Vec::new(),
|
||||
@@ -568,6 +607,7 @@ impl App {
|
||||
custom_commands::EndpointCommandRegistry::new(&state.keybinds.custom_commands);
|
||||
|
||||
let mut app = Self {
|
||||
plugin_installation_leases,
|
||||
config_diagnostic_deadline: None,
|
||||
toast_deadline: None,
|
||||
last_api_notification_at: None,
|
||||
@@ -627,7 +667,7 @@ impl App {
|
||||
};
|
||||
app.configure_tab_bar_status(&config.ui.tab_bar_right, &config.ui.tab_bar_right_separator);
|
||||
app.configure_window_title(&config.ui.window_title);
|
||||
app
|
||||
Ok(app)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
@@ -642,13 +682,13 @@ impl App {
|
||||
crate::handoff_runtime::ImportedHandoffRuntime,
|
||||
>,
|
||||
) -> io::Result<Self> {
|
||||
let mut app = Self::new(
|
||||
let mut app = Self::try_new(
|
||||
config,
|
||||
AppPolicy::HANDOFF_REPLACEMENT,
|
||||
config_diagnostic,
|
||||
api_rx,
|
||||
event_hub,
|
||||
);
|
||||
)?;
|
||||
let (workspaces, terminals, runtimes) = crate::persist::restore_handoff(
|
||||
snapshot,
|
||||
config.advanced.scrollback_limit_bytes,
|
||||
|
||||
+13
-6
@@ -326,7 +326,7 @@ fn install_github_plugin(
|
||||
== Some(resolved_commit.as_str())
|
||||
{
|
||||
println!("{} is already up to date.", preview_plugin.plugin_id);
|
||||
return Ok(0);
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
let mut source_info =
|
||||
@@ -342,10 +342,11 @@ fn install_github_plugin(
|
||||
"plugin {} cancelled",
|
||||
if updating { "update" } else { "install" }
|
||||
);
|
||||
return Ok(0);
|
||||
return Ok(false);
|
||||
}
|
||||
let installation =
|
||||
crate::plugin_paths::create_managed_installation(&preview_plugin.plugin_id)?;
|
||||
let installation_lease = crate::plugin_installations::create_lease(&installation)?;
|
||||
let final_checkout = installation.join("checkout");
|
||||
let mut activation_attempted = false;
|
||||
let install_attempt = (|| {
|
||||
@@ -376,6 +377,7 @@ fn install_github_plugin(
|
||||
let plugin = match install_attempt {
|
||||
Ok(plugin) => plugin,
|
||||
Err(err) => {
|
||||
drop(installation_lease);
|
||||
// A failed activation may have published the path. Never delete files
|
||||
// that a server or a plugin process could already be using.
|
||||
if activation_attempted {
|
||||
@@ -393,8 +395,6 @@ fn install_github_plugin(
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
// ponytail: retain old installations, including across uninstall. Reclaim
|
||||
// them only once plugin process lifetimes can be tracked reliably.
|
||||
println!(
|
||||
"{} {} from {}.",
|
||||
if updating { "Updated" } else { "Installed" },
|
||||
@@ -405,10 +405,17 @@ fn install_github_plugin(
|
||||
"Config: {}",
|
||||
crate::plugin_paths::plugin_config_dir(&plugin.plugin_id).display()
|
||||
);
|
||||
Ok(0)
|
||||
Ok(true)
|
||||
})();
|
||||
let _ = std::fs::remove_dir_all(&temp_root);
|
||||
install_result
|
||||
install_result.map(|cleanup| {
|
||||
if cleanup {
|
||||
if let Err(err) = crate::plugin_installations::cleanup() {
|
||||
eprintln!("Plugin cleanup deferred: {err}");
|
||||
}
|
||||
}
|
||||
0
|
||||
})
|
||||
}
|
||||
|
||||
fn plugin_uninstall(args: &[String]) -> std::io::Result<i32> {
|
||||
|
||||
@@ -38,6 +38,7 @@ mod pane_graphics_files;
|
||||
mod persist;
|
||||
mod platform;
|
||||
mod plugin_command;
|
||||
mod plugin_installations;
|
||||
mod plugin_paths;
|
||||
mod popup_size;
|
||||
mod product_announcements;
|
||||
|
||||
@@ -88,7 +88,13 @@ pub fn update<T>(
|
||||
}
|
||||
|
||||
pub fn try_load() -> std::io::Result<Vec<InstalledPluginInfo>> {
|
||||
with_registry_lock(|| load_from_path_strict(®istry_path()))
|
||||
read(Ok)
|
||||
}
|
||||
|
||||
pub(crate) fn read<T>(
|
||||
operation: impl FnOnce(Vec<InstalledPluginInfo>) -> std::io::Result<T>,
|
||||
) -> std::io::Result<T> {
|
||||
with_registry_lock(|| operation(load_from_path_strict(®istry_path())?))
|
||||
}
|
||||
|
||||
/// Load the global registry. Returns an empty vec on failure so a corrupt or
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
use std::collections::HashMap;
|
||||
use std::fs::{File, OpenOptions, TryLockError};
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::api::schema::InstalledPluginInfo;
|
||||
|
||||
const LEASE_FILE: &str = ".in-use";
|
||||
pub(crate) type Leases = HashMap<PathBuf, Arc<File>>;
|
||||
|
||||
pub(crate) fn create_lease(installation: &Path) -> io::Result<File> {
|
||||
let file = OpenOptions::new()
|
||||
.create_new(true)
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(installation.join(LEASE_FILE))?;
|
||||
file.lock_shared()?;
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
fn lease_path(checkout: &Path) -> Option<PathBuf> {
|
||||
(checkout.file_name()? == "checkout").then(|| {
|
||||
let path = checkout.parent()?;
|
||||
Some(path.canonicalize().unwrap_or_else(|_| path.to_path_buf()))
|
||||
})?
|
||||
}
|
||||
|
||||
pub(crate) fn command_lease(leases: &Leases, plugin: &InstalledPluginInfo) -> Option<Arc<File>> {
|
||||
let installation = lease_path(Path::new(plugin.source.managed_path.as_deref()?))?;
|
||||
leases.get(&installation).cloned()
|
||||
}
|
||||
|
||||
// The caller holds the registry lock, preventing cleanup between registry
|
||||
// selection and acquisition. Missing markers denote untracked installations.
|
||||
fn retain_checkout(leases: &mut Leases, checkout: &Path) -> io::Result<()> {
|
||||
let Some(installation) = lease_path(checkout) else {
|
||||
return Ok(());
|
||||
};
|
||||
if leases.contains_key(&installation) {
|
||||
return Ok(());
|
||||
}
|
||||
let file = match OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(installation.join(LEASE_FILE))
|
||||
{
|
||||
Ok(file) => file,
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(()),
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
file.lock_shared()?;
|
||||
leases.insert(installation, Arc::new(file));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn load(leases: &mut Leases) -> io::Result<Vec<InstalledPluginInfo>> {
|
||||
crate::persist::plugin_registry::read(|entries| {
|
||||
for entry in &entries {
|
||||
if let Some(path) = &entry.source.managed_path {
|
||||
retain_checkout(leases, Path::new(path))?;
|
||||
}
|
||||
}
|
||||
Ok(entries)
|
||||
})
|
||||
}
|
||||
|
||||
fn installations() -> io::Result<Vec<PathBuf>> {
|
||||
let root = crate::plugin_paths::managed_plugins_dir().join("github-installations");
|
||||
let plugins = match std::fs::read_dir(root) {
|
||||
Ok(plugins) => plugins,
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
let mut result = Vec::new();
|
||||
for plugin in plugins {
|
||||
let plugin = plugin?;
|
||||
if !plugin.file_type()?.is_dir() {
|
||||
continue;
|
||||
}
|
||||
for generation in std::fs::read_dir(plugin.path())? {
|
||||
let generation = generation?;
|
||||
if generation.file_type()?.is_dir() {
|
||||
result.push(generation.path().canonicalize()?);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub(crate) fn retain_startup(leases: &mut Leases, restored_cwds: &[PathBuf]) -> io::Result<()> {
|
||||
crate::persist::plugin_registry::read(|_| {
|
||||
for installation in installations()? {
|
||||
if leases.contains_key(&installation) {
|
||||
continue;
|
||||
}
|
||||
let file = match OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(installation.join(LEASE_FILE))
|
||||
{
|
||||
Ok(file) => file,
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => continue,
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
let referenced = restored_cwds.iter().any(|cwd| {
|
||||
cwd.canonicalize()
|
||||
.unwrap_or_else(|_| cwd.clone())
|
||||
.starts_with(installation.join("checkout"))
|
||||
});
|
||||
match file.try_lock() {
|
||||
Ok(()) if !referenced => continue,
|
||||
Ok(()) => file.unlock()?,
|
||||
Err(TryLockError::WouldBlock) => {}
|
||||
Err(TryLockError::Error(err)) => return Err(err),
|
||||
}
|
||||
// Also retain generations held by the outgoing server during handoff.
|
||||
file.lock_shared()?;
|
||||
leases.insert(installation, Arc::new(file));
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn cleanup() -> io::Result<()> {
|
||||
crate::persist::plugin_registry::read(|entries| {
|
||||
for installation in installations()? {
|
||||
let checkout = installation.join("checkout");
|
||||
if entries.iter().any(|entry| {
|
||||
entry.source.managed_path.as_deref().is_some_and(|path| {
|
||||
let path = Path::new(path);
|
||||
path == checkout
|
||||
|| path.canonicalize().ok().as_deref() == Some(checkout.as_path())
|
||||
})
|
||||
}) {
|
||||
continue;
|
||||
}
|
||||
let Some(component) = installation.parent().and_then(Path::file_name) else {
|
||||
continue;
|
||||
};
|
||||
let mutation_path = crate::plugin_paths::managed_plugins_dir()
|
||||
.join(".locks")
|
||||
.join(format!(".{}.lock", component.to_string_lossy()));
|
||||
let mutation = OpenOptions::new()
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(mutation_path)?;
|
||||
match mutation.try_lock() {
|
||||
Ok(()) => {}
|
||||
Err(TryLockError::WouldBlock) => continue,
|
||||
Err(TryLockError::Error(err)) => return Err(err),
|
||||
}
|
||||
let lease = match OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(installation.join(LEASE_FILE))
|
||||
{
|
||||
Ok(file) => file,
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => continue,
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
match lease.try_lock() {
|
||||
Ok(()) => {}
|
||||
Err(TryLockError::WouldBlock) => continue,
|
||||
Err(TryLockError::Error(err)) => return Err(err),
|
||||
}
|
||||
// Windows cannot remove the open lease file. Registry + mutation
|
||||
// locks exclude new readers/installers while the handle is closed.
|
||||
drop(lease);
|
||||
std::fs::remove_dir_all(&installation)?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn with_config(test: impl FnOnce()) {
|
||||
let _guard = crate::config::test_config_env_lock().lock().unwrap();
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"herdr-plugin-cleanup-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
let previous = std::env::var_os("XDG_CONFIG_HOME");
|
||||
std::env::set_var("XDG_CONFIG_HOME", &root);
|
||||
std::fs::create_dir_all(crate::plugin_paths::managed_plugins_dir().join(".locks")).unwrap();
|
||||
test();
|
||||
match previous {
|
||||
Some(value) => std::env::set_var("XDG_CONFIG_HOME", value),
|
||||
None => std::env::remove_var("XDG_CONFIG_HOME"),
|
||||
}
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
fn installation(id: &str) -> (PathBuf, InstalledPluginInfo) {
|
||||
let root = crate::plugin_paths::create_managed_installation(id).unwrap();
|
||||
let checkout = root.join("checkout");
|
||||
std::fs::create_dir(&checkout).unwrap();
|
||||
std::fs::write(
|
||||
checkout.join("herdr-plugin.toml"),
|
||||
format!(
|
||||
"id = {id:?}\nname = 'Cleanup'\nversion = '0.1.0'\nmin_herdr_version = '0.6.10'\n"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let mut plugin =
|
||||
crate::app::load_plugin_manifest(&checkout.to_string_lossy(), true).unwrap();
|
||||
plugin.source.managed_path = Some(checkout.display().to_string());
|
||||
drop(create_lease(&root).unwrap());
|
||||
(root, plugin)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_waits_for_all_servers_and_command_references_and_preserves_current_paths() {
|
||||
with_config(|| {
|
||||
let (current, mut registered) = installation("example.current");
|
||||
// Registry paths and config paths can use different aliases.
|
||||
registered.source.managed_path =
|
||||
Some(current.join("checkout/../checkout").display().to_string());
|
||||
let (retired, old) = installation("example.retired");
|
||||
let untracked =
|
||||
crate::plugin_paths::create_managed_installation("example.legacy").unwrap();
|
||||
crate::persist::plugin_registry::update(|entries| {
|
||||
*entries = vec![registered.clone(), old.clone()];
|
||||
})
|
||||
.unwrap();
|
||||
let mut first_server = Leases::new();
|
||||
let mut second_server = Leases::new();
|
||||
load(&mut first_server).unwrap();
|
||||
load(&mut second_server).unwrap();
|
||||
let command = command_lease(&first_server, &old).unwrap();
|
||||
crate::persist::plugin_registry::update(|entries| *entries = vec![registered]).unwrap();
|
||||
drop(first_server);
|
||||
cleanup().unwrap();
|
||||
assert!(retired.exists());
|
||||
drop(second_server);
|
||||
cleanup().unwrap();
|
||||
assert!(retired.exists(), "running commands outlive the server map");
|
||||
drop(command);
|
||||
cleanup().unwrap();
|
||||
assert!(!retired.exists());
|
||||
assert!(
|
||||
current.exists(),
|
||||
"registered alias must protect the current files"
|
||||
);
|
||||
assert!(
|
||||
untracked.exists(),
|
||||
"untracked installations are never reclaimed"
|
||||
);
|
||||
|
||||
let (building, _) = installation("example.building");
|
||||
let mutation = OpenOptions::new()
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(crate::plugin_paths::managed_checkout_lock_path(
|
||||
"example.building",
|
||||
))
|
||||
.unwrap();
|
||||
mutation.lock().unwrap();
|
||||
cleanup().unwrap();
|
||||
assert!(
|
||||
building.exists(),
|
||||
"a building checkout is not yet registered"
|
||||
);
|
||||
drop(mutation);
|
||||
cleanup().unwrap();
|
||||
assert!(!building.exists());
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_retains_restored_and_handoff_generations_and_rejects_pin_errors() {
|
||||
with_config(|| {
|
||||
let (restored, _) = installation("example.restored");
|
||||
let (handoff, _) = installation("example.handoff");
|
||||
let outgoing = OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(handoff.join(LEASE_FILE))
|
||||
.unwrap();
|
||||
outgoing.lock_shared().unwrap();
|
||||
let mut incoming = Leases::new();
|
||||
retain_startup(&mut incoming, &[restored.join("checkout")]).unwrap();
|
||||
drop(outgoing);
|
||||
cleanup().unwrap();
|
||||
assert!(restored.exists());
|
||||
assert!(handoff.exists());
|
||||
drop(incoming);
|
||||
cleanup().unwrap();
|
||||
assert!(!restored.exists());
|
||||
assert!(!handoff.exists());
|
||||
|
||||
let broken =
|
||||
crate::plugin_paths::create_managed_installation("example.broken").unwrap();
|
||||
std::fs::create_dir(broken.join(LEASE_FILE)).unwrap();
|
||||
let (_tx, rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let app = crate::app::App::try_new(
|
||||
&crate::config::Config::default(),
|
||||
crate::app::AppPolicy {
|
||||
persist_plugin_registry: true,
|
||||
..crate::app::AppPolicy::TEST
|
||||
},
|
||||
None,
|
||||
rx,
|
||||
crate::api::EventHub::default(),
|
||||
);
|
||||
assert!(
|
||||
app.is_err(),
|
||||
"startup cannot restore consumers without their pins"
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
+13
-2
@@ -76,8 +76,11 @@ fn ensure_plugin_config_dir(plugin_id: &str) -> std::io::Result<()> {
|
||||
|
||||
fn legacy_plugin_config_dirs(plugin_id: &str) -> Vec<PathBuf> {
|
||||
let plugins_dir = managed_plugins_dir();
|
||||
let old_unhashed =
|
||||
(!matches!(plugin_id, "config" | "github")).then(|| plugins_dir.join(plugin_id));
|
||||
let old_unhashed = (!matches!(
|
||||
plugin_id,
|
||||
"config" | "github" | "github-installations" | ".locks"
|
||||
))
|
||||
.then(|| plugins_dir.join(plugin_id));
|
||||
let current_hashed =
|
||||
plugins_dir.join(crate::api::schema::plugin_managed_path_component(plugin_id));
|
||||
let mut candidates = Vec::new();
|
||||
@@ -161,6 +164,14 @@ mod tests {
|
||||
std::fs::read_to_string(first.join("keep")).unwrap(),
|
||||
"original"
|
||||
);
|
||||
ensure_plugin_config_dir("github-installations").unwrap();
|
||||
assert!(
|
||||
std::fs::read_dir(plugin_config_dir("github-installations"))
|
||||
.unwrap()
|
||||
.next()
|
||||
.is_none(),
|
||||
"managed installations are not legacy user configuration"
|
||||
);
|
||||
std::fs::remove_dir_all(&first).unwrap();
|
||||
std::fs::remove_dir_all(&second).unwrap();
|
||||
std::fs::remove_dir(managed_installations_dir(&id)).unwrap();
|
||||
|
||||
@@ -53,13 +53,13 @@ pub fn run_server() -> io::Result<()> {
|
||||
|
||||
let result = rt.block_on(async {
|
||||
// Create the App (with AppState, event channels, etc.).
|
||||
let mut app = app::App::new(
|
||||
let mut app = app::App::try_new(
|
||||
&loaded_config.config,
|
||||
app::AppPolicy::PRODUCTION,
|
||||
config::config_diagnostic_summary(&loaded_config.diagnostics),
|
||||
api_rx,
|
||||
event_hub,
|
||||
);
|
||||
)?;
|
||||
seed_startup_workspace_if_empty(&mut app);
|
||||
|
||||
// Create the headless server.
|
||||
|
||||
@@ -467,6 +467,19 @@ fn plugin_update_refreshes_selected_then_all_github_plugins() {
|
||||
|
||||
let _ = run_named_cli(&config_home, &runtime_dir, &["session", "stop", "updates"]);
|
||||
drop(server);
|
||||
let unchanged = run_named_cli_with_env(
|
||||
&config_home,
|
||||
&runtime_dir,
|
||||
&["plugin", "update", "--yes"],
|
||||
&[("GIT_CONFIG_GLOBAL", &git_config)],
|
||||
);
|
||||
assert!(unchanged.status.success());
|
||||
assert!(
|
||||
!first_installation.exists(),
|
||||
"stopped servers release old files"
|
||||
);
|
||||
assert!(!second_installation.exists());
|
||||
assert!(local_dir.exists(), "cleanup never removes local plugins");
|
||||
cleanup_test_base(&base);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user