fix(cover): follow connect-URL flips in library cover backfill (#952)

* fix(cover): follow connect-URL flips in library cover backfill

The native cover backfill was configured once with a snapshot of the runtime
connect URL. When a laptop moved off the LAN, the smart endpoint switch flipped
the sticky connect URL to the public address (playback/UI covers rebuild it per
request and followed it), but backfill kept fetching from the now-unreachable
local address — flooding the log with "error sending request" failures.

Make the connect cache observable (notify on effective flips) and have the
backfill hook reconfigure when the resolved URL changes, forcing a pass so the
.fetch-failed backoff from the stale address is cleared and those covers retry
on the reachable endpoint.

* docs(changelog): note cover backfill endpoint-switch fix (PR #952)

* fix(cover): abort stale backfill pass + rerun on connect-URL flip

Frontend reconfigure alone didn't fully cover the boot case: at startup the
first backfill pass starts on the primary (LAN) URL before the reachability
probe resolves, so when the probe flips to public the forced rerun was dropped
by the pass_running guard and the slow LAN pass (every cover timing out) ran to
completion with nothing re-running on the reachable address.

set_session now bumps a session generation; the running pass checks it on every
focus gate and abandons promptly when the URL flips (same server_index_key, new
rest_base_url). try_schedule_full_pass records a rerun when a pass is in flight
and drains it once the abandoned pass returns, so a fresh forced pass runs on
the new address.

* fix(cover-backfill): resolve connect URL per fetch instead of baking it into the queue

The backfill worklist no longer carries a URL. Each cover fetch reads the
current reachable address live from a single worker cell, so a LAN↔public flip
is honoured even by the pass already in flight — its remaining covers download
against the new endpoint without aborting/rebuilding the worklist.

- Drop rest_base_url from CoverBackfillSession; add live base_url cell read in
  ensure_one. Remove the session-generation abort machinery (no longer needed).
- New lightweight library_cover_backfill_set_base_url command pushes the URL on
  every connect-cache flip; a real change clears the stale .fetch-failed backoff
  and runs a forced pass so covers that timed out on the old address retry.
- Split useLibraryCoverBackfill into a configure effect (server/creds/strategy)
  and a flip effect that only pushes the URL.
- Keep a single rerun_pending flag for the boot case (flip mid-pass), since the
  finished pass re-arms the idle gate.
This commit is contained in:
cucadmuh
2026-06-02 16:01:01 +03:00
committed by GitHub
parent 81f900c7a6
commit 47832632fd
9 changed files with 245 additions and 16 deletions
+61
View File
@@ -15,6 +15,7 @@ import {
pickReachableBaseUrl,
serverAddressEndpoints,
serverShareBaseUrl,
subscribeConnectCache,
} from './serverEndpoint';
import type { ServerProfile } from '../../store/authStoreTypes';
@@ -377,6 +378,66 @@ describe('invalidateReachableEndpointCache', () => {
});
});
describe('subscribeConnectCache — connect-URL flip notifications', () => {
beforeEach(() => {
invalidateReachableEndpointCache();
vi.mocked(pingWithCredentials).mockReset();
});
it('notifies when a probe resolves a new endpoint and on a later flip', async () => {
const listener = vi.fn();
const unsubscribe = subscribeConnectCache(listener);
const profile = makeProfile({
url: 'https://music.example.com',
alternateUrl: 'http://192.168.0.10',
});
// First probe: LAN answers → cache set → one notification.
vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk());
await pickReachableBaseUrl(profile);
expect(listener).toHaveBeenCalledTimes(1);
// LAN drops, public answers → cached URL flips → another notification.
vi.mocked(pingWithCredentials)
.mockResolvedValueOnce(pingFail())
.mockResolvedValueOnce(pingOk());
await pickReachableBaseUrl(profile);
expect(listener).toHaveBeenCalledTimes(2);
unsubscribe();
});
it('does not notify when the sticky endpoint is unchanged', async () => {
const profile = makeProfile({ url: 'http://192.168.0.10' });
vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk());
await pickReachableBaseUrl(profile);
const listener = vi.fn();
const unsubscribe = subscribeConnectCache(listener);
// Re-probe, same endpoint answers → cache value identical → no notification.
vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk());
await pickReachableBaseUrl(profile);
expect(listener).not.toHaveBeenCalled();
unsubscribe();
});
it('notifies on explicit cache invalidation when an entry existed', async () => {
vi.mocked(pingWithCredentials).mockResolvedValueOnce(pingOk());
await pickReachableBaseUrl(makeProfile({ id: 'a' }));
const listener = vi.fn();
const unsubscribe = subscribeConnectCache(listener);
invalidateReachableEndpointCache('a');
expect(listener).toHaveBeenCalledTimes(1);
// No-op invalidation (nothing cached) must stay silent.
invalidateReachableEndpointCache('a');
expect(listener).toHaveBeenCalledTimes(1);
unsubscribe();
});
});
describe('serverShareBaseUrl', () => {
it('returns the single address for a single-URL profile', () => {
expect(serverShareBaseUrl({ url: 'https://music.example.com' })).toBe(
+35 -3
View File
@@ -165,6 +165,33 @@ export function serverShareBaseUrl(
const connectCache = new Map<string, string>();
// ── Connect-cache change notifications ───────────────────────────────────────
// The sticky connect URL flips silently (120-s probe tick / online event /
// switch). Long-lived consumers that snapshot the URL once — notably the native
// **library cover backfill**, which is configured with a fixed `rest_base_url`
// — need to react when a laptop moves off the LAN, or they keep hammering the
// now-unreachable local address. UI/playback rebuild the URL per request and
// don't need this. Listeners are notified only when a profile's cached URL
// actually changes value (set to a different endpoint, dropped, or cleared).
const connectCacheListeners = new Set<() => void>();
let connectCacheVersion = 0;
function notifyConnectCacheChanged(): void {
connectCacheVersion += 1;
connectCacheListeners.forEach(cb => cb());
}
/** Subscribe to connect-URL flips (any profile). Returns an unsubscribe fn. */
export function subscribeConnectCache(cb: () => void): () => void {
connectCacheListeners.add(cb);
return () => connectCacheListeners.delete(cb);
}
/** Monotonic version, bumped on every effective connect-cache change. */
export function getConnectCacheVersion(): number {
return connectCacheVersion;
}
/**
* In-flight probes keyed by `profile.id`. Three call sites (useConnectionStatus
* 120-s tick, switchActiveServer, bindIndexedServer, plus retry / online
@@ -208,14 +235,17 @@ export function connectBaseUrlForServer(
*/
export function invalidateReachableEndpointCache(profileId?: string): void {
if (profileId === undefined) {
connectCache.clear();
// Don't clear in-flight slots — they're already racing against the
// network, letting their own `finally` clean up keeps the dedup
// invariant. Their results will still write to the (now empty) cache,
// which is the right behaviour: the freshest probe wins.
if (connectCache.size > 0) {
connectCache.clear();
notifyConnectCacheChanged();
}
return;
}
connectCache.delete(profileId);
if (connectCache.delete(profileId)) notifyConnectCacheChanged();
}
/**
@@ -250,14 +280,16 @@ export async function pickReachableBaseUrl(
for (const endpoint of endpoints) {
const ping = await pingWithCredentials(endpoint.url, profile.username, profile.password);
if (ping.ok) {
const prev = connectCache.get(profile.id);
connectCache.set(profile.id, endpoint.url);
if (prev !== endpoint.url) notifyConnectCacheChanged();
return { ok: true, baseUrl: endpoint.url, endpoint, ping };
}
}
// Every endpoint failed — drop any stale cache entry so the next probe
// starts from the natural LAN-first order.
connectCache.delete(profile.id);
if (connectCache.delete(profile.id)) notifyConnectCacheChanged();
return { ok: false, reason: 'unreachable' };
})();