Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions apps/desktop/loopx-control-plane/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,19 @@ with `loopx update`.

## Updates And Recovery

From a terminal, `loopx upgrade` coordinates the owning CLI installer and this
App's signed updater, restarts the App and waits for native service readiness.
Archive users can select Main with `loopx upgrade --ref main`; `--dry-run` is
read-only. The terminal passes only a bounded request id and an official channel
to the App. A private request receipt contains versions, source revisions,
update phase and the qualified service runtime identity; it contains no Goal
content or credentials. An update check failure remains a failed terminal
upgrade even when the workspace can still open. Normal launches without that
request retain their existing three-second optional startup check; an explicit
terminal upgrade uses the existing thirty-second interactive check budget.
The existing update panel shows the same native maintenance phases and recovery
actions. Main is the latest complete signed build, which can lag Git HEAD.

Startup automatically uses the newest qualified local runtime it can establish.
It compares package versions; equal versions with source revisions use ancestry
from the fixed official GitHub repository. Installation time and lexical SHA
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/loopx-control-plane/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ mod runtime_selection;
mod service_endpoints;
mod services;
mod update_backup;
mod upgrade_request;
mod workspace_handoff;
mod workspace_location;

Expand Down
45 changes: 40 additions & 5 deletions apps/desktop/loopx-control-plane/src-tauri/src/maintenance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ pub struct Maintenance {
automatic_update_checked: AtomicBool,
incomplete_app_installation: AtomicBool,
manual_failure_pending: AtomicBool,
upgrade_request: std::sync::OnceLock<Option<crate::upgrade_request::UpgradeRequest>>,
}

#[derive(Default)]
Expand Down Expand Up @@ -143,6 +144,10 @@ impl Maintenance {
}
}
*snapshot = value.clone();
drop(snapshot);
if let Some(Some(request)) = self.upgrade_request.get() {
request.publish(phase, &value["details"]);
}
value
}

