⬆️ 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:
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -4,7 +4,7 @@ version = 4
|
||||
|
||||
[[package]]
|
||||
name = "PMOMusic"
|
||||
version = "0.3.36"
|
||||
version = "0.3.37"
|
||||
dependencies = [
|
||||
"axum 0.8.7",
|
||||
"console-subscriber",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user