feat(update): say when the daemon is another build and update it on request

- A daemon keeps running the copy it was started from, so after an update the old one ran until the next login and nothing said so; clients compared only the major protocol version. Every build now carries a UUID, the daemon reports it as ServerInfo.build_id under protocol 1.3, and a daemon too old to report one counts as outdated.
- The window shows a notice above every page when the daemon it reached is another build, with both versions. Update now registers the copy that was opened as the service and restarts the daemon from it; Not now puts the notice away. Nothing is restarted unless the user asks, so two copies open at once cannot replace each other's daemon in turn. A newer daemon is offered as Use this version, and one the service did not start, or one reached with --socket, gets no button.
- service install --start now stops a running daemon before registering and starting, so the daemon started is the program that was asked. Under systemd and Task Scheduler it used to rewrite the registration and leave the old daemon running, since starting a running service does nothing.
- service status says when the daemon is another build than the program asked, and --json carries same_build.
- The App Store app stops a daemon another build of the app left running and starts its own, instead of attaching to it.
- Packaging sets MIDI_HARBOR_BUILD_ID once for everything a run builds, because the App Store app, its helper and each architecture are compiled separately and must agree. Packages of one release share an identifier, so neither replaces the other's daemon.
This commit is contained in:
James Coleman 2026-10-02 11:56:27 -05:00
parent be2e93bcda
commit 5dce49ee43
26 changed files with 795 additions and 20 deletions

View file

@ -19,6 +19,9 @@ builds:
- --locked - --locked
- --package=midi-harbor - --package=midi-harbor
env: &env env: &env
# One identifier for everything this run builds, so a window and the daemon installed
# with it agree, and differ from every other release (research R-108).
- MIDI_HARBOR_BUILD_ID={{ .Env.MIDI_HARBOR_BUILD_ID }}
- MACOSX_DEPLOYMENT_TARGET=11.0 - MACOSX_DEPLOYMENT_TARGET=11.0
- SDKROOT=/osxcross/MacOSX.sdk - SDKROOT=/osxcross/MacOSX.sdk
- PKG_CONFIG_ALLOW_CROSS=1 - PKG_CONFIG_ALLOW_CROSS=1

View file

@ -13,6 +13,8 @@ TARGET_VOLUME := midi-harbor-release-target
MIDI_HARBOR_MAINTAINER ?= $(shell git config user.name) <$(shell git config user.email)> MIDI_HARBOR_MAINTAINER ?= $(shell git config user.name) <$(shell git config user.email)>
# The version every build step uses; a release is the commit tagged with it. # The version every build step uses; a release is the commit tagged with it.
VERSION := $(shell cat VERSION) VERSION := $(shell cat VERSION)
# Identifies everything one release run builds, apart from every other build of the version.
BUILD_ID := $(shell uuidgen | tr '[:upper:]' '[:lower:]')
# GoReleaser runs one build at a time: its Rust builder reads each binary from target/, which # GoReleaser runs one build at a time: its Rust builder reads each binary from target/, which
# the full and headless builds of one target share. Cargo still builds each in parallel. # the full and headless builds of one target share. Cargo still builds each in parallel.
@ -29,6 +31,7 @@ GORELEASER = mkdir -p $(CARGO_CACHE) && docker run --rm \
-e HOME=/tmp \ -e HOME=/tmp \
-e MIDI_HARBOR_MAINTAINER="$(MIDI_HARBOR_MAINTAINER)" \ -e MIDI_HARBOR_MAINTAINER="$(MIDI_HARBOR_MAINTAINER)" \
-e MIDI_HARBOR_VERSION="$(VERSION)" \ -e MIDI_HARBOR_VERSION="$(VERSION)" \
-e MIDI_HARBOR_BUILD_ID="$(BUILD_ID)" \
-v $(CURDIR):/src \ -v $(CURDIR):/src \
-v $(SYSROOT):/sysroot:ro \ -v $(SYSROOT):/sysroot:ro \
-v $(CARGO_CACHE):/cargo-home \ -v $(CARGO_CACHE):/cargo-home \

View file

