fix(tmux): use-after-free in receivedListWindows action slice#11213
fix(tmux): use-after-free in receivedListWindows action slice#11213daiimus wants to merge 1 commit intoghostty-org:mainfrom
Conversation
receivedListWindows stores a slice of Window structs into the action list so callers can process GUI window changes. Previously, this slice pointed directly into a local ArrayList's backing memory, which was freed by defer on function return. The action then held a dangling pointer. This is currently latent because consumers only read the slice length (inline in the fat pointer), not the pointed-to data. It becomes a live crash the moment anyone iterates the window contents. Fix: dupe the slice into the arena allocator so it shares the same lifetime as the action list. Add a regression test that dereferences action.windows[0].id/width/ height after the function returns, which would read freed memory without the fix.
There was a problem hiding this comment.
Pull request overview
This PR fixes a use-after-free bug in the tmux viewer's receivedListWindows function. Previously, windows.items — a slice backed by a local std.ArrayList — was stored directly into the action list. Since the ArrayList is freed by a defer when the function returns, this left the action holding a dangling pointer. The fix duplicates the slice into the arena allocator that backs the action list, ensuring the data survives the function return.
Changes:
- Duplicate
windows.itemsintoarena_allocbefore appending to the action list, preventing a use-after-free - Added a regression test that exercises the full startup-to-list-windows flow and validates that window data in the action is accessible after the function returns
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // is a local ArrayList whose backing memory is freed by the | ||
| // defer above when this function returns. | ||
| const arena_windows = try arena_alloc.dupe(Window, windows.items); | ||
| try actions.append(arena_alloc, .{ .windows = arena_windows }); |
There was a problem hiding this comment.
I have to check in more detail, but on first pass this doesn't seem right. Why not change windows to be on the arena, possibly layout too so it's not in its own arena but using the shared arena?
Please explain your thinking, not the agents.
…ship Replace per-window layout_arena with a shared windows_arena on Viewer. All window and layout allocations now happen on a single arena that is reset as a unit during receivedListWindows (free_all) and layoutChanged (retain_capacity + selective rebuild via Layout.clone). This addresses Mitchell's review on PR ghostty-org#11213: 'Why not change windows to be on the arena, possibly layout too so it's not in its own arena but using the shared arena?' Changes: - Add windows_arena field to Viewer, initialized from action_arena's child allocator - Remove layout_arena from Window and Window.deinit() - receivedListWindows: reset windows_arena, rebuild all windows+layouts - layoutChanged: reset windows_arena with retain_capacity, clone unchanged windows' layouts via new Layout.clone() method - syncLayouts: remove window.deinit() calls (arena owns memory) - Add Layout.clone() for deep-copying layout trees across allocators - Add 3 new tests: clone leaf, clone split tree, shared arena preservation across layout changes
…ship Replace per-window layout_arena with a shared windows_arena on Viewer. All window and layout allocations now happen on a single arena that is reset as a unit during receivedListWindows (free_all) and layoutChanged (retain_capacity + selective rebuild via Layout.clone). This addresses Mitchell's review on PR ghostty-org#11213: 'Why not change windows to be on the arena, possibly layout too so it's not in its own arena but using the shared arena?' Changes: - Add windows_arena field to Viewer, initialized from action_arena's child allocator - Remove layout_arena from Window and Window.deinit() - receivedListWindows: reset windows_arena, rebuild all windows+layouts - layoutChanged: reset windows_arena with retain_capacity, clone unchanged windows' layouts via new Layout.clone() method - syncLayouts: remove window.deinit() calls (arena owns memory) - Add Layout.clone() for deep-copying layout trees across allocators - Add 3 new tests: clone leaf, clone split tree, shared arena preservation across layout changes
…ship Replace per-window layout_arena with a shared windows_arena on Viewer. All window and layout allocations now happen on a single arena that is reset as a unit during receivedListWindows (free_all) and layoutChanged (retain_capacity + selective rebuild via Layout.clone). This addresses Mitchell's review on PR ghostty-org#11213: 'Why not change windows to be on the arena, possibly layout too so it's not in its own arena but using the shared arena?' Changes: - Add windows_arena field to Viewer, initialized from action_arena's child allocator - Remove layout_arena from Window and Window.deinit() - receivedListWindows: reset windows_arena, rebuild all windows+layouts - layoutChanged: reset windows_arena with retain_capacity, clone unchanged windows' layouts via new Layout.clone() method - syncLayouts: remove window.deinit() calls (arena owns memory) - Add Layout.clone() for deep-copying layout trees across allocators - Add 3 new tests: clone leaf, clone split tree, shared arena preservation across layout changes
…ship Replace per-window layout_arena with a shared windows_arena on Viewer. All window and layout allocations now happen on a single arena that is reset as a unit during receivedListWindows (free_all) and layoutChanged (retain_capacity + selective rebuild via Layout.clone). This addresses Mitchell's review on PR ghostty-org#11213: 'Why not change windows to be on the arena, possibly layout too so it's not in its own arena but using the shared arena?' Changes: - Add windows_arena field to Viewer, initialized from action_arena's child allocator - Remove layout_arena from Window and Window.deinit() - receivedListWindows: reset windows_arena, rebuild all windows+layouts - layoutChanged: reset windows_arena with retain_capacity, clone unchanged windows' layouts via new Layout.clone() method - syncLayouts: remove window.deinit() calls (arena owns memory) - Add Layout.clone() for deep-copying layout trees across allocators - Add 3 new tests: clone leaf, clone split tree, shared arena preservation across layout changes
Summary
receivedListWindowsstoreswindows.items— a slice into a localstd.ArrayList(Window)— directly into the action list. The ArrayList is freed bydefer windows.deinit(self.alloc)on function return, leaving the action holding a dangling pointer.Currently latent: consumers only read
.len(inline in the fat pointer), not the pointed-to data. Becomes a live crash when anything iteratesaction.windows[i].Fix
Dupe the slice into the arena allocator so it shares the action list's lifetime:
Test
Added
"list-windows action data survives function return"— runs through the startup sequence and dereferencesaction.windows[0].id,.width,.heightin the check callback. Reads freed memory without the fix.AI Disclosure
Bug was identified and fix was authored with Claude Code assistance. I understand the root cause: the local ArrayList's
.itemsslice becomes invalid afterdeinit, but was stored into the action struct that outlives the function scope. The fix copies into the arena that backs all actions.