Expand Down Expand Up @@ -842,7 +847,10 @@ fn automatic_app_update(app: &AppHandle) {
{
return;
}
let channel = if app.package_info().version.pre.as_str().starts_with("main.") {
let request = state.upgrade_request.get().and_then(Option::as_ref);
let channel = if let Some(request) = request {
request.channel.as_str()
} else if app.package_info().version.pre.as_str().starts_with("main.") {
"main"
} else {
"stable"
Expand All @@ -853,8 +861,14 @@ fn automatic_app_update(app: &AppHandle) {
async {
let url = endpoint(channel)?;
// Keep the optional feed lookup on the startup path bounded.
let checked =
perform(app, "check", channel, url.clone(), Duration::from_secs(3)).await?;
let checked = perform(
app,
"check",
channel,
url.clone(),
Duration::from_secs(if request.is_some() { 30 } else { 3 }),
)
.await?;
Ok((checked["phase"] == "available").then_some(url))
},
|url| async move {
Expand All @@ -874,11 +888,26 @@ pub fn start_services(
app: &AppHandle,
endpoints: &crate::service_endpoints::ServiceEndpoints,
) -> Result<Option<crate::services::ServiceSet>, String> {
app.state::<Maintenance>().upgrade_request.get_or_init(|| {
let args = std::env::args().collect::<Vec<_>>();
if !args
.iter()
.any(|arg| arg.starts_with("--loopx-upgrade-request="))
{
return None;
}
crate::upgrade_request::UpgradeRequest::from_args(
&args,
app.path().app_local_data_dir().ok()?,
app.package_info().version.to_string(),
bundled_runtime::identity(app).ok()?["source_revision"].clone(),
)
});
app.state::<Maintenance>()
.startup_started
.get_or_init(Instant::now);
automatic_app_update(app);
app.state::<Maintenance>().reconcile_services(|| {
let result = app.state::<Maintenance>().reconcile_services(|| {
let runtime = match resume_runtime(app) {
Ok(runtime) => runtime,
Err(error) => {
Expand All @@ -900,7 +929,13 @@ pub fn start_services(
.publish("connecting", json!({"service":service}));
})
.map_err(|e| e.to_string())
})
});
if let Ok(Some(services)) = &result {
if let Some(Some(request)) = app.state::<Maintenance>().upgrade_request.get() {
request.services_ready(services.runtime_identity());
}
}
result
}

pub fn reconnect_requested(app: &AppHandle) -> bool {
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/loopx-control-plane/src-tauri/src/services.rs
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,10 @@ pub(crate) struct SelectedRuntime {
}

impl ServiceSet {
pub(crate) fn runtime_identity(&self) -> Option<&serde_json::Value> {
self.runtime.identity.as_ref()
}

pub(crate) fn start(
runtime: &SelectedRuntime,
endpoints: &ServiceEndpoints,
Expand Down
64 changes: 23 additions & 41 deletions apps/desktop/loopx-control-plane/src-tauri/src/services_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -133,20 +133,26 @@ fn finder_runtime_path_includes_tools_without_loading_shell_profiles() {
fn spawn_listener_fixture(
executable: &Path,
command: &str,
port: u16,
payload: &str,
) -> std::process::Child {
) -> (std::process::Child, u16) {
use std::os::fd::OwnedFd;
use std::os::unix::fs::PermissionsExt;

// Keep the OS reservation through exec; releasing a port number before
// Python binds it lets unrelated concurrent tests steal the fixture port.
let listener =
std::net::TcpListener::bind(("127.0.0.1", 0)).expect("reserve loopback listener");
let port = listener
.local_addr()
.expect("reserved loopback address")
.port();
let source = format!(
r#"#!/usr/bin/env python3
import socket
import time

payload = {payload:?}.encode("utf-8")
server = socket.socket()
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind(("127.0.0.1", {port}))
server = socket.socket(fileno=0)
server.listen(8)
while True:
connection, _ = server.accept()
Expand All @@ -171,22 +177,14 @@ while True:
.permissions();
permissions.set_mode(0o755);
fs::set_permissions(executable, permissions).expect("make listener fixture executable");
Command::new(executable)
let child = Command::new(executable)
.args([command, "--host", "127.0.0.1", "--port", &port.to_string()])
.stdin(Stdio::null())
.stdin(Stdio::from(OwnedFd::from(listener)))
.stdout(Stdio::null())
.stderr(Stdio::null())
.stderr(Stdio::inherit())
.spawn()
.expect("spawn listener fixture")
}

#[cfg(not(windows))]
fn reserve_loopback_port() -> u16 {
std::net::TcpListener::bind(("127.0.0.1", 0))
.expect("reserve loopback port")
.local_addr()
.expect("reserved loopback address")
.port()
.expect("spawn listener fixture");
(child, port)
}

#[cfg(not(windows))]
Expand Down Expand Up @@ -351,19 +349,14 @@ fn service_supervisor_reuses_matching_replaces_stale_and_rejects_foreign() {
"source_revision": "current-revision",
});

let matching_port = reserve_loopback_port();
let matching_executable = fixture_root.join("matching-loopx");
let matching_payload = serde_json::json!({
"source": "serve-status",
"runtime_identity": current_identity,
})
.to_string();
let mut matching = spawn_listener_fixture(
&matching_executable,
"serve-status",
matching_port,
&matching_payload,
);
let (mut matching, matching_port) =
spawn_listener_fixture(&matching_executable, "serve-status", &matching_payload);
wait_for_probe(
ServiceKind::Status,
matching_port,
Expand All @@ -377,7 +370,6 @@ fn service_supervisor_reuses_matching_replaces_stale_and_rejects_foreign() {
matching.kill().expect("stop matching fixture");
matching.wait().expect("reap matching fixture");

let stale_port = reserve_loopback_port();
let stale_executable = fixture_root.join("loopx");
let stale_payload = serde_json::json!({
"source": "serve-status",
Expand All @@ -389,12 +381,8 @@ fn service_supervisor_reuses_matching_replaces_stale_and_rejects_foreign() {
},
})
.to_string();
let mut stale = spawn_listener_fixture(
&stale_executable,
"serve-status",
stale_port,
&stale_payload,
);
let (mut stale, stale_port) =
spawn_listener_fixture(&stale_executable, "serve-status", &stale_payload);
wait_for_probe(
ServiceKind::Status,
stale_port,
Expand Down Expand Up @@ -428,14 +416,9 @@ fn service_supervisor_reuses_matching_replaces_stale_and_rejects_foreign() {
.expect("stale fixture final status")
.is_some());

let foreign_port = reserve_loopback_port();
let foreign_executable = fixture_root.join("foreign-server");
let mut foreign = spawn_listener_fixture(
&foreign_executable,
"serve-status",
foreign_port,
r#"{"source":"other"}"#,
);
let (mut foreign, foreign_port) =
spawn_listener_fixture(&foreign_executable, "serve-status", r#"{"source":"other"}"#);
wait_for_probe(
ServiceKind::Status,
foreign_port,
Expand All @@ -459,9 +442,8 @@ fn service_supervisor_reuses_matching_replaces_stale_and_rejects_foreign() {
// A silent listener must be distinct from a foreign HTTP response. Only
// the expected LoopX process may be terminated after the startup grace.
for confirmed in [true, false] {
let port = reserve_loopback_port();
let executable = fixture_root.join(if confirmed { "loopx" } else { "silent-foreign" });
let mut silent = spawn_listener_fixture(&executable, "chat", port, "");
let (mut silent, port) = spawn_listener_fixture(&executable, "chat", "");
wait_for_probe(
ServiceKind::Chat,
port,
Expand Down
Loading
Loading