@ -74,7 +74,15 @@ pub async fn run(
.status() .status()
.map(|status| status.installed) .map(|status| status.installed)
.unwrap_or(false); .unwrap_or(false);
let path = match manager.install(&spec) { // With --start a daemon already running is stopped first, so the one started is this
// copy. Starting a service that is running does nothing, and left an updated program
// registered beside the old daemon still running.
let installed = if *start {
manager.replace(&spec)
} else {
manager.install(&spec)
};
let path = match installed {
Ok(path) => path, Ok(path) => path,
Err(error) => { Err(error) => {
eprintln!("could not install the service: {error}"); eprintln!("could not install the service: {error}");
@ -91,10 +99,6 @@ pub async fn run(
format.line("it will start automatically at login"); format.line("it will start automatically at login");
if *start { if *start {
if let Err(error) = manager.start() {
eprintln!("the service was installed but could not be started: {error}");
return ExitCode::Failure;
}
return report_started(format); return report_started(format);
} }
ExitCode::Success ExitCode::Success
@ -156,6 +160,7 @@ pub async fn run(
"definition_path": status.definition_path, "definition_path": status.definition_path,
"registered_executable": status.registered_executable, "registered_executable": status.registered_executable,
"daemon_version": daemon.as_ref().map(|info| info.daemon_version.clone()), "daemon_version": daemon.as_ref().map(|info| info.daemon_version.clone()),
"same_build": daemon.as_ref().map(|info| info.build_id == midi_harbor_core::BUILD_ID),
"started_at": started.map(|at| at.to_string()), "started_at": started.map(|at| at.to_string()),
"uptime_seconds": uptime, "uptime_seconds": uptime,
})); }));
@ -167,6 +172,14 @@ pub async fn run(
line.push_str(&format!(", up {}", uptime_text(seconds))); line.push_str(&format!(", up {}", uptime_text(seconds)));
} }
format.line(line); format.line(line);
// A daemon left running by another copy does not have what this one
// has, and nothing else here would say so.
if info.build_id != midi_harbor_core::BUILD_ID {
format.line(
"daemon: another build than this program; run 'midi-harbor \
service install --start' to replace it with this one",
);
}
} }
if let Some(path) = &status.definition_path { if let Some(path) = &status.definition_path {
format.line(format!("definition: {}", path.display())); format.line(format!("definition: {}", path.display()));

View file

@ -20,6 +20,9 @@ serde_yaml_ng.workspace = true
tracing.workspace = true tracing.workspace = true
uuid.workspace = true uuid.workspace = true
[build-dependencies]
uuid.workspace = true
[dev-dependencies] [dev-dependencies]
proptest.workspace = true proptest.workspace = true
serde_json.workspace = true serde_json.workspace = true

19
crates/core/build.rs Normal file
View file

@ -0,0 +1,19 @@
//! Gives every build an identifier of its own, so a window can tell whether the daemon it
//! reached is the build it came with (research R-108).
//!
//! Packaging sets `MIDI_HARBOR_BUILD_ID` once for everything that goes into one package, so the
//! app and the daemon it carries agree even when they are built separately. Without it a new
//! identifier is made whenever this crate or the version changes, which is as often as a build
//! outside packaging can be told apart.
fn main() {
println!("cargo:rerun-if-env-changed=MIDI_HARBOR_BUILD_ID");
println!("cargo:rerun-if-changed=build.rs");
println!("cargo:rerun-if-changed=src");
println!("cargo:rerun-if-changed=../../VERSION");
let id = std::env::var("MIDI_HARBOR_BUILD_ID")
.ok()
.filter(|id| !id.trim().is_empty())
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
println!("cargo:rustc-env=MIDI_HARBOR_BUILD_ID={}", id.trim());
}

View file

@ -29,6 +29,13 @@ pub mod time;
/// build step reads. The crates' own versions stay 0.0.0, since they are never published. /// build step reads. The crates' own versions stay 0.0.0, since they are never published.
pub const VERSION: &str = include_str!("../../../VERSION").trim_ascii(); pub const VERSION: &str = include_str!("../../../VERSION").trim_ascii();
/// Identifies this build, apart from every other build of the same version.
///
/// The daemon reports it and the window compares it with its own, which is how a window tells
/// that the daemon it reached was left running by another copy of Midi Harbor (R-108). A UUID
/// packaging gives everything in one package, or one made when this crate was built.
pub const BUILD_ID: &str = env!("MIDI_HARBOR_BUILD_ID");
pub use backoff::{Backoff, BackoffPolicy}; pub use backoff::{Backoff, BackoffPolicy};
pub use capability::{Capability, CapabilityName, CapabilitySet, UnavailableReason}; pub use capability::{Capability, CapabilityName, CapabilitySet, UnavailableReason};
pub use config::Configuration; pub use config::Configuration;

View file

@ -489,6 +489,7 @@ impl Harbor for HarborService {
) -> Result<Response<pb::ServerInfo>, Status> { ) -> Result<Response<pb::ServerInfo>, Status> {
Ok(Response::new(pb::ServerInfo { Ok(Response::new(pb::ServerInfo {
daemon_version: midi_harbor_core::VERSION.to_owned(), daemon_version: midi_harbor_core::VERSION.to_owned(),
build_id: midi_harbor_core::BUILD_ID.to_owned(),
protocol_major: midi_harbor_ipc::PROTOCOL_MAJOR, protocol_major: midi_harbor_ipc::PROTOCOL_MAJOR,
protocol_minor: midi_harbor_ipc::PROTOCOL_MINOR, protocol_minor: midi_harbor_ipc::PROTOCOL_MINOR,
started_at: to_proto_time(self.daemon.started_at()), started_at: to_proto_time(self.daemon.started_at()),

View file

@ -360,6 +360,36 @@ async fn a_session_reads_the_same_on_the_stream_as_in_a_listing() {
); );
} }
/// Proves that the daemon tells a client which build it is, in the field protocol 1.3 added.
///
/// A window compares it with its own to tell that the daemon was left running by another copy
/// of Midi Harbor (R-108). Here the client and the daemon are one build, so the two agree; a
/// daemon that reported nothing would be replaced by every window that reached it.
#[tokio::test]
async fn the_daemon_reports_which_build_it_is() {
let (_daemon, _platform, socket) = serving("build").await;
let mut client = HarborClient::new(
transport::connect(&socket)
.await
.expect("the client connects to the daemon's socket"),
);
let info = client
.get_server_info(pb::GetServerInfoRequest {})
.await
.expect("the daemon says what it is")
.into_inner();
assert_eq!(
(info.build_id.as_str(), info.protocol_minor),
(midi_harbor_core::BUILD_ID, 3),
"the daemon's build identifier is not this build's, under protocol 1.3"
);
assert!(
uuid::Uuid::parse_str(&info.build_id).is_ok(),
"the build identifier is not a UUID: {:?}",
info.build_id
);
}
/// Starts a daemon with one network port accepting everyone, returning its UDP port. /// Starts a daemon with one network port accepting everyone, returning its UDP port.
async fn accepting(label: &str, name: &str) -> (Arc<Daemon>, u16) { async fn accepting(label: &str, name: &str) -> (Arc<Daemon>, u16) {
let root = let root =

View file

@ -8,6 +8,7 @@ use crate::client::{Client, Snapshot};
use crate::format; use crate::format;
use crate::onboarding; use crate::onboarding;
use crate::parts; use crate::parts;
use crate::update;
use crate::{dialogs, view}; use crate::{dialogs, view};
use cosmic::app::context_drawer::{self, ContextDrawer}; use cosmic::app::context_drawer::{self, ContextDrawer};
use cosmic::app::{Core, Task}; use cosmic::app::{Core, Task};
@ -291,9 +292,26 @@ pub struct ServiceState {
pub failed: Option<String>, pub failed: Option<String>,
} }
/// What the window is doing about a daemon that is another build than itself (R-108).
#[derive(Default)]
pub struct BuildState {
/// Whether the daemon is being replaced now.
pub replacing: bool,
/// What was found, shown until the user updates the daemon or dismisses the notice.
pub notice: Option<update::Notice>,
}
/// What the window reacts to. /// What the window reacts to.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum Message { pub enum Message {
/// The service manager was asked what runs the daemon, which is another build.
BuildChecked(Option<midi_harbor_service::ServiceStatus>),
/// The user chose to update the daemon to this copy.
ReplaceDaemon,
/// Replacing the daemon finished.
DaemonReplaced(Result<(), String>),
/// The notice about the daemon's build was dismissed.
DismissBuildNotice,
/// The periodic refresh fired. /// The periodic refresh fired.
Tick, Tick,
/// A connection attempt finished. /// A connection attempt finished.
@ -462,6 +480,8 @@ pub struct App {
pub test_note: TestNote, pub test_note: TestNote,
/// The background service, while the daemon cannot be reached. /// The background service, while the daemon cannot be reached.
pub service: ServiceState, pub service: ServiceState,
/// What is being done about a daemon of another build.
pub build: BuildState,
/// Counts state streams started, so an ended one is replaced by a new one rather than the /// Counts state streams started, so an ended one is replaced by a new one rather than the
/// subscription deciding it is still the same stream. /// subscription deciding it is still the same stream.
stream_generation: u64, stream_generation: u64,
@ -481,6 +501,26 @@ pub struct App {
} }
impl App { impl App {
/// Registers this copy as the service and restarts the daemon from it, as the user asked.
fn replace_daemon(&mut self) -> Task<Message> {
if self.build.replacing {
return Task::none();
}
self.build.replacing = true;
self.build.notice = None;
self.client = None;
self.service = ServiceState {
working: true,
..ServiceState::default()
};
self.unreachable = Some(
"The daemon is being restarted from this copy. Your ports and connections come \
back in a moment."
.to_owned(),
);
cosmic::task::future(async { Message::DaemonReplaced(update::replace().await) })
}
/// Runs a call against the daemon, reporting only whether it failed. /// Runs a call against the daemon, reporting only whether it failed.
fn act<F, Fut>(&self, call: F) -> Task<Message> fn act<F, Fut>(&self, call: F) -> Task<Message>
where where
@ -796,6 +836,7 @@ impl cosmic::Application for App {
monitor: MonitorState::default(), monitor: MonitorState::default(),
test_note: TestNote::default(), test_note: TestNote::default(),
service: ServiceState::default(), service: ServiceState::default(),
build: BuildState::default(),
stream_generation: 0, stream_generation: 0,
selected: None, selected: None,
dialog: None, dialog: None,
@ -938,6 +979,11 @@ impl cosmic::Application for App {
// A tick with no client is a reconnect attempt, which is how the window recovers // A tick with no client is a reconnect attempt, which is how the window recovers
// from a daemon that was restarted while it was open. // from a daemon that was restarted while it was open.
if self.client.is_none() { if self.client.is_none() {
// The daemon being replaced still answers until it is stopped, and is not
// the one to connect to.
if self.build.replacing {
return Task::none();
}
let socket = self.socket.clone(); let socket = self.socket.clone();
return cosmic::task::future(async { return cosmic::task::future(async {
Message::Connected(Client::connect(socket).await) Message::Connected(Client::connect(socket).await)
@ -945,11 +991,61 @@ impl cosmic::Application for App {
} }
self.refresh() self.refresh()
} }
Message::Connected(Ok(_)) if self.build.replacing => Task::none(),
Message::Connected(Ok(client)) => { Message::Connected(Ok(client)) => {
let same_build = client.same_build();
self.client = Some(client); self.client = Some(client);
self.unreachable = None; self.unreachable = None;
self.service = ServiceState::default(); self.service = ServiceState::default();
self.refresh() if same_build {
self.build.notice = None;
return self.refresh();
}
// The App Store app settles this before the window connects, since it starts
// the daemon itself.
#[cfg(target_os = "macos")]
if self.store.is_some() {
return self.refresh();
}
// Only the standard socket is the service's, so there is nothing to ask about
// a daemon reached with --socket.
let check = if self.socket.is_some() {
cosmic::task::future(async { Message::BuildChecked(None) })
} else {
cosmic::task::future(async {
Message::BuildChecked(update::service_status().await)
})
};
Task::batch([self.refresh(), check])
}
Message::BuildChecked(service) => {
let Some(client) = &self.client else {
return Task::none();
};
if !client.same_build() {
self.build.notice = Some(update::Notice::about(
client.daemon_version(),
midi_harbor_core::VERSION,
self.socket.is_some(),
service.as_ref(),
));
}
Task::none()
}
Message::ReplaceDaemon => self.replace_daemon(),
Message::DaemonReplaced(result) => {
self.build.replacing = false;
self.service.working = false;
// The next tick connects to whatever is running now, and says so again if it is
// still another build.
if let Err(error) = result {
self.service.failed = Some(error);
}
Task::none()
}
Message::DismissBuildNotice => {
self.build.notice = None;
Task::none()
} }
Message::Connected(Err(error)) => { Message::Connected(Err(error)) => {
self.unreachable = Some(error); self.unreachable = Some(error);

View file

@ -40,17 +40,28 @@ impl DaemonOwner {
/// and waits for it to serve. /// and waits for it to serve.
/// ///
/// A second daemon would open the same ports and sessions beside the first, so one that /// A second daemon would open the same ports and sessions beside the first, so one that
/// answers is always used rather than replaced. /// answers is used when it is this build, and stopped first when it is another.
pub async fn start( pub async fn start(
program: &Path, program: &Path,
arguments: &[OsString], arguments: &[OsString],
socket: &Path, socket: &Path,
) -> Result<Self, String> { ) -> Result<Self, String> {
if midi_harbor_ipc::transport::probe(socket).await { if midi_harbor_ipc::transport::probe(socket).await {
info!(socket = %socket.display(), "using the daemon already running"); let attached = Self::Attached {
return Ok(Self::Attached {
socket: socket.to_path_buf(), socket: socket.to_path_buf(),
}); };
// One left by an earlier version of the app is stopped and replaced by this app's
// own, or an update would go on running the old daemon (R-108). One that does not
// answer is treated as this build, and used.
let same_build = crate::client::Client::connect(Some(socket.to_path_buf()))
.await
.map_or(true, |client| client.same_build());
if same_build {
info!(socket = %socket.display(), "using the daemon already running");
return Ok(attached);
}
info!(socket = %socket.display(), "replacing the daemon another build left running");
attached.stop().await;
} }
// Start one, and wait for it to serve. // Start one, and wait for it to serve.

View file

@ -41,6 +41,9 @@ const EVENT_LIMIT: u32 = 100;
#[derive(Clone)] #[derive(Clone)]
pub struct Client { pub struct Client {
inner: HarborClient<Channel>, inner: HarborClient<Channel>,
/// What the daemon said about itself when the connection was made. Shared, so a clone of
/// the client stays as small as its channel.
server: std::sync::Arc<midi_harbor_ipc::pb::ServerInfo>,
} }
/// Everything one refresh returns, so the interface never shows two halves of different moments. /// Everything one refresh returns, so the interface never shows two halves of different moments.
@ -145,7 +148,20 @@ impl Client {
if let Err(mismatch) = check_compatibility(&server) { if let Err(mismatch) = check_compatibility(&server) {
return Err(mismatch.guidance()); return Err(mismatch.guidance());
} }
Ok(Self { inner }) Ok(Self {
inner,
server: std::sync::Arc::new(server),
})
}
/// Returns the version the daemon reported.
pub fn daemon_version(&self) -> &str {
&self.server.daemon_version
}
/// Reports whether the daemon is the build this window is. A daemon too old to say is not.
pub fn same_build(&self) -> bool {
self.server.build_id == midi_harbor_core::BUILD_ID
} }
/// Reads the whole visible state in one pass. /// Reads the whole visible state in one pass.

View file

@ -11,6 +11,7 @@ mod dialogs;
pub mod format; pub mod format;
mod onboarding; mod onboarding;
mod parts; mod parts;
mod update;
mod view; mod view;
use cosmic::app::Settings; use cosmic::app::Settings;

250
crates/gui/src/update.rs Normal file
View file

@ -0,0 +1,250 @@
//! Telling the user that the daemon is not the build the window is, and updating it when they
//! ask (research R-108).
//!
//! The window and the daemon are one program, installed together. A daemon keeps running the
//! copy it was started from, so after an update, or with a second copy of Midi Harbor installed
//! another way, the window can reach a daemon that is not the build it came with. Every build
//! carries an identifier and the daemon reports its own. When they differ the window says so,
//! and offers to register its own copy as the service and restart the daemon from it.
//!
//! Nothing is replaced until the user asks. A restart drops every connection for a few seconds,
//! which is theirs to time, and two windows of different builds cannot then replace each
//! other's daemon in turn.
use midi_harbor_service::{ServiceSpec, ServiceStatus};
/// How the daemon's version stands against the window's.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Relation {
/// The daemon is an older version, or too old to say which.
Older {
/// The version the daemon reports, empty when it reports none.
version: String,
},
/// The daemon is this version, built or installed separately.
Same,
/// The daemon is a newer version. This window can still take its place, but an older
/// daemon may not read the configuration the newer one wrote.
Newer {
/// The version the daemon reports.
version: String,
},
}
/// Why the window cannot replace the daemon itself.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Blocked {
/// The window was pointed at the daemon with `--socket`, which the service does not serve.
OtherSocket,
/// The service is not running the daemon, so there is no registration that says how it was
/// started.
ByHand,
}
/// What the window says about a daemon that is another build than itself.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Notice {
/// How the daemon's version stands against the window's.
pub relation: Relation,
/// Why the window cannot replace it, when it cannot.
pub blocked: Option<Blocked>,
}
impl Notice {
/// Works out what to say about a daemon of another build, reporting `daemon_version`, from
/// a window of `own_version`. `other_socket` says the window was started with `--socket`,
/// and `service` is what the service manager reports, when there is one to ask.
pub fn about(
daemon_version: &str,
own_version: &str,
other_socket: bool,
service: Option<&ServiceStatus>,
) -> Self {
let relation = match (numbers(daemon_version), numbers(own_version)) {
(Some(daemon), Some(own)) if daemon > own => Relation::Newer {
version: daemon_version.to_owned(),
},
(Some(daemon), Some(own)) if daemon == own => Relation::Same,
_ => Relation::Older {
version: daemon_version.to_owned(),
},
};
let by_service = service.is_some_and(|service| service.installed && service.running);
let blocked = if other_socket {
Some(Blocked::OtherSocket)
} else if by_service {
None
} else {
Some(Blocked::ByHand)
};
Self { relation, blocked }
}
/// Returns the notice's heading.
pub fn heading(&self) -> &'static str {
match self.relation {
Relation::Older { .. } => "The daemon is outdated",
Relation::Same => "The daemon is a different build",
Relation::Newer { .. } => "The daemon is newer than this window",
}
}
/// Says what was found and what updating does, or what to do where the window cannot.
pub fn explanation(&self, own_version: &str) -> String {
let found = match &self.relation {
Relation::Older { version } if version.is_empty() => {
format!("It is running an older Midi Harbor, and this is {own_version}.")
}
Relation::Older { version } | Relation::Newer { version } => {
format!("It is running Midi Harbor {version}, and this is {own_version}.")
}
Relation::Same => {
format!("It was started from another copy of Midi Harbor {own_version}.")
}
};
let next = match (self.blocked, &self.relation) {
(Some(Blocked::OtherSocket), _) => {
"This window was pointed at it with --socket, so restart it from this copy to \
use this version."
}
(Some(Blocked::ByHand), _) => {
"The background service did not start it, so stop it and start it from this \
copy to use this version."
}
(None, Relation::Newer { .. }) => {
"Open that version instead, or put this older one in its place. Connections \
drop for a few seconds and come back."
}
(None, _) => {
"Updating restarts it from this copy. Connections drop for a few seconds and \
come back."
}
};
format!("{found} {next}")
}
/// Returns the label of the button that replaces the daemon, or nothing when the window
/// cannot.
pub fn action(&self) -> Option<&'static str> {
if self.blocked.is_some() {
return None;
}
Some(match self.relation {
Relation::Newer { .. } => "Use this version",
_ => "Update now",
})
}
}
/// Reads a version's numbers, leaving out anything after them such as `-rc1`.
///
/// Compared as numbers, since 0.10.0 is newer than 0.9.3 and older as text.
fn numbers(version: &str) -> Option<Vec<u64>> {
version
.split(['-', '+'])
.next()?
.split('.')
.map(|part| part.parse().ok())
.collect()
}
/// Asks the service manager what is registered, or returns nothing where there is none to ask.
pub async fn service_status() -> Option<ServiceStatus> {
tokio::task::spawn_blocking(|| midi_harbor_service::detect().ok()?.status().ok())
.await
.ok()
.flatten()
}
/// Registers this copy as the service in place of whatever is registered, and restarts the
/// daemon from it.
pub async fn replace() -> Result<(), String> {
let done = tokio::task::spawn_blocking(|| {
let manager = midi_harbor_service::detect().map_err(|error| error.to_string())?;
let spec = ServiceSpec::for_current_executable().map_err(|error| error.to_string())?;
manager
.replace(&spec)
.map(|_| ())
.map_err(|error| format!("could not update the daemon: {error}"))
})
.await;
done.map_err(|error| format!("updating the daemon did not finish: {error}"))?
}
#[cfg(test)]
mod tests {
use super::*;
/// Proves what the window says about a daemon of another build, and when it offers to
/// replace it: only a daemon the service is running, reached on the service's own socket.
///
/// 0.10.0 is newer than 0.9.3 by its numbers and older as text. A daemon older than
/// protocol 1.3 reports no build, and may report a version; one that reports neither is
/// outdated all the same.
#[test]
fn a_daemon_of_another_build_is_named_and_offered_only_where_the_service_runs_it() {
let running = ServiceStatus {
installed: true,
running: true,
..ServiceStatus::default()
};
let stopped = ServiceStatus {
running: false,
..running.clone()
};
let older = |version: &str| Relation::Older {
version: version.to_owned(),
};
let cases = [
(
"an older daemon",
("0.9.3", "0.10.0", false, Some(&running)),
(older("0.9.3"), None, Some("Update now")),
),
(
"a daemon that reports no version",
("", "0.10.0", false, Some(&running)),
(older(""), None, Some("Update now")),
),
(
"another build of this version",
("0.10.0", "0.10.0", false, Some(&running)),
(Relation::Same, None, Some("Update now")),
),
(
"a newer daemon",
("0.10.0", "0.9.3", false, Some(&running)),
(
Relation::Newer {
version: "0.10.0".to_owned(),
},
None,
Some("Use this version"),
),
),
(
"a daemon the service is not running",
("0.9.3", "0.10.0", false, Some(&stopped)),
(older("0.9.3"), Some(Blocked::ByHand), None),
),
(
"no service manager",
("0.9.3", "0.10.0", false, None),
(older("0.9.3"), Some(Blocked::ByHand), None),
),
(
"a window pointed at another socket",
("0.9.3", "0.10.0", true, Some(&running)),
(older("0.9.3"), Some(Blocked::OtherSocket), None),
),
];
for (name, (daemon, own, other_socket, service), (relation, blocked, action)) in cases {
let notice = Notice::about(daemon, own, other_socket, service);
assert_eq!(
(notice.relation.clone(), notice.blocked, notice.action()),
(relation, blocked, action),
"{name}: said or offered wrongly"
);
}
}
}

View file

@ -53,6 +53,11 @@ pub fn window(app: &App) -> Element<'_, Message> {
{ {
column = column.push(midi_server_banner(at)); column = column.push(midi_server_banner(at));
} }
// Shown above whatever page is open: the daemon is not running what this window came with,
// and only the user can say when a restart suits.
if let Some(notice) = &app.build.notice {
column = column.push(build_banner(notice));
}
// Shown above whatever page is open, because a machine is waiting on an answer and will give // Shown above whatever page is open, because a machine is waiting on an answer and will give
// up. A page the user has to think to visit would be a prompt nobody sees. // up. A page the user has to think to visit would be a prompt nobody sees.
for invitation in &app.snapshot.invitations { for invitation in &app.snapshot.invitations {
@ -704,6 +709,32 @@ fn midi_server_banner<'a>(at: &prost_types::Timestamp) -> Element<'a, Message> {
.into() .into()
} }
/// Builds the notice that the daemon is another build of Midi Harbor than this window, with the
/// button that updates it where the window can.
fn build_banner<'a>(notice: &crate::update::Notice) -> Element<'a, Message> {
let mut row = widget::row::with_capacity(4)
.spacing(10)
.align_y(Alignment::Center)
.push(dot(Tone::Waiting))
.push(
widget::column::with_capacity(2)
.push(widget::text::heading(notice.heading()))
.push(widget::text::caption(
notice.explanation(midi_harbor_core::VERSION),
))
.width(Length::Fill),
);
if let Some(label) = notice.action() {
row = row.push(widget::button::suggested(label).on_press(Message::ReplaceDaemon));
}
widget::container(
row.push(widget::button::text("Not now").on_press(Message::DismissBuildNotice)),
)
.padding(12)
.class(cosmic::theme::Container::Card)
.into()
}
/// Builds the notice for a machine asking to join a network port. /// Builds the notice for a machine asking to join a network port.
fn invitation_banner<'a>(app: &'a App, invitation: &'a Invitation) -> Element<'a, Message> { fn invitation_banner<'a>(app: &'a App, invitation: &'a Invitation) -> Element<'a, Message> {
let id = invitation.invitation_id.clone(); let id = invitation.invitation_id.clone();
@ -763,6 +794,12 @@ fn unreachable<'a>(app: &'a App, error: &'a str) -> Element<'a, Message> {
.into(), .into(),
}; };
} }
if app.build.replacing {
return column
.push(widget::text::title3("Updating Midi Harbor…"))
.push(widget::text::body(error))
.into();
}
let Some(checked) = &app.service.checked else { let Some(checked) = &app.service.checked else {
column = column column = column
.push(widget::text::title3("Midi Harbor cannot be reached")) .push(widget::text::title3("Midi Harbor cannot be reached"))

View file

@ -15,8 +15,8 @@ pub const PROTOCOL_MAJOR: u32 = 1;
/// is unchanged. 1.2 added `StatusSummary.midi_server_replaced_at`, the last-received and /// is unchanged. 1.2 added `StatusSummary.midi_server_replaced_at`, the last-received and
/// last-sent times in `TrafficCounters`, and a network port's `automatic_port_counters`, which an /// last-sent times in `TrafficCounters`, and a network port's `automatic_port_counters`, which an
/// older daemon leaves unset, and `SendTestNote` and `DismissMidiServerWarning`, which it answers /// older daemon leaves unset, and `SendTestNote` and `DismissMidiServerWarning`, which it answers
/// with `UNIMPLEMENTED`. /// with `UNIMPLEMENTED`. 1.3 added `ServerInfo.build_id`, which an older daemon leaves empty.
pub const PROTOCOL_MINOR: u32 = 2; pub const PROTOCOL_MINOR: u32 = 3;
/// A client and daemon that cannot talk to each other. /// A client and daemon that cannot talk to each other.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
@ -120,6 +120,7 @@ mod tests {
started_at: None, started_at: None,
config_path: String::new(), config_path: String::new(),
socket_path: String::new(), socket_path: String::new(),
build_id: String::new(),
}; };
assert_eq!( assert_eq!(
check_compatibility(&info) check_compatibility(&info)

View file

@ -138,6 +138,14 @@ impl ServiceManager for Launchd {
Ok(self.plist_path.clone()) Ok(self.plist_path.clone())
} }
fn replace(&self, spec: &ServiceSpec) -> Result<PathBuf, ServiceError> {
// Installing boots the old job out, which stops its daemon. Stopping it first would
// only have launchd start the old one again in between.
let path = self.install(spec)?;
self.start()?;
Ok(path)
}
fn uninstall(&self) -> Result<(), ServiceError> { fn uninstall(&self) -> Result<(), ServiceError> {
self.bootout(); self.bootout();
match std::fs::remove_file(&self.plist_path) { match std::fs::remove_file(&self.plist_path) {

View file

@ -168,6 +168,19 @@ pub trait ServiceManager: Send + Sync {
/// Stops the service now. /// Stops the service now.
fn stop(&self) -> Result<(), ServiceError>; fn stop(&self) -> Result<(), ServiceError>;
/// Registers `spec` in place of whatever is registered and runs it now, stopping the daemon
/// the old registration was running.
///
/// Stopped before the definition changes, so the service manager stops the process it
/// started rather than losing track of it.
fn replace(&self, spec: &ServiceSpec) -> Result<PathBuf, ServiceError> {
// Nothing running is not a failure of replacing it.
let _ = self.stop();
let path = self.install(spec)?;
self.start()?;
Ok(path)
}
/// Reports whether the service is installed, running, and whether its registration is stale. /// Reports whether the service is installed, running, and whether its registration is stale.
fn status(&self) -> Result<ServiceStatus, ServiceError>; fn status(&self) -> Result<ServiceStatus, ServiceError>;

View file

@ -47,10 +47,10 @@ endpoint is refused with the candidates listed, never resolved by guessing.
|---|---| |---|---|
| `daemon [--log-file PATH]` | Run the daemon in the foreground. Refuses to start while another is running. With `--log-file`, it logs to that file instead of the terminal, rolling it over at 10 MB. | | `daemon [--log-file PATH]` | Run the daemon in the foreground. Refuses to start while another is running. With `--log-file`, it logs to that file instead of the terminal, rolling it over at 10 MB. |
| `gui` | Open the graphical interface. | | `gui` | Open the graphical interface. |
| `service install [--start]` | Register the daemon to run at login, and with `--start`, start it now. Running it again updates the registration in place. | | `service install [--start]` | Register the daemon to run at login, and with `--start`, start it now. Running it again updates the registration in place, and with `--start` stops a daemon already running so the one started is this copy. |
| `service uninstall` | Stop the daemon and remove the registration. The configuration stays. | | `service uninstall` | Stop the daemon and remove the registration. The configuration stays. |
| `service start`, `service stop` | Start or stop the registered daemon. | | `service start`, `service stop` | Start or stop the registered daemon. |
| `service status` | Whether it is registered, running, and whether the registration points at a binary that has since moved, and the running daemon's version and how long it has been up. | | `service status` | Whether it is registered, running, and whether the registration points at a binary that has since moved, and the running daemon's version and how long it has been up. It says when the daemon is another build than the program asked, as after an update the daemon has not been restarted for; `--json` gives that as `same_build`. |
See [Installation](installation.md) for what registering does on each platform. See [Installation](installation.md) for what registering does on each platform.

View file

@ -60,6 +60,24 @@ through a browser until the download's quarantine is removed with
`packaging/README.md` describes how the packages are built. `packaging/README.md` describes how the packages are built.
## Updating
Install the new version over the old one. The daemon goes on running the copy it was started
from until it is restarted, so the first time the window is opened afterwards it says **The
daemon is outdated** and offers **Update now**. That registers the copy you opened as the service
and restarts the daemon from it; connections drop for a few seconds and come back. Nothing is
restarted until you choose it, so an update installed during a show waits for you.
The same notice appears when Midi Harbor is installed a second way, as an AppImage and then a
package of another release: Update now makes the service run the copy you opened. Two packages of
one release are the same build, and neither replaces the other's daemon.
Without the window, `midi-harbor service status` says when the daemon is another build, and
`midi-harbor service install --start` replaces it with the program that was asked.
The Mac App Store build updates its daemon with the app: quitting Midi Harbor stops the daemon,
and the updated app starts its own.
## Building from source ## Building from source
### Requirements ### Requirements

View file

@ -18,6 +18,10 @@ set -eu
cd "$(dirname "$0")/../.." cd "$(dirname "$0")/../.."
version=$(cat VERSION) version=$(cat VERSION)
build=${CARGO_TARGET_DIR:-target} build=${CARGO_TARGET_DIR:-target}
# One identifier for every binary in the bundle. The app and its helper are built separately,
# and each architecture too, and the app replaces a daemon whose identifier is not its own.
MIDI_HARBOR_BUILD_ID=${MIDI_HARBOR_BUILD_ID:-$(uuidgen | tr '[:upper:]' '[:lower:]')}
export MIDI_HARBOR_BUILD_ID
universal= universal=
if rustup target list --installed | grep -qx x86_64-apple-darwin \ if rustup target list --installed | grep -qx x86_64-apple-darwin \
&& rustup target list --installed | grep -qx aarch64-apple-darwin; then && rustup target list --installed | grep -qx aarch64-apple-darwin; then

View file

@ -195,6 +195,9 @@ message ServerInfo {
google.protobuf.Timestamp started_at = 4; google.protobuf.Timestamp started_at = 4;
string config_path = 5; string config_path = 5;
string socket_path = 6; string socket_path = 6;
// Identifies the daemon's build apart from every other build, the same version included.
// Empty from a daemon older than protocol 1.3.
string build_id = 7;
} }
// Why a connection is not usable. The slug is the stable value clients switch on. // Why a connection is not usable. The slug is the stable value clients switch on.

View file

@ -0,0 +1,72 @@
# Research: Daemon Updates
The investigation behind this spec, under its number in the project-wide research log.
---
## R-108: A daemon left running by another build
**Status**: **DONE** (2026-10-02). Built as T253.
**What happened before.** A client called `GetServerInfo` and refused only another major
protocol version. Nothing compared builds, so after an update the old daemon ran on:
| Install | Updating while the daemon runs |
|---|---|
| macOS disk image, launchd | The old daemon kept running until the next login |
| Mac App Store | Quitting the app stops its daemon, so the updated app started the new one; a daemon left by a window that crashed was attached to and kept |
| `.deb`, `.rpm` | Installing starts nothing, so the old daemon kept running |
| AppImage | A new file moved over the old one was used at the daemon's next start |
| Windows archive | No installer; the old daemon kept running |
`service install --start` did not help under systemd or Task Scheduler either: it rewrote the
registration and started the service, and starting a service that is running does nothing.
**The identifier.** `midi_harbor_core::BUILD_ID`, a UUID set by `crates/core/build.rs`. It takes
`MIDI_HARBOR_BUILD_ID` from the environment when that is set, and makes one otherwise. A package
can hold binaries built separately that must agree: the App Store app and its headless helper,
and each architecture of a universal binary. So `packaging/macos/build.sh` and the release build
each choose one identifier for everything they build in a run. A build outside packaging gets a
new identifier when the core crate or `VERSION` changes, which is as often as cargo runs the
script again without forcing every build to relink. The daemon reports it as
`ServerInfo.build_id` (7), protocol 1.3; an older daemon leaves it empty, which matches nothing.
**One release, several packages.** The `.deb`, `.rpm` and AppImage of a release hold the same
binary and so the same identifier. A user who installs two of them has one build twice, and no
daemon to update, so neither window replaces the other's daemon. Comparing the registered path as
well was considered and left out: it would restart the daemon every time the other copy was
opened, to run the same code.
**Why reinstall rather than restart.** Restarting runs whatever the service is registered to
run, which after a second install is the other copy. Registering this copy and then starting it
is what makes the daemon the one the window came with, wherever it is.
**Asking instead of acting.** The first version replaced the daemon as soon as the window
connected. That needed guards against a loop: two windows of different builds each replacing
the other's daemon when they reconnected, and a replacement that failed being tried again on
every reconnect. The owner asked for a notice with an Update now button instead. Nothing is then
restarted unless someone asks, which removes the loop rather than guarding it, and leaves the
few seconds without MIDI to be timed by the person at the machine.
**What the notice says.** "The daemon is outdated" for an older version or one too old to say,
"a different build" for the same version, and "newer than this window" for a newer one, where the
button reads Use this version: an older program in a newer one's place may not read the
configuration it wrote, which is refused with `SchemaTooNew`. The button is offered only when the
service manager says it is running the daemon and the window is on the service's own socket.
Otherwise there is no registration that says how the daemon was started, and the notice says to
restart it from this copy.
**While the daemon is being replaced** the old one still answers for a moment. The window does
not reconnect until the replacement has finished, or it would put the notice straight back.
**Replacing under each service manager.** `ServiceManager::replace` stops the daemon, installs
the registration and starts it. Stopping comes first so the service manager stops the process it
started. launchd is the exception: installing boots the old job out, which stops its daemon, and
stopping it beforehand only has launchd start the old one again in between.
**The App Store build** has no service. The app starts its helper, or attaches to one already
answering. It now asks an attached daemon for its build and, when it is another, asks it to stop
and starts its own.
**Checked live** on Arch Linux under systemd, with two builds of the tree given the identifiers
`aaaaaaaa-…` and `bbbbbbbb-…`. See T253 for what was seen.

View file

@ -0,0 +1,121 @@
# Feature Specification: Daemon Updates
**Created**: 2026-10-02
**Status**: Implemented; the window's part checked on Arch Linux under systemd on 2026-10-02.
Not yet run under launchd or Task Scheduler, or in the App Store build
**Input**: User question, "If I were to update the app while the daemon is running, would the GUI
auto restart the daemon so it can be updated as well?", and then: "When we build a GUI we also
build the daemon used. Maybe make an UUID at build and embed it in the daemon and the GUI, have
the GUI check that UUID to see if its the same as what it knows. If its not, re-install the
daemon. I believe re-install is the best method if someone installed the app image, then
downloaded the RPM (for an example) to have it remove the app image daemon and install its own.
Things like that should be considered so we don't go into a daemon update loop."
Then, on restarting by itself: "Maybe instead of auto restarting we could popup saying `Daemon is
outdated` and have an update now button?"
The window and the daemon are one program, installed together, but the daemon keeps running the
copy it was started from. Updating the app left the old daemon running until the next login, and
nothing said so: a client checked only that both sides spoke the same major protocol version
(research R-108).
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Being told the daemon is outdated, and updating it (Priority: P1)
A user updates Midi Harbor while its daemon is running, and opens the window. A notice above
every page says the daemon is outdated, with both versions, and offers **Update now**. Choosing
it registers this copy as the service and restarts the daemon from it; the ports and connections
come back as they do after any restart. **Not now** puts the notice away.
**Why this priority**: an update that does not reach the daemon is not an update, and nobody
would know.
**Independent Test**: Install the service from one build and open the window of another: the
notice shows, and after Update now the service is registered to run the window's copy and the
daemon reports the window's build.
**Acceptance Scenarios**:
1. **Given** the service running a daemon of another build, **When** the window connects,
**Then** it shows the notice and changes nothing.
2. **Given** the notice, **When** the user chooses Update now, **Then** the window registers its
own copy with the service manager, the daemon is restarted from it, and the window
reconnects with no notice.
3. **Given** Midi Harbor installed a second way, as an AppImage and then a package of another
release, **When** the package's window opens and the user chooses Update now, **Then** the
service runs the package's copy from then on.
4. **Given** a daemon older than protocol 1.3, which reports no build, **When** a window
connects, **Then** it is treated as outdated.
5. **Given** the App Store build, **When** the app starts and finds a daemon another build of
the app left running, **Then** it stops that daemon and starts its own.
### User Story 2 - Nothing restarts by itself (Priority: P1)
Two copies of Midi Harbor are open at once. Neither replaces the other's daemon unless its user
asks, so the daemon is never restarted in a loop, and never while nobody is deciding.
**Why this priority**: every replacement drops each connection for a few seconds. Two windows
replacing each other's daemon by themselves would do that for as long as both were open.
**Independent Test**: With the service running one build, open the window of a second build and
leave it: the daemon keeps running, under the notice.
**Acceptance Scenarios**:
1. **Given** a daemon of another build, **When** nobody chooses Update now, **Then** the daemon
is never restarted.
2. **Given** a daemon of a newer version than the window, **When** the window connects, **Then**
the notice says it is newer and offers **Use this version** in place of Update now.
3. **Given** a daemon the service did not start, or a window pointed at one with `--socket`,
**When** the window connects, **Then** the notice says how to use this version and offers no
button.
### Edge Cases
- **The same release installed twice**: both copies carry one build identifier, so neither sees
the other's daemon as different. If the registered copy is then deleted, the daemon stops at
the next login and the window offers to register this copy, as it did before.
- **No window**: a machine with only the command line is told by `service status` that the daemon
is another build, and `service install --start` replaces it.
- **Updating fails**: the window says why and reconnects to whatever is running, with the
notice again if that is still another build.
- **The App Store build**: its app replaces a daemon left by another build as it starts, without
asking. There the app runs the daemon and stops it on Quit, so one of another build is only
ever left by a window that crashed, and starting the app is the user asking for it.
## Requirements *(mandatory)*
### Functional Requirements
- **FR-B01**: Every build MUST carry an identifier that is the same for everything in one
package and differs from every other build, and the daemon MUST report it over the contract.
- **FR-B02**: The window MUST say when the daemon it reached is another build than itself, with
both versions, and MUST offer to update it when the service is running that daemon.
- **FR-B03**: The window MUST NOT restart or replace the daemon unless the user asks.
- **FR-B04**: When the user asks, the window MUST register its own copy with the service manager
and restart the daemon from it.
- **FR-B05**: `service install --start` MUST stop a daemon already running, so the daemon started
is the program that was asked.
- **FR-B06**: `service status` MUST say when the running daemon is another build than the
program asked.
- **FR-B07**: The App Store app MUST stop a daemon another build of the app left running and
start its own.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-B01**: After an update, the first window opened says the daemon is outdated, and one
click has the updated daemon running within ten seconds.
- **SC-B02**: However many copies of Midi Harbor are open, the daemon is never restarted without
a user asking.
## Assumptions
- A user who installs Midi Harbor a second way and chooses Update now means to use the copy they
opened.
- A notice above the page serves as the popup that was asked for: it shows over every page until
answered, and does not take over a dialog the user has open.

View file

@ -0,0 +1,6 @@
# Tasks: Daemon Updates
Tasks by their numbers in the project-wide sequence, which continues across every spec. Built
and checked in one piece, so not broken into phases.
- [x] T253 Say when the daemon is another build than the window, and update it when the user asks, per FR-B01 to FR-B07, SC-B01, SC-B02 (R-108) — done: `midi_harbor_core::BUILD_ID` from `crates/core/build.rs`, one per package run through `MIDI_HARBOR_BUILD_ID` in `packaging/macos/build.sh`, the Makefile and `.goreleaser.yaml`; `ServerInfo.build_id` (7), protocol 1.3; the window shows a notice above every page with Update now and Not now and restarts nothing by itself; `ServiceManager::replace` stops, registers and starts, and `service install --start` uses it; `service status` says when the daemon is another build, `same_build` in `--json`; the App Store app stops a daemon another build left and starts its own. Tests: what the notice says and when it offers the button, as a table; the daemon reporting its build over a real socket; and the service lifecycle test now installs with `--start` over a running daemon and checks the process changed, with the stand-in service managers ignoring a start when running as the real ones do. Checked live on Arch Linux under systemd with two builds given different identifiers: build B's `service status` said the daemon was another build; its window showed "The daemon is a different build" with Update now; clicking it rewrote `ExecStart` to build B's path and the journal showed the daemon stopped and started 0.5 s apart, after which `same_build` was true and the notice was gone; build A's window opened beside it showed the notice and the daemon kept its start time. Not run: launchd, Task Scheduler and the App Store app's replacement, and the release build with the new environment variable. The view shown while the daemon restarts was not seen, the restart being over before the first screenshot.

View file

@ -29,6 +29,7 @@ exception is 014, whose task list started again at T001: its T001 to T034 are ci
| [016-app-store-submission](016-app-store-submission/spec.md) | Building the package App Store Connect takes | | [016-app-store-submission](016-app-store-submission/spec.md) | Building the package App Store Connect takes |
| [017-appimage](017-appimage/spec.md) | The AppImage for Linux, and registering the daemon from it | | [017-appimage](017-appimage/spec.md) | The AppImage for Linux, and registering the daemon from it |
| [018-remembered-machines](018-remembered-machines/spec.md) | Machines a network port connected to: forgetting old connections, following a session that moved, proving which port a session is; answering Apple over a link-local address | | [018-remembered-machines](018-remembered-machines/spec.md) | Machines a network port connected to: forgetting old connections, following a session that moved, proving which port a session is; answering Apple over a link-local address |
| [019-daemon-updates](019-daemon-updates/spec.md) | Telling that the daemon is another build than the window, and updating it when asked |
## Where each number is ## Where each number is
@ -152,3 +153,10 @@ exception is 014, whose task list started again at T001: its T001 to T034 are ci
- **Success criteria**: SC-P01, SC-P02 - **Success criteria**: SC-P01, SC-P02
- **Research**: R-105–R-107 - **Research**: R-105–R-107
- **Tasks**: T249–T252 - **Tasks**: T249–T252
### 019-daemon-updates
- **Requirements**: FR-B01, FR-B02, FR-B03, FR-B04, FR-B05, FR-B06, FR-B07
- **Success criteria**: SC-B01, SC-B02
- **Research**: R-108
- **Tasks**: T253

View file

@ -3,7 +3,8 @@
//! The binary runs with `PATH` holding nothing but a stand-in for `launchctl` or `systemctl`, so //! The binary runs with `PATH` holding nothing but a stand-in for `launchctl` or `systemctl`, so
//! the real one cannot be reached and nothing is registered with the machine. The stand-in keeps //! the real one cannot be reached and nothing is registered with the machine. The stand-in keeps
//! its state in files and starts the daemon itself, as the real one would, so `service start` //! its state in files and starts the daemon itself, as the real one would, so `service start`
//! has a daemon to wait for. Everything else a user would have is the real code: the definition //! has a daemon to wait for. Starting a service that is running does nothing, as with the real
//! ones. Everything else a user would have is the real code: the definition
//! written under a scratch home, the status read back from it, and the configuration beside it. //! written under a scratch home, the status read back from it, and the configuration beside it.
#![cfg(any(target_os = "macos", target_os = "linux"))] #![cfg(any(target_os = "macos", target_os = "linux"))]
@ -36,6 +37,7 @@ case "$1" in
bootout) [ -f "$FAKE_STATE/loaded" ] || { echo "Boot-out failed: 3: No such process" >&2; exit 3; } bootout) [ -f "$FAKE_STATE/loaded" ] || { echo "Boot-out failed: 3: No such process" >&2; exit 3; }
halt; /bin/rm -f "$FAKE_STATE/loaded" ;; halt; /bin/rm -f "$FAKE_STATE/loaded" ;;
kickstart) [ -f "$FAKE_STATE/loaded" ] || exit 113 kickstart) [ -f "$FAKE_STATE/loaded" ] || exit 113
running && exit 0
"$FAKE_DAEMON" daemon > "$FAKE_STATE/daemon.out" 2>&1 & "$FAKE_DAEMON" daemon > "$FAKE_STATE/daemon.out" 2>&1 &
echo $! > "$FAKE_STATE/pid" ;; echo $! > "$FAKE_STATE/pid" ;;
kill) halt ;; kill) halt ;;
@ -67,7 +69,8 @@ case "$1" in
daemon-reload) ;; daemon-reload) ;;
enable) : > "$FAKE_STATE/enabled" ;; enable) : > "$FAKE_STATE/enabled" ;;
disable) halt; /bin/rm -f "$FAKE_STATE/enabled" ;; disable) halt; /bin/rm -f "$FAKE_STATE/enabled" ;;
start) "$FAKE_DAEMON" daemon > "$FAKE_STATE/daemon.out" 2>&1 & start) running && exit 0
"$FAKE_DAEMON" daemon > "$FAKE_STATE/daemon.out" 2>&1 &
echo $! > "$FAKE_STATE/pid" ;; echo $! > "$FAKE_STATE/pid" ;;
stop) halt ;; stop) halt ;;
is-active) if running; then echo active; else echo inactive; exit 3; fi ;; is-active) if running; then echo active; else echo inactive; exit 3; fi ;;
@ -140,6 +143,14 @@ impl Machine {
) )
} }
/// Returns the process the stand-in is running as the daemon.
fn daemon_pid(&self) -> String {
std::fs::read_to_string(self.root.join("state/pid"))
.expect("the stand-in recorded the daemon it started")
.trim()
.to_owned()
}
fn status(&self) -> serde_json::Value { fn status(&self) -> serde_json::Value {
let (code, out) = self.run(&["--json", "service", "status"]); let (code, out) = self.run(&["--json", "service", "status"]);
assert_eq!(code, 0, "service status must succeed: {out}"); assert_eq!(code, 0, "service status must succeed: {out}");
@ -157,11 +168,14 @@ impl Drop for Machine {
} }
/// Locks the service lifecycle a user drives: install registers the daemon stopped, a second /// Locks the service lifecycle a user drives: install registers the daemon stopped, a second
/// install updates the one registration, start waits until the daemon answers, stop ends it, and /// install updates the one registration, start waits until the daemon answers, installing with
/// uninstall removes the registration and leaves the configuration byte for byte as it was. /// `--start` while it runs replaces the running daemon, stop ends it, and uninstall removes the
/// registration and leaves the configuration byte for byte as it was.
/// ///
/// Losing a user's ports and routes to an uninstall, or leaving two registrations to fight over /// Losing a user's ports and routes to an uninstall, or leaving two registrations to fight over
/// one socket, are the failures this guards. /// one socket, are the failures this guards. So is an update that registers the new program and
/// leaves the old daemon running: starting a service that is already running does nothing
/// (R-108).
#[test] #[test]
fn the_service_installs_starts_stops_and_uninstalls_in_place() { fn the_service_installs_starts_stops_and_uninstalls_in_place() {
let machine = Machine::new(); let machine = Machine::new();
@ -210,6 +224,23 @@ fn the_service_installs_starts_stops_and_uninstalls_in_place() {
true, true,
"the daemon must be running after start" "the daemon must be running after start"
); );
// Installing with --start while it runs replaces the daemon with the program that asked.
let old = machine.daemon_pid();
let (code, out) = machine.run(&["service", "install", "--start"]);
assert_eq!(code, 0, "install --start over a running daemon: {out}");
let status = machine.status();
assert_eq!(
(status["running"].clone(), status["same_build"].clone()),
(true.into(), true.into()),
"the daemon must be running, and the build that installed it: {status}"
);
assert_ne!(
machine.daemon_pid(),
old,
"the daemon running before the install was left running"
);
let (code, out) = machine.run(&["service", "stop"]); let (code, out) = machine.run(&["service", "stop"]);
assert_eq!(code, 0, "stop must succeed: {out}"); assert_eq!(code, 0, "stop must succeed: {out}");
assert_eq!( assert_eq!(