From 0a26e778992d4f5d9d7fd15db8ca5c3d2d9287e6 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Mon, 15 Jun 2026 13:58:04 +0700 Subject: [PATCH] fix(app): drop blocking version-handshake; Shutdown is fire-and-forget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The handshake ran synchronously in Bridge::connect: on a build-id mismatch it sent Cmd::Shutdown and awaited a reply that never flushes (the daemon exits first), so request() hit its 5s timeout and the reconnect-retry respawned the daemon and re-sent Shutdown — a loop that produced repeated 'spaceshd listening' lines and a multi-second launch delay. The id stamps also differed between the separately-built daemon and GUI, so it fired on normal launches. - Remove the handshake auto-restart; `make install`/`reinstall` already kill and replace the daemon reliably. health.build stays for display in Settings. - Shutdown now goes through a fire-and-forget send (no reply wait, no retry), fixing the same loop for the Settings Restart button. - Makefile: `make app-bundle` builds just the .app via `tauri build --bundles app` (no .dmg, no hdiutil) and `reinstall` uses it — faster self-update that can't hang on a mounted DMG volume. Co-Authored-By: Claude Opus 4.8 (1M context) --- Makefile | 19 +++++++++++++---- app/src-tauri/src/bridge.rs | 41 ++++++++++++------------------------- 2 files changed, 28 insertions(+), 32 deletions(-) diff --git a/Makefile b/Makefile index 605d6be..ed3599d 100644 --- a/Makefile +++ b/Makefile @@ -44,14 +44,18 @@ targets: ## add rust targets for the universal build .PHONY: dmg dmg: targets ## build the universal (Intel + Apple Silicon) .dmg — UNSIGNED - # Tauri's universal build compiles each arch separately and expects a sidecar - # named with THAT arch's triple; it lipo's them into the universal bundle and - # ships spaceshd inside spacesh.app/Contents/MacOS (else the GUI is offline). + # Tauri's universal build needs BOTH the per-arch sidecars (resolved during each + # arch sub-build) AND a fat spaceshd-universal-apple-darwin (copied into the final + # bundle — Tauri does not lipo sidecars itself). spaceshd ships inside + # spacesh.app/Contents/MacOS else the GUI is offline. cargo build --release -p spaceshd --target aarch64-apple-darwin cargo build --release -p spaceshd --target x86_64-apple-darwin rm -rf $(SIDECAR_DIR) && mkdir -p $(SIDECAR_DIR) # avoid stale sidecars poisoning the bundle cp target/aarch64-apple-darwin/release/spaceshd $(SIDECAR_DIR)/spaceshd-aarch64-apple-darwin cp target/x86_64-apple-darwin/release/spaceshd $(SIDECAR_DIR)/spaceshd-x86_64-apple-darwin + lipo -create -output $(SIDECAR_DIR)/spaceshd-universal-apple-darwin \ + target/aarch64-apple-darwin/release/spaceshd \ + target/x86_64-apple-darwin/release/spaceshd cd $(APP_DIR) && npm run tauri build -- --target $(TAURI_TARGET) --config $(BUNDLE_CONFIG) @echo "→ $(DMG_DIR)" && ls -lh $(DMG_DIR)/*.dmg @@ -63,6 +67,13 @@ dmg-native: ## build a .dmg for the current arch only (faster) cd $(APP_DIR) && npm run tauri build -- --config $(BUNDLE_CONFIG) @ls -lh $(NATIVE_DMG_DIR)/*.dmg +.PHONY: app-bundle +app-bundle: ## build just the native .app (no .dmg/hdiutil — fast, for self-install) + cargo build --release -p spaceshd + rm -rf $(SIDECAR_DIR) && mkdir -p $(SIDECAR_DIR) + cp target/release/spaceshd $(SIDECAR_DIR)/spaceshd-$(NATIVE_TRIPLE) + cd $(APP_DIR) && npm run tauri build -- --bundles app --config $(BUNDLE_CONFIG) + .PHONY: dev dev: ## run the app in dev mode (tauri dev) cd $(APP_DIR) && npm run tauri dev @@ -90,7 +101,7 @@ install-universal: kill-daemon ## install the universal .app to /Applications xattr -dr com.apple.quarantine /Applications/spacesh.app .PHONY: reinstall -reinstall: dmg-native install ## native rebuild + reinstall + restart daemon (fast self-update) +reinstall: app-bundle install ## fast self-update: build .app (no dmg), reinstall, restart daemon # ---- Tests ---- diff --git a/app/src-tauri/src/bridge.rs b/app/src-tauri/src/bridge.rs index d16e315..2257a64 100644 --- a/app/src-tauri/src/bridge.rs +++ b/app/src-tauri/src/bridge.rs @@ -120,7 +120,7 @@ impl Bridge { let pending: Arc>>> = Arc::default(); let out_channels: Arc>>>> = Arc::default(); let (tx, reader) = spawn_connection(&sock, &app, pending.clone(), out_channels.clone()).await?; - let bridge = Self { + Ok(Self { next_id: AtomicU64::new(1), app, sock, @@ -130,34 +130,16 @@ impl Bridge { reader: Mutex::new(reader), pending, out_channels, - }; - bridge.ensure_matching_daemon().await; - Ok(bridge) + }) } - /// If a previously-running daemon is a different build than the one bundled - /// with this GUI (the daemon outlives the GUI, so an old one can still be - /// serving the socket), restart it so our matching daemon takes over. No-op - /// for unstamped dev builds or when the daemon is too old to report a build. - async fn ensure_matching_daemon(&self) { - let want = option_env!("SPACESH_BUILD").unwrap_or("dev"); - if want == "dev" { - return; - } - let got = match self.request(Cmd::Health).await { - Ok(env) => data_of(env) - .ok() - .and_then(|v| v.get("build").and_then(|b| b.as_str()).map(str::to_string)) - .unwrap_or_default(), - Err(_) => return, - }; - if got.is_empty() || got == want { - return; - } - // Stale daemon — stop it and respawn our bundled one (matching code). - let seen = self.gen.load(Ordering::Acquire); - let _ = self.request(Cmd::Shutdown).await; // daemon exits; reply may not arrive - let _ = self.reconnect(seen).await; // ensure_daemon respawns the bundled daemon + /// Send a command without awaiting a reply or retrying. Used for Shutdown: + /// the daemon exits before its reply is flushed, so a normal request() would + /// time out and the reconnect-retry would respawn-and-reshutdown in a loop. + async fn fire(&self, cmd: Cmd) { + let id = self.next_id.fetch_add(1, Ordering::Relaxed); + let tx = self.tx.lock().await.clone(); + let _ = tx.send(Envelope::Req { id, cmd }).await; } /// Re-establish the daemon connection. Single-flight: callers pass the `gen` @@ -461,5 +443,8 @@ pub async fn set_config( #[tauri::command] pub async fn shutdown_daemon(state: BridgeState<'_>) -> Result { - data_of(state.request(Cmd::Shutdown).await.map_err(|e| e.to_string())?) + // Fire-and-forget: the daemon exits without a flushed reply, so awaiting one + // would time out and trigger a respawn-then-reshutdown loop. + state.fire(Cmd::Shutdown).await; + Ok(Value::Null) }