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
128 changes: 128 additions & 0 deletions docs/daemon-apply-verification.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
# Daemon-owned Apply verification, 2026-09-16

Applying from the client could stop the daemon permanently and disable its
autostart entry. Hide Window Controls then reported that the service was
unavailable. Restarting the daemon recovered that module without reopening
Spotify.

The RPC handler ran synchronous commands on the daemon's single async thread.
Apply's request to its own health endpoint timed out, so Apply treated the
daemon's version as unknown, unregistered it, and killed its own process.
Daemon-owned Apply could also register `spicetify-daemon` as the URL handler,
although that executable does not implement CLI protocol commands.

RPC commands now execute on blocking workers. Explicit Apply modes keep daemon
maintenance and URL registration in the foreground CLI. RPC Apply, watcher
repairs, and the update transaction preserve their owning daemon. Blocking
workers are no longer limited to one, so a watcher waiting for Spotify to exit
does not prevent RPC commands from reaching the operation guard.

The watcher could also keep waiting after another Apply had consumed the stock
archive. On Windows, each process check launched `tasklist.exe` with a visible
console. Process sampling confirmed daemon-owned `tasklist.exe` starts about
2.2 seconds apart, each followed by an `OpenConsole.exe` start. The watcher now
stops waiting when repair is no longer pending. Windows process and PowerShell
helpers use `CREATE_NO_WINDOW` so legitimate background checks stay hidden too.

## Automated checks

- A regression holds the Apply file lock while dispatching a real Apply RPC.
The old handler stalled the async runtime for five seconds and failed. The
fixed handler kept it responsive and passed in 0.06 seconds. The fixture
refuses a foreign apply before any real Spotify operation.
- `cargo +1.95.0 test --workspace --locked --features daemon/native-window-controls-tests`:
144 passed, three existing tests requiring real bundles or registry downloads ignored.
- A watcher regression consumes the archive while Spotify remains running and
verifies that polling stops and the operation lock remains available.
- A Windows child-process test verifies that a background PowerShell helper has
no console while preserving its output and nonzero exit status.
- `cargo +1.95.0 clippy --workspace --locked -- -D warnings`: passed.
A pre-existing TUI Backspace match required a behavior-preserving lint fix.
- An additional `--all-targets` Clippy scan found existing test-only warnings
outside this regression. That broader scan is not the repository CI command
and is not reported as passing.
- Matching release CLI/daemon binaries were built with the current payload.

## Windows live run

The patched pair was installed in the normal local installation directory,
with backups retained. Both still identify as 3.0.0-beta.17; this is a local
build, not a published release. The existing daemon was explicitly restarted
before testing because its version alone cannot distinguish local builds.

An authenticated `spicetify:0:apply` request completed against Spotify desktop
1.3.0.277. This used the same RPC as the client but was sent by a diagnostic
script, **not clicked in the UI**.

- All 109 concurrent health requests succeeded; maximum latency was 17 ms.
- Daemon PID 28012 survived the operation, with monotonically increasing uptime.
- Apply finished and automatically launched Spotify, which exposed a window
titled `Spotify Free`.
- Autostart stayed enabled and the URL handler still targeted `spicetify.exe`.
- No daemon restart or registration mutation appeared in the Apply log.
- Spotify updates remained blocked.

Evidence is retained locally under the workspace's
`scratchpad/daemon-owned-apply/`. It is not a release fixture.

### Follow-up after the console fix

The matching CLI and daemon were rebuilt and installed, then the same diagnostic
RPC Apply was repeated. All 84 health requests succeeded with a maximum latency
of 28 ms. Daemon PID 28800 survived; autostart, CLI URL registration, and update
protection remained intact.

A 45-second process sample spanning Apply and the period after it recorded the
expected daemon-owned helpers during Apply and no new `OpenConsole.exe` process.
There were no recurring `tasklist.exe` starts after completion. This run's
watcher saw the already-applied client and skipped repair; the cancellation of
an existing wait is covered by the regression test, not this live timing.
The latest RPC result, Apply log, and helper-process sample are retained in the
same local evidence directory. This is process-level verification, not a native
visual pass.

## End-user coverage and remaining limits

Before installing this fix, native Computer Use verified the profile-menu
settings route, Manager's six loaded modules, playback, elapsed-time rendering,
and recovery of hidden window controls after restarting the stopped daemon.

The native bridge subsequently became unavailable in the current host session.
Both a fresh connection and a session reset returned `native pipe unavailable`.
Consequently the fixed build's automatic launch was verified as a process and
window, not visually inspected. The Windows UI Apply action and the first
patched renderer after an actual Spotify version update still need a native
end-user pass. The earlier [Windows update report](windows-update-verification.md)
remains accurate; this run does not clear its first-boot limitation or enable
Windows Update & Apply in release builds.


## macOS visible Store Apply, 2026-09-17

A combined local build at `115d010` included this fix, GraphQL discovery and
protocol signing (#3951), and launchd reconciliation (#3952). Matching CLI and
daemon binaries were installed in the normal installation directory after
backing up both binaries and Spotify. This remains a local build reporting
3.0.0-beta.17, not a published release.

The fixture used the published, checksum-verified stdlib 1.11.3 artifact,
installed and applied through the CLI. From the visible Module Store, **Update
all** staged stdlib 1.12.0. **Apply stdlib update** opened the restart warning.
**Cancel** preserved the staged update. Reopening the confirmation and clicking
**Apply and restart** restarted Spotify 1.3.0.277. The Home view and Module Store
rendered with the existing theme, the Apply banner cleared, and the manifest
and installed module link both returned to stdlib 1.12.0.

Daemon PID 3060 survived the operation with increasing uptime and an unchanged
launch-agent plist. The concurrent health sample recorded no failures or uptime
resets. Both watchers remained active. The watcher observed the temporary stock
archive during Apply and cancelled its pending repair once that archive was
consumed. The normal macOS URL handoff, `open spicetify:0:apply`, subsequently
completed another Apply and restart with the same daemon PID. The registered
applet passed strict code-signature verification.

Fixture preparation and the URL handoff used CLI commands; the Store update,
cancellation, confirmation, restart, and returned views were exercised through
the native UI. This run does not test a Spotify version upgrade, Windows native
UI behavior, or browser confirmation prompts for custom URL schemes. Evidence
is retained under `scratchpad/final-reconciliation/` in the local workspace.
5 changes: 5 additions & 0 deletions docs/windows-update-verification.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,11 @@ These files are local diagnostic artifacts, not release fixtures.

## Remaining verification

The later [daemon Apply verification](daemon-apply-verification.md) diagnoses
and fixes a separate self-shutdown path seen during an ordinary in-client
Apply. Its authenticated RPC run passed, but it does not replace the missing
first-boot visual check below.

Repeat an actual version update with reliable first-boot observation and
without a diagnostic restart. Check loaded modules through the normal UI before
calling the job's user outcome complete. Microsoft Store installations, Linux,
Expand Down
58 changes: 56 additions & 2 deletions rust/crates/daemon/src/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -203,8 +203,7 @@ async fn handle_ws(mut socket: WebSocket, state: Arc<DaemonState>) {
while let Some(Ok(msg)) = socket.next().await {
if let Message::Text(text) = msg {
tracing::info!("{}", spicetify::fl!("rpc-received", msg = text.as_str()));
let ctx = state.ctx.load();
match protocol::handle(&ctx, &text) {
match dispatch_rpc(state.ctx.load_full(), text.to_string()).await {
Ok(res) if !res.is_empty() => {
if let Err(e) = socket.send(Message::Text(res.into())).await {
tracing::warn!(error = %e, "failed to send ws message");
Expand All @@ -226,6 +225,16 @@ async fn handle_ws(mut socket: WebSocket, state: Arc<DaemonState>) {
}
}

async fn dispatch_rpc(
ctx: Arc<spicetify::context::AppContext>,
text: String,
) -> anyhow::Result<String> {
tokio::task::spawn_blocking(move || {
protocol::handle(&ctx, &text, spicetify::commands::apply::ApplyMode::Daemon)
})
.await?
}

// A cross-origin POST is sent even when the browser refuses to let the page
// read the reply, so without a token any page the user visits could stop the
// daemon and silently disable auto re-apply.
Expand All @@ -246,6 +255,51 @@ async fn shutdown_handler(
mod tests {
use super::*;

#[tokio::test]
async fn apply_rpc_does_not_block_the_server_while_waiting_for_the_apply_lock()
-> anyhow::Result<()> {
use spicetify::context::{AppContext, Config};
use std::fs::OpenOptions;

let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?.as_nanos();
let root =
std::env::temp_dir().join(format!("spicetify-rpc-{}-{nonce}", std::process::id()));
// Foreign apply artifacts make the command fail before it can touch Spotify.
std::fs::create_dir_all(root.join("Apps/xpui"))?;
let ctx = Arc::new(AppContext::from_config(
root.clone(),
&Config {
spotify_exec: Some(root.join("Spotify")),
spotify_data_dir: Some(root.clone()),
offline_bnk_dir: Some(root.clone()),
..Config::default()
},
)?);
let lock = OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.open(root.join("spicetify-apply.lock"))?;
lock.lock()?;
let (release, held) = std::sync::mpsc::channel();
// A bounded external release keeps a regression from hanging the test runtime.
let holder = std::thread::spawn(move || {
let _ = held.recv_timeout(Duration::from_secs(5));
drop(lock);
});
let rpc = tokio::spawn(dispatch_rpc(ctx, "spicetify:0:apply".to_string()));
let started = std::time::Instant::now();
tokio::time::sleep(Duration::from_millis(50)).await;
let responsive = started.elapsed() < Duration::from_secs(2) && !rpc.is_finished();
let _ = release.send(());
let result = rpc.await?;
holder.join().expect("lock holder exits");
std::fs::remove_dir_all(root)?;
assert!(result.is_err(), "fixture must refuse a foreign apply");
assert!(responsive, "the server must keep polling while Apply waits on disk");
Ok(())
}

fn headers(protocols: &str) -> HeaderMap {
let mut h = HeaderMap::new();
let _ =
Expand Down
5 changes: 1 addition & 4 deletions rust/crates/daemon/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,7 @@ pub fn run() -> anyhow::Result<()> {
fn start(ctx: AppContext) -> anyhow::Result<()> {
let _lock = acquire_instance_lock(&ctx.config_root)?;

let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.max_blocking_threads(1)
.build()?;
let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?;
runtime.block_on(async move {
let shared = Arc::new(SharedContext::new(ctx));
let shutdown = Arc::new(tokio::sync::Notify::new());
Expand Down
6 changes: 5 additions & 1 deletion rust/crates/daemon/src/update_job.rs
Original file line number Diff line number Diff line change
Expand Up @@ -637,7 +637,11 @@ impl Supervisor {
);
return;
};
if let Err(e) = spicetify::commands::apply::run(ctx, guard, false) {
if let Err(e) = spicetify::commands::apply::run(
ctx,
guard,
spicetify::commands::apply::ApplyMode::Daemon,
) {
self.secure_failure(
FailureCode::ApplyFailed,
&format!("Spicetify apply failed after Spotify updated: {e}"),
Expand Down
Loading