⬆️ version bump & fix race conditions in API cache and renderers

- Bump Cargo.lock version to `0.3.47`
- Fix race condition in API cache by using unique request keys and proper promise cleanup
  - Avoid duplicate pending requests with `request:${key}` prefix
- Improve renderer snapshot error handling: clear invalid snapshots on failure and always clean up loading flag in `finally`
- Add debounce to renderer refresh logic (500ms) preventing excessive concurrent fetches
  - Use local `currentRendererId` to avoid stale ID usage across async boundaries
This commit is contained in:
2026-04-06 08:06:21 +02:00
parent bb06fbe30e
commit 66ba31b12f
4 changed files with 54 additions and 15 deletions

2
Cargo.lock generated
View File

@@ -4,7 +4,7 @@ version = 4
[[package]]
name = "PMOMusic"
version = "0.3.36"
version = "0.3.37"
dependencies = [
"axum 0.8.7",
"console-subscriber",

View File

@@ -92,20 +92,34 @@ class ApiCacheService {
if (cached) return cached;
}
const existing = this.pendingRequests.get(key);
if (existing) {
return existing.promise as Promise<T>;
// Utiliser une clé unique pour éviter les problèmes de race condition
// avec les requêtes en cours qui peuvent être supprimées avant résolution
const requestKey = `request:${key}`;
// Récupérer ou créer la requête
let pendingRequest = this.pendingRequests.get(requestKey);
// Si une requête est en cours et sa promesse n'a pas encore été resolved/rejected
// on retourne directement cette promesse
if (pendingRequest) {
try {
// Attendre la résolution pour s'assurer que c'est toujours valide
return await pendingRequest.promise as T;
} catch (e) {
// La requête a échoué, on continue pour faire une nouvelle requête
this.pendingRequests.delete(requestKey);
}
}
let resolvePromise!: (value: unknown) => void;
let resolvePromise!: (value: T) => void;
let rejectPromise!: (reason: unknown) => void;
const promise = new Promise<unknown>((resolve, reject) => {
const promise = new Promise<T>((resolve, reject) => {
resolvePromise = resolve;
rejectPromise = reject;
});
this.pendingRequests.set(key, {
this.pendingRequests.set(requestKey, {
promise,
subscribers: new Set(),
});
@@ -124,7 +138,7 @@ class ApiCacheService {
resolvePromise(data);
const pending = this.pendingRequests.get(key);
const pending = this.pendingRequests.get(requestKey);
if (pending) {
pending.subscribers.forEach(cb => cb(data));
}
@@ -133,10 +147,10 @@ class ApiCacheService {
rejectPromise(error);
throw error;
} finally {
this.pendingRequests.delete(key);
this.pendingRequests.delete(requestKey);
}
return Promise.reject(new Error('Unreachable'));
return promise;
}
subscribe<T>(endpoint: string, params: Record<string, string | number | boolean>, callback: (data: T) => void): () => void {

View File

@@ -322,6 +322,7 @@ async function fetchRendererSnapshot(
}
}
// Éviter les requêtes multiples simultanées pour le même renderer
if (snapshotState.loadingIds.has(rendererId)) {
return;
}
@@ -333,7 +334,10 @@ async function fetchRendererSnapshot(
snapshotState.lastSnapshotAt.set(rendererId, Date.now());
} catch (err) {
console.error(`[useRenderers] Erreur snapshot ${rendererId}:`, err);
// En cas d'erreur, on supprime le snapshot pour permettre une nouvelle tentative
snapshotState.snapshots.delete(rendererId);
} finally {
// Toujours nettoyer le flag de chargement
snapshotState.loadingIds.delete(rendererId);
}
}
@@ -545,10 +549,25 @@ export function useRenderer(rendererId: Ref<string>) {
const isStream = computed(() => snapshot.value?.is_stream ?? false);
const queueRefreshing = computed(() => isQueueRefreshing(rendererId.value));
// Debounce pour éviter les refreshs multiples trop fréquents
let refreshDebounceTimer: ReturnType<typeof setTimeout> | null = null;
const REFRESH_DEBOUNCE_MS = 500;
async function refresh(force = true) {
const currentRendererId = rendererId.value;
// Debounce: ignorer si un refresh est en cours pour ce renderer
if (refreshDebounceTimer !== null) {
return;
}
refreshDebounceTimer = setTimeout(() => {
refreshDebounceTimer = null;
}, REFRESH_DEBOUNCE_MS);
await Promise.all([
fetchRenderers(force),
fetchRendererSnapshot(rendererId.value, { force: true }),
fetchRendererSnapshot(currentRendererId, { force: true }),
]);
}

View File

@@ -135,12 +135,18 @@ onMounted(async () => {
});
// Watch renderers pour sync automatique des tabs
// Note: on watch la taille du tableau + les IDs pour éviter un deep watch coûteux
watch(
() => allRenderers.value,
(newRenderers) => {
syncWithRenderers(filterRenderers(newRenderers));
() => ({
length: allRenderers.value.length,
ids: allRenderers.value.map(r => r.id).join(','),
}),
(newVal, oldVal) => {
// Re-sync uniquement si le nombre ou les IDs ont changé
if (newVal.length !== oldVal?.length || newVal.ids !== oldVal?.ids) {
syncWithRenderers(filterRenderers(allRenderers.value));
}
},
{ deep: true },
);
// Watch l'UDN du WebRenderer local : quand il s'établit, resync pour faire apparaître notre onglet