fix: resolve UI state reset and backlog sync hang

- Fix client startup order: fetch/apply server UI state BEFORE loadGuilds() to prevent overwriting persisted state with default guild
- Remove auto-post of first guild in loadGuilds() — let server state drive selection
- Refactor collectWatchableChannels() to collect text channels fast first, then discover threads in parallel with 5s per-channel timeout and 30s overall timeout to prevent blocking message sync
- Ignore /favicon.ico 404 in error logging to reduce noise

Fixes:
- UI state now persists across restart (was being overwritten by client startup race)
- Backlog sync no longer hangs on thread discovery (was blocking before message sync)
- Cleaner logs without favicon 404 errors

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
MythEclipse
2026-05-14 03:45:51 +07:00
parent d4a4f737a8
commit 0a5cedfed1
3 changed files with 42 additions and 14 deletions

View File

@@ -85,7 +85,7 @@
function appendBadge(parent, label, className) { const badge = document.createElement('span'); badge.className = `badge ${className}`; badge.textContent = label; parent.appendChild(badge); } function appendBadge(parent, label, className) { const badge = document.createElement('span'); badge.className = `badge ${className}`; badge.textContent = label; parent.appendChild(badge); }
function parseMetadata(value) { if (!value) return {}; try { return JSON.parse(value); } catch { return {}; } } function parseMetadata(value) { if (!value) return {}; try { return JSON.parse(value); } catch { return {}; } }
async function loadGuilds() { const guilds = await apiRequest('/api/guilds'); renderOptions(el.guildSelect, guilds, 'Select guild'); if (state.selectedGuild) { el.guildSelect.value = state.selectedGuild; await loadChannels(state.selectedGuild); } else if (guilds[0]?.id) { await postUIState({ selectedGuild: guilds[0].id }); } } async function loadGuilds() { const guilds = await apiRequest('/api/guilds'); renderOptions(el.guildSelect, guilds, 'Select guild'); if (state.selectedGuild) { el.guildSelect.value = state.selectedGuild; await loadChannels(state.selectedGuild); } }
async function loadChannels(guildId) { if (!guildId) return; const [voiceChannels, watchChannels] = await Promise.all([apiRequest(`/api/guilds/${guildId}/voice-channels`), apiRequest(`/api/guilds/${guildId}/channels`)]); renderOptions(el.channelSelect, voiceChannels, 'Select voice channel'); renderOptions(el.channelFilter, watchChannels, 'Select channel'); if (state.selectedVoiceChannel) el.channelSelect.value = state.selectedVoiceChannel; if (state.selectedTextChannel) el.channelFilter.value = state.selectedTextChannel; apiRequest(`/api/guilds/${guildId}/threads`).then((threads) => { appendOptions(el.channelFilter, threads); if (state.selectedTextChannel) el.channelFilter.value = state.selectedTextChannel; }).catch((error) => showError(`Thread discovery failed: ${error.message}`)); } async function loadChannels(guildId) { if (!guildId) return; const [voiceChannels, watchChannels] = await Promise.all([apiRequest(`/api/guilds/${guildId}/voice-channels`), apiRequest(`/api/guilds/${guildId}/channels`)]); renderOptions(el.channelSelect, voiceChannels, 'Select voice channel'); renderOptions(el.channelFilter, watchChannels, 'Select channel'); if (state.selectedVoiceChannel) el.channelSelect.value = state.selectedVoiceChannel; if (state.selectedTextChannel) el.channelFilter.value = state.selectedTextChannel; apiRequest(`/api/guilds/${guildId}/threads`).then((threads) => { appendOptions(el.channelFilter, threads); if (state.selectedTextChannel) el.channelFilter.value = state.selectedTextChannel; }).catch((error) => showError(`Thread discovery failed: ${error.message}`)); }
async function refreshStatus() { try { const status = await apiRequest('/api/status'); el.voiceStatusText.textContent = status.connected ? status.activeChannelName || 'Connected' : 'Not connected'; el.voiceStatusNote.textContent = status.connected ? `Connected to ${status.activeChannelName}` : 'Idle'; } catch (error) { showError(error.message); } } async function refreshStatus() { try { const status = await apiRequest('/api/status'); el.voiceStatusText.textContent = status.connected ? status.activeChannelName || 'Connected' : 'Not connected'; el.voiceStatusNote.textContent = status.connected ? `Connected to ${status.activeChannelName}` : 'Idle'; } catch (error) { showError(error.message); } }
async function connectVoice() { const guildId = el.guildSelect.value; const channelId = el.channelSelect.value; if (!guildId || !channelId) return showError('Select guild and voice channel first'); await postUIState({ selectedGuild: guildId, selectedVoiceChannel: channelId }); const status = await apiRequest('/api/connect', { method: 'POST', body: JSON.stringify({ guildId, channelId }) }); el.voiceStatusText.textContent = status.activeChannelName || 'Connected'; el.voiceStatusNote.textContent = `Connected to ${status.activeChannelName}`; } async function connectVoice() { const guildId = el.guildSelect.value; const channelId = el.channelSelect.value; if (!guildId || !channelId) return showError('Select guild and voice channel first'); await postUIState({ selectedGuild: guildId, selectedVoiceChannel: channelId }); const status = await apiRequest('/api/connect', { method: 'POST', body: JSON.stringify({ guildId, channelId }) }); el.voiceStatusText.textContent = status.activeChannelName || 'Connected'; el.voiceStatusNote.textContent = `Connected to ${status.activeChannelName}`; }
@@ -143,7 +143,7 @@
el.channelFilter.addEventListener('change', () => { const selectedTextChannel = el.channelFilter.value; const url = new URL(location.href); if (selectedTextChannel) url.searchParams.set('channel', selectedTextChannel); else url.searchParams.delete('channel'); if (el.guildSelect.value) url.searchParams.set('guild', el.guildSelect.value); history.replaceState({}, '', url); postUIState({ selectedTextChannel }).catch((error) => showError(error.message)); }); el.channelFilter.addEventListener('change', () => { const selectedTextChannel = el.channelFilter.value; const url = new URL(location.href); if (selectedTextChannel) url.searchParams.set('channel', selectedTextChannel); else url.searchParams.delete('channel'); if (el.guildSelect.value) url.searchParams.set('guild', el.guildSelect.value); history.replaceState({}, '', url); postUIState({ selectedTextChannel }).catch((error) => showError(error.message)); });
connectWebSocket(); connectWebSocket();
loadGuilds().then(refreshStatus).then(() => apiRequest('/api/ui-state')).then(applyServerState).catch((error) => showError(error.message)); apiRequest('/api/ui-state').then(applyServerState).then(() => loadGuilds()).then(refreshStatus).catch((error) => showError(error.message));
setInterval(() => { if (state.activeTab === 'text') fetchText().catch(() => {}); }, 7000); setInterval(() => { if (state.activeTab === 'text') fetchText().catch(() => {}); }, 7000);
</script> </script>
</body> </body>

View File

@@ -17,24 +17,51 @@ function isWatchableChannel(channel: { type?: string; messages?: unknown }): boo
async function collectWatchableChannels(guild: any): Promise<any[]> { async function collectWatchableChannels(guild: any): Promise<any[]> {
const channels: any[] = []; const channels: any[] = [];
// Fast pass: collect text channels from cache only
for (const channel of guild.channels.cache.values()) { for (const channel of guild.channels.cache.values()) {
if (isWatchableChannel(channel)) { if (isWatchableChannel(channel)) {
channels.push(channel); channels.push(channel);
} }
}
if (channel.threads?.fetch) { // Slow pass: discover threads with timeout per channel (non-blocking to message sync)
const threadPromises: Promise<void>[] = [];
for (const channel of guild.channels.cache.values()) {
if (!channel.threads?.fetch) continue;
threadPromises.push(
(async () => {
for (const archived of [false, true]) { for (const archived of [false, true]) {
const fetched = await channel.threads try {
.fetch({ archived, limit: 100 }) const controller = new AbortController();
.catch(() => null); const timeout = setTimeout(() => controller.abort(), 5000);
const fetched = await Promise.race([
channel.threads.fetch({ archived, limit: 100 }),
new Promise((_, reject) => controller.signal.addEventListener('abort', () => reject(new Error('timeout')))),
]).catch(() => null);
clearTimeout(timeout);
if (!fetched?.threads) continue; if (!fetched?.threads) continue;
for (const thread of fetched.threads.values()) { for (const thread of fetched.threads.values()) {
if (isWatchableChannel(thread)) channels.push(thread); if (isWatchableChannel(thread)) channels.push(thread);
} }
} catch {
// Skip this channel's threads on timeout/error
} }
} }
})()
);
} }
// Wait for all thread discoveries with overall timeout
await Promise.race([
Promise.all(threadPromises),
new Promise((resolve) => setTimeout(resolve, 30000)),
]).catch(() => {
logger.warn("Thread discovery timeout, proceeding with cached channels");
});
return Array.from(new Map(channels.map((channel) => [channel.id, channel])).values()); return Array.from(new Map(channels.map((channel) => [channel.id, channel])).values());
} }

View File

@@ -129,6 +129,7 @@ export function startWebserver(
} }
res.on("finish", () => { res.on("finish", () => {
if (req.originalUrl.startsWith("/.well-known/appspecific/")) return; if (req.originalUrl.startsWith("/.well-known/appspecific/")) return;
if (req.originalUrl === "/favicon.ico") return;
if (res.statusCode >= 400) { if (res.statusCode >= 400) {
logger.error( logger.error(
{ method: req.method, url: req.originalUrl, statusCode: res.statusCode }, { method: req.method, url: req.originalUrl, statusCode: res.statusCode },