fix: corrigir memory leaks e testes de mocks

- Fechar ConvexClient antigo antes de criar novo (evita memory leak)
- Adicionar flag disposed para prevenir race condition em useEffect
- Reduzir polling SSE de 1s para 5s (balanco entre responsividade e carga)
- Adicionar .take() aos mocks de testes para compatibilidade

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
esdrasrenan 2025-12-09 21:49:04 -03:00
parent 638faeb287
commit 508f915cf9
5 changed files with 39 additions and 6 deletions

View file

@ -83,6 +83,15 @@ async function ensureClient(): Promise<ClientCache> {
return cached return cached
} }
// Fechar cliente antigo antes de criar novo (evita memory leak)
if (cached) {
try {
cached.client.close()
} catch {
// Ignora erro ao fechar cliente antigo
}
}
const client = new ConvexClient(convexUrl) const client = new ConvexClient(convexUrl)
cached = { client, token: data.token, convexUrl } cached = { client, token: data.token, convexUrl }
return cached return cached

View file

@ -1090,9 +1090,11 @@ const resolvedAppUrl = useMemo(() => {
let prevUnread = 0 let prevUnread = 0
let unsub: (() => void) | null = null let unsub: (() => void) | null = null
let disposed = false
subscribeMachineUpdates( subscribeMachineUpdates(
(payload) => { (payload) => {
if (!payload) return if (disposed || !payload) return
const totalUnread = payload.totalUnread ?? 0 const totalUnread = payload.totalUnread ?? 0
const hasSessions = (payload.sessions ?? []).length > 0 const hasSessions = (payload.sessions ?? []).length > 0
@ -1107,6 +1109,7 @@ const resolvedAppUrl = useMemo(() => {
prevUnread = totalUnread prevUnread = totalUnread
}, },
(err) => { (err) => {
if (disposed) return
console.error("chat updates (Convex) erro:", err) console.error("chat updates (Convex) erro:", err)
const msg = (err?.message || "").toLowerCase() const msg = (err?.message || "").toLowerCase()
if (msg.includes("token de máquina") || msg.includes("revogado") || msg.includes("expirado") || msg.includes("inválido")) { if (msg.includes("token de máquina") || msg.includes("revogado") || msg.includes("expirado") || msg.includes("inválido")) {
@ -1115,10 +1118,16 @@ const resolvedAppUrl = useMemo(() => {
} }
} }
).then((u) => { ).then((u) => {
unsub = u // Se o effect já foi desmontado antes da Promise resolver, cancelar imediatamente
if (disposed) {
u()
} else {
unsub = u
}
}) })
return () => { return () => {
disposed = true
unsub?.() unsub?.()
} }
}, [token, attemptSelfHeal]) }, [token, attemptSelfHeal])

View file

@ -82,7 +82,7 @@ export async function GET(request: Request) {
sendEvent("heartbeat", { ts: Date.now() }) sendEvent("heartbeat", { ts: Date.now() })
}, 30_000) }, 30_000)
// Poll interno a cada 1s e push via SSE (responsivo para chat) // Poll interno a cada 5s e push via SSE (balanco entre responsividade e carga)
const pollInterval = setInterval(async () => { const pollInterval = setInterval(async () => {
if (isAborted) { if (isAborted) {
clearInterval(pollInterval) clearInterval(pollInterval)
@ -118,7 +118,7 @@ export async function GET(request: Request) {
clearInterval(heartbeatInterval) clearInterval(heartbeatInterval)
controller.close() controller.close()
} }
}, 1_000) }, 5_000)
// Enviar evento inicial de conexao // Enviar evento inicial de conexao
sendEvent("connected", { ts: Date.now() }) sendEvent("connected", { ts: Date.now() })

View file

@ -60,8 +60,12 @@ describe("convex.machines.getById", () => {
collect: vi.fn(async () => [ collect: vi.fn(async () => [
{ revoked: false, expiresAt: FIXED_NOW + 60_000, lastUsedAt: FIXED_NOW - 1000, usageCount: 5 }, { revoked: false, expiresAt: FIXED_NOW + 60_000, lastUsedAt: FIXED_NOW - 1000, usageCount: 5 },
]), ]),
take: vi.fn(async () => [
{ revoked: false, expiresAt: FIXED_NOW + 60_000, lastUsedAt: FIXED_NOW - 1000, usageCount: 5 },
]),
})), })),
collect: vi.fn(async () => []), collect: vi.fn(async () => []),
take: vi.fn(async () => []),
})), })),
} }

View file

@ -31,6 +31,7 @@ function ticketsChain(collection: Doc<"tickets">[]) {
}), }),
order: vi.fn(() => chain), order: vi.fn(() => chain),
collect: vi.fn(async () => collection), collect: vi.fn(async () => collection),
take: vi.fn(async (limit: number) => collection.slice(0, limit)),
} }
return chain return chain
} }
@ -74,9 +75,11 @@ export function createReportsCtx({
cb?.(noopIndexBuilder) cb?.(noopIndexBuilder)
return { return {
collect: vi.fn(async () => queues), collect: vi.fn(async () => queues),
take: vi.fn(async (limit: number) => queues.slice(0, limit)),
} }
}), }),
collect: vi.fn(async () => queues), collect: vi.fn(async () => queues),
take: vi.fn(async (limit: number) => queues.slice(0, limit)),
} }
} }
@ -86,9 +89,11 @@ export function createReportsCtx({
cb?.(noopIndexBuilder) cb?.(noopIndexBuilder)
return { return {
collect: vi.fn(async () => categories), collect: vi.fn(async () => categories),
take: vi.fn(async (limit: number) => categories.slice(0, limit)),
} }
}), }),
collect: vi.fn(async () => categories), collect: vi.fn(async () => categories),
take: vi.fn(async (limit: number) => categories.slice(0, limit)),
} }
} }
@ -103,8 +108,10 @@ export function createReportsCtx({
}, },
} }
cb?.(builder as { eq: (field: unknown, value: unknown) => unknown }) cb?.(builder as { eq: (field: unknown, value: unknown) => unknown })
const events = ticketId ? ticketEventsByTicket.get(ticketId) ?? [] : []
return { return {
collect: vi.fn(async () => (ticketId ? ticketEventsByTicket.get(ticketId) ?? [] : [])), collect: vi.fn(async () => events),
take: vi.fn(async (limit: number) => events.slice(0, limit)),
} }
}), }),
} }
@ -121,8 +128,10 @@ export function createReportsCtx({
}, },
} }
cb?.(builder as { eq: (field: unknown, value: unknown) => unknown }) cb?.(builder as { eq: (field: unknown, value: unknown) => unknown })
const sessions = agentId ? ticketWorkSessionsByAgent.get(agentId) ?? [] : []
return { return {
collect: vi.fn(async () => (agentId ? ticketWorkSessionsByAgent.get(agentId) ?? [] : [])), collect: vi.fn(async () => sessions),
take: vi.fn(async (limit: number) => sessions.slice(0, limit)),
} }
}), }),
} }
@ -131,8 +140,10 @@ export function createReportsCtx({
return { return {
withIndex: vi.fn(() => ({ withIndex: vi.fn(() => ({
collect: vi.fn(async () => []), collect: vi.fn(async () => []),
take: vi.fn(async () => []),
})), })),
collect: vi.fn(async () => []), collect: vi.fn(async () => []),
take: vi.fn(async () => []),
} }
}), }),
} }