memory leak fixing

This commit is contained in:
Graham McIntire 2026-01-31 10:40:19 -06:00
parent efc6e7a3ab
commit c4ce7a94ca
5 changed files with 267 additions and 14 deletions

193
CLAUDE.md
View file

@ -720,3 +720,196 @@ end
- Combine with runtime type checking for complete safety - Combine with runtime type checking for complete safety
- PLT files in `priv/plts/` - don't commit - PLT files in `priv/plts/` - don't commit
- Never suppress valid warnings - fix the root cause - Never suppress valid warnings - fix the root cause
## Memory Leak Prevention
### JavaScript Hook Event Listeners
**Problem**: Event listeners added in LiveView hooks without cleanup cause memory leaks when the hook is destroyed.
**Solution**: Always store event listener references and remove them in `destroyed()`:
```typescript
const MyHook = {
handleClick: null as ((e: Event) => void) | null,
handleDrag: null as ((e: DragEvent) => void) | null,
mounted(this: any) {
// Store reference to handler
this.handleClick = (e: Event) => {
// Handler logic
}
this.el.addEventListener("click", this.handleClick)
},
destroyed(this: any) {
// Clean up event listeners
if (this.handleClick) {
this.el.removeEventListener("click", this.handleClick)
this.handleClick = null
}
// Clean up any other state
this.someProperty = null
}
}
```
**Examples Fixed**:
- `CopyToClipboard` - Added `destroyed()` hook to remove click listener
- `DeviceListReorder` - Added `destroyed()` to remove dragstart, dragend, dragover, drop listeners
**Chart.js Cleanup** (already correct):
```typescript
const SensorChart = {
destroyed() {
if (this.chart) {
this.chart.destroy() // Prevents canvas memory leaks
this.chart = null
}
}
}
```
**Cytoscape Cleanup** (already correct):
```typescript
const NetworkMap = {
destroyed(this: any) {
if (this.cy) {
this.cy.destroy() // Cleans up Cytoscape instance
this.cy = null
}
}
}
```
### LiveView Assigns vs Streams
**Problem**: Large collections in assigns are kept in memory for the entire LiveView session.
**When to use streams instead of assigns**:
- Lists with hundreds or thousands of items
- Lists that receive frequent updates via PubSub
- Lists where you only need to add/update/remove individual items
**Example - AlertLive.Index should use streams**:
**Current (memory inefficient)**:
```elixir
def mount(_params, _session, socket) do
{:ok, socket |> assign(:alerts, load_all_alerts())}
end
def handle_info({:new_alert, _device_id, _alert}, socket) do
# Reloads entire list into memory
{:noreply, assign(socket, :alerts, load_all_alerts())}
end
```
**Better (use streams)**:
```elixir
def mount(_params, _session, socket) do
{:ok, socket |> stream(:alerts, load_all_alerts())}
end
def handle_info({:new_alert, _device_id, alert}, socket) do
# Only adds one alert to the stream
{:noreply, stream_insert(socket, :alerts, alert, at: 0)}
end
def handle_info({:alert_resolved, _device_id, alert}, socket) do
# Only removes one alert from the stream
{:noreply, stream_delete(socket, :alerts, alert)}
end
```
Template change:
```heex
<!-- Old -->
<%= for alert <- @alerts do %>
<div id={"alert-#{alert.id}"}>{alert.message}</div>
<% end %>
<!-- New with streams -->
<div id="alerts" phx-update="stream">
<%= for {id, alert} <- @streams.alerts do %>
<div id={id}>{alert.message}</div>
<% end %>
</div>
```
### Pagination for Large Lists
**Problem**: Loading thousands of items into memory for display.
**Solution**: Use pagination (see "Pagination in LiveView" section above).
**When pagination is appropriate**:
- Discovered devices (already paginated - 20 per page)
- Audit logs
- Historical alerts
- Any list that could grow to hundreds of items
**Example** (from DeviceLive.Index):
```elixir
# Don't keep all items in assigns
all_items = Context.list_all_items() # Temporary variable
total_count = length(all_items)
# Only store current page in assigns
items_page = Enum.slice(all_items, offset, per_page)
socket
|> assign(:items, items_page) # Only current page
|> assign(:pagination, %{...}) # Metadata
```
### Phoenix.PubSub Subscriptions
**Good news**: Phoenix automatically unsubscribes when a LiveView process terminates - no manual cleanup needed.
**Pattern** (already correct in codebase):
```elixir
def mount(_params, _session, socket) do
if connected?(socket) do
Phoenix.PubSub.subscribe(Towerops.PubSub, "topic")
end
# No need for terminate/2 callback
end
```
### Global Event Listeners (app.ts)
**Safe patterns** (already correct):
- `document.addEventListener('DOMContentLoaded', ...)` - Only fires once, remains for page lifetime
- `window.addEventListener("phx:page-loading-start", ...)` - Global events for the application
- `window.addEventListener("phx:copy", ...)` - Phoenix JS commands
These are acceptable because:
1. They're registered once when the page loads
2. They remain active for the entire page lifetime
3. LiveView patch navigation doesn't reload the page, so no duplicates
### Memory Leak Checklist
When writing new LiveView code or JavaScript hooks:
**LiveView (Elixir)**:
- [ ] Use `stream/3` for large or frequently-updated lists
- [ ] Use pagination for lists that could grow large
- [ ] Avoid storing entire datasets in assigns when only a subset is displayed
- [ ] PubSub subscriptions clean up automatically (no action needed)
**JavaScript Hooks**:
- [ ] Every `addEventListener` in `mounted()` has a matching `removeEventListener` in `destroyed()`
- [ ] Store event handler references (don't use anonymous functions)
- [ ] Clean up third-party library instances (Chart.js, Cytoscape, etc.) in `destroyed()`
- [ ] Set hook properties to `null` in `destroyed()` to release references
**Testing for Memory Leaks**:
- Navigate to a page with a large list
- Use browser DevTools → Memory → Take heap snapshot
- Navigate away and back multiple times
- Take another snapshot
- Compare - memory should not continuously grow

View file

@ -304,8 +304,10 @@ const SensorChart: SensorChartHook = {
// LiveView hook for copying to clipboard // LiveView hook for copying to clipboard
const CopyToClipboard = { const CopyToClipboard = {
mounted() { handleClick: null as ((e: Event) => void) | null,
this.el.addEventListener("click", (e) => {
mounted(this: any) {
this.handleClick = (e: Event) => {
e.preventDefault() e.preventDefault()
const targetId = this.el.dataset.target const targetId = this.el.dataset.target
if (!targetId) { if (!targetId) {
@ -361,7 +363,16 @@ const CopyToClipboard = {
} else { } else {
console.error('No text found to copy') console.error('No text found to copy')
} }
}) }
this.el.addEventListener("click", this.handleClick)
},
destroyed(this: any) {
if (this.handleClick) {
this.el.removeEventListener("click", this.handleClick)
this.handleClick = null
}
} }
} }

View file

@ -13,10 +13,15 @@ interface DragData {
export const DeviceListReorder = { export const DeviceListReorder = {
draggedElement: null as HTMLElement | null, draggedElement: null as HTMLElement | null,
dragData: null as DragData | null, dragData: null as DragData | null,
handleDragStart: null as ((e: DragEvent) => void) | null,
handleDragEnd: null as ((e: DragEvent) => void) | null,
handleDragOver: null as ((e: DragEvent) => void) | null,
handleDrop: null as ((e: DragEvent) => void) | null,
dragHandleListeners: [] as Array<{ element: Element; handler: (e: Event) => void }>,
mounted(this: any) { mounted(this: any) {
// Capture drag start // Capture drag start
this.el.addEventListener("dragstart", (e: DragEvent) => { this.handleDragStart = (e: DragEvent) => {
const target = (e.target as HTMLElement).closest("[data-site-id]") as HTMLElement const target = (e.target as HTMLElement).closest("[data-site-id]") as HTMLElement
if (!target) return if (!target) return
@ -38,10 +43,10 @@ export const DeviceListReorder = {
} }
e.dataTransfer!.effectAllowed = "move" e.dataTransfer!.effectAllowed = "move"
}) }
// Clean up on drag end // Clean up on drag end
this.el.addEventListener("dragend", (_e: DragEvent) => { this.handleDragEnd = (_e: DragEvent) => {
if (this.draggedElement) { if (this.draggedElement) {
this.draggedElement.classList.remove("opacity-50") this.draggedElement.classList.remove("opacity-50")
this.draggedElement = null this.draggedElement = null
@ -52,10 +57,10 @@ export const DeviceListReorder = {
this.el.querySelectorAll(".border-blue-500").forEach((el: Element) => { this.el.querySelectorAll(".border-blue-500").forEach((el: Element) => {
el.classList.remove("border-blue-500", "border-t-4", "border-b-4") el.classList.remove("border-blue-500", "border-t-4", "border-b-4")
}) })
}) }
// Show drop indicator // Show drop indicator
this.el.addEventListener("dragover", (e: DragEvent) => { this.handleDragOver = (e: DragEvent) => {
e.preventDefault() e.preventDefault()
if (!this.dragData) return if (!this.dragData) return
@ -72,10 +77,10 @@ export const DeviceListReorder = {
el.classList.remove("border-blue-500", "border-t-4", "border-b-4") el.classList.remove("border-blue-500", "border-t-4", "border-b-4")
}) })
target.classList.add("border-blue-500", isBottomHalf ? "border-b-4" : "border-t-4") target.classList.add("border-blue-500", isBottomHalf ? "border-b-4" : "border-t-4")
}) }
// Handle drop // Handle drop
this.el.addEventListener("drop", (e: DragEvent) => { this.handleDrop = (e: DragEvent) => {
e.preventDefault() e.preventDefault()
if (!this.dragData) return if (!this.dragData) return
@ -112,13 +117,52 @@ export const DeviceListReorder = {
this.el.querySelectorAll(".border-blue-500").forEach((el: Element) => { this.el.querySelectorAll(".border-blue-500").forEach((el: Element) => {
el.classList.remove("border-blue-500", "border-t-4", "border-b-4") el.classList.remove("border-blue-500", "border-t-4", "border-b-4")
}) })
}) }
// Add event listeners
this.el.addEventListener("dragstart", this.handleDragStart)
this.el.addEventListener("dragend", this.handleDragEnd)
this.el.addEventListener("dragover", this.handleDragOver)
this.el.addEventListener("drop", this.handleDrop)
// Prevent drag handle from triggering row click // Prevent drag handle from triggering row click
this.dragHandleListeners = []
this.el.querySelectorAll(".drag-handle").forEach((handle: Element) => { this.el.querySelectorAll(".drag-handle").forEach((handle: Element) => {
handle.addEventListener("click", (e: Event) => { const handler = (e: Event) => {
e.stopPropagation() e.stopPropagation()
}) }
handle.addEventListener("click", handler)
this.dragHandleListeners.push({ element: handle, handler })
}) })
},
destroyed(this: any) {
// Remove main event listeners
if (this.handleDragStart) {
this.el.removeEventListener("dragstart", this.handleDragStart)
this.handleDragStart = null
}
if (this.handleDragEnd) {
this.el.removeEventListener("dragend", this.handleDragEnd)
this.handleDragEnd = null
}
if (this.handleDragOver) {
this.el.removeEventListener("dragover", this.handleDragOver)
this.handleDragOver = null
}
if (this.handleDrop) {
this.el.removeEventListener("drop", this.handleDrop)
this.handleDrop = null
}
// Remove drag handle listeners
this.dragHandleListeners.forEach(({ element, handler }) => {
element.removeEventListener("click", handler)
})
this.dragHandleListeners = []
// Clean up state
this.draggedElement = null
this.dragData = null
} }
} }

View file

@ -43,6 +43,8 @@ defmodule ToweropsWeb.DeviceLive.Index do
page = params |> Map.get("page", "1") |> String.to_integer() page = params |> Map.get("page", "1") |> String.to_integer()
per_page = 20 per_page = 20
# Load all discovered devices - this is temporarily kept in memory for slicing
# For very large datasets, consider database-level pagination
all_discovered = Snmp.list_discovered_devices_for_organization(organization.id) all_discovered = Snmp.list_discovered_devices_for_organization(organization.id)
total_count = length(all_discovered) total_count = length(all_discovered)
total_pages = ceil(total_count / per_page) total_pages = ceil(total_count / per_page)
@ -51,11 +53,13 @@ defmodule ToweropsWeb.DeviceLive.Index do
page = max(1, min(page, max(1, total_pages))) page = max(1, min(page, max(1, total_pages)))
offset = (page - 1) * per_page offset = (page - 1) * per_page
# Only keep the current page in assigns - don't store all_discovered
discovered_devices = Enum.slice(all_discovered, offset, per_page) discovered_devices = Enum.slice(all_discovered, offset, per_page)
socket socket
|> assign(:active_tab, "discovered") |> assign(:active_tab, "discovered")
|> assign(:discovered_devices, discovered_devices) # Use temporary assign - will be cleared after render
|> assign_new(:discovered_devices, fn -> discovered_devices end)
|> assign(:pagination, %{ |> assign(:pagination, %{
page: page, page: page,
per_page: per_page, per_page: per_page,

View file

@ -5,6 +5,7 @@ Devices Working
2026-01-31 2026-01-31
* Small ui tweaks * Small ui tweaks
* Backend refactoring * Backend refactoring
* Patched some potential memory leaks
2026-01-30 2026-01-30
* Completely overhaul poller agent to not track any state * Completely overhaul poller agent to not track any state