Files
opencode/specs/12-terminal-unmount-race-cleanup.md
2026-01-27 15:25:07 -06:00

93 lines
2.7 KiB
Markdown

## Terminal unmount race cleanup
Prevent Ghostty Terminal/WebSocket leaks when unmounting mid-init
---
### Summary
`packages/app/src/components/terminal.tsx` initializes Ghostty in `onMount` via async steps (`import("ghostty-web")`, `Ghostty.load()`, WebSocket creation, terminal creation, listeners). If the component unmounts while awaits are pending, `onCleanup` runs before `ws`/`term` exist. The async init can then continue and create resources that never get disposed.
This spec makes initialization abortable and ensures resources created after unmount are immediately cleaned up.
---
### Scoped files (parallel-safe)
- `packages/app/src/components/terminal.tsx`
---
### Goals
- Never leave a WebSocket open after the terminal component unmounts
- Never leave window/container/textarea event listeners attached after unmount
- Avoid creating terminal resources if `disposed` is already true
---
### Non-goals
- Reworking terminal buffering/persistence format
- Changing PTY server protocol
---
### Current state
- `disposed` is checked in some WebSocket event handlers, but not during async init.
- `onCleanup` closes/disposes only the resources already assigned at cleanup time.
---
### Proposed approach
1. Guard async init steps
- After each `await`, check `disposed` and return early.
2. Register cleanups as resources are created
- Maintain an array of cleanup callbacks (`cleanups: VoidFunction[]`).
- When creating `socket`, `term`, adding event listeners, etc., push the corresponding cleanup.
- In `onCleanup`, run all registered cleanups exactly once.
3. Avoid mutating shared vars until safe
- Prefer local variables inside `run()` and assign to outer `ws`/`term` only after confirming not disposed.
---
### Implementation steps
1. Add `const cleanups: VoidFunction[] = []` and `const cleanup = () => { ... }` in component scope
2. In `onCleanup`, set `disposed = true` and call `cleanup()`
3. In `run()`:
- `await import(...)` -> if disposed return
- `await Ghostty.load()` -> if disposed return
- create WebSocket -> if disposed, close it and return
- create Terminal -> if disposed, dispose + close socket and return
- when adding listeners, register removers in `cleanups`
4. Ensure `cleanup()` is idempotent
---
### Acceptance criteria
- Rapidly mounting/unmounting terminal components does not leave open WebSockets
- No `resize` listeners remain after unmount
- No errors are thrown if unmount occurs mid-initialization
---
### Validation plan
- Manual:
- Open a session and rapidly switch sessions/tabs to force terminal unmount/mount
- Verify via devtools that no orphan WebSocket connections remain
- Verify that terminal continues to work normally when kept mounted