# Session Heartbeat & Lock Release ## Problem A drone could be left permanently locked if the IDE disconnects or navigates away without explicitly releasing the session lock. Once locked, the drone rejects all new lock requests until it is restarted. Two mechanisms solve this: 1. **`releaseSessionLock`** — An explicit message to unlock a drone from a chat session. Sent deliberately by the IDE on view cleanup, and as a fallback by the backend on socket disconnect. 2. **`sessionHeartbeat`** — A periodic keepalive from IDE → drone. The drone starts a 60-second timer on each heartbeat. If no heartbeat arrives within 60 seconds, the drone automatically releases its lock and returns to `Syncing` state. --- ## Protocol Two new messages, both flowing `IDE → Web → Drone`: ``` ┌──────────────┐ releaseSessionLock ┌──────────────┐ releaseSessionLock ┌──────────────┐ │ │ ────────────────────────► │ │ ────────────────────────► │ │ │ IDE │ sessionHeartbeat │ Web │ sessionHeartbeat │ Drone │ │ (Browser) │ ────────────────────────► │ (Backend) │ ────────────────────────► │ (Worker) │ │ │ ◄──────────────────────── │ │ ◄──────────────────────── │ │ │ │ cb(ack) │ │ cb(ack) │ │ └──────────────┘ └──────────────┘ └──────────────┘ ``` ### `releaseSessionLock` | Direction | Type | Purpose | |-----------|------|---------| | IDE → Web | `ClientToServerEvents.releaseSessionLock` | IDE releases a held lock | | Web → Drone | `ServerToClientEvents.releaseSessionLock` | Web forwards to drone | **Signature:** ```typescript type ReleaseSessionLockMessage = ( registration: IDroneRegistration, project: IProject, chatSession: IChatSession, cb: (success: boolean) => void, ) => void; ``` The callback is simpler than `requestSessionLock` — just a boolean success, no payload needed. ### `sessionHeartbeat` | Direction | Type | Purpose | |-----------|------|---------| | IDE → Web | `ClientToServerEvents.sessionHeartbeat` | Periodic keepalive | | Web → Drone | `ServerToClientEvents.sessionHeartbeat` | Forwarded to drone | **Signature:** ```typescript type SessionHeartbeatMessage = (cb: (ack: boolean) => void) => void; ``` --- ## Implementation Layer by Layer ### 1. Shared Types — `packages/api/src/messages/ide.ts` Defines `ReleaseSessionLockCallback`, `ReleaseSessionLockMessage`, `SessionHeartbeatCallback`, and `SessionHeartbeatMessage`. ### 2. Socket Event Maps — `packages/api/src/messages/socket.ts` Both messages are registered in `ClientToServerEvents` (IDE → Web) and `ServerToClientEvents` (Web → Drone). ### 3. Frontend Socket Client — `gadget-code/frontend/src/lib/socket.ts` ```typescript class SocketClient { private heartbeatInterval: ReturnType | null = null; releaseSessionLock(registration, project, chatSession): Promise; startSessionHeartbeat(): void; stopSessionHeartbeat(): void; } ``` - `releaseSessionLock()` wraps `socket.emit("releaseSessionLock", ...)` in a Promise. - `startSessionHeartbeat()` starts a `setInterval` at 19 seconds that emits `sessionHeartbeat` with an ack callback. - `stopSessionHeartbeat()` clears the interval. - `disconnect()` automatically calls `stopSessionHeartbeat()`. ### 4. ChatSessionView — `gadget-code/frontend/src/pages/ChatSessionView.tsx` - On mount after `session` and `project` are loaded: starts heartbeat. - On unmount: stops heartbeat, then sends `releaseSessionLock` using the drone registration from `localStorage` (`dtp_drone_registration`). - Uses `sessionRef` / `projectRef` to capture latest state for the unmount closure. ### 5. Backend CodeSession — `gadget-code/src/lib/code-session.ts` **`onReleaseSessionLock(registration, project, chatSession, cb)`:** 1. Looks up `DroneSession` via `SocketService.getDroneSession(registration)`. 2. Forwards `releaseSessionLock` to the drone socket. 3. On success callback: calls `SocketService.unregisterChatSession()`, clears `droneSession.chatSessionId`, clears local `selectedDrone`, `chatSession`, `project`. 4. Calls `cb(success)`. **`onSessionHeartbeat(cb)`:** 1. Guards `this.selectedDrone` — returns `cb(false)` if not set. 2. Looks up `DroneSession` via `SocketService.getDroneSession()`. 3. Forwards heartbeat to drone socket with the ack callback. ### 6. Backend Disconnect — `gadget-code/src/services/socket.ts` When a `CodeSession` disconnects: 1. Retrieve the `CodeSession` from `codeSessions` **before** deleting (fixes an existing bug where the session was read after deletion). 2. Call `disconnectingCodeSession.selectedDroneId` getter to check if a drone was selected. 3. If yes, look up the `DroneSession` in `droneRegistrationIndex`. 4. Emit `releaseSessionLock` to the drone (fire-and-forget, no callback needed since the socket is already going away). 5. Clean up `codeSessionUserIndex` and `chatSessionIndex`. 6. Delete from `codeSessions` map. This is a safety net for cases where the IDE closes without sending a deliberate release (browser crash, tab close, network failure). ### 7. Drone — `gadget-drone/src/gadget-drone.ts` **State:** ```typescript private heartbeatTimer: ReturnType | null = null; ``` **`onReleaseSessionLock(registration, project, chatSession, cb)`:** 1. Validates registration (must match self). 2. If no lock held: `cb(true)` — nothing to do. 3. If lock held by different session: logs warning but still releases (caller knows what it's doing). 4. Clears `sessionLock`, sets `workspaceMode = Syncing`. 5. Emits `"session lock released"` status. 6. `cb(true)`. **`onSessionHeartbeat(cb)`:** 1. Clears existing `heartbeatTimer` if set. 2. Sets new 60-second `heartbeatTimer` that: clears `sessionLock`, sets `workspaceMode = Syncing`, emits status about heartbeat timeout. 3. Guards `isShuttingDown` in the timeout handler. 4. `cb(true)` (immediately acknowledges). **Shutdown:** Heartbeat timer is cleared in `stop()` so it doesn't fire during graceful shutdown. --- ## Timing | Parameter | Value | Rationale | |-----------|-------|-----------| | Heartbeat interval | 19 seconds | ~3 heartbeats per minute, stays well within timeout | | Heartbeat timeout | 60 seconds | Tolerates 2 missed heartbeats + network jitter | --- ## Edge Cases | Scenario | Behavior | |----------|----------| | User navigates from ChatSession to Project Manager | `releaseSessionLock` sent on unmount, heartbeat stopped | | User closes browser tab | Socket disconnect fires backend-initiated `releaseSessionLock` | | User closes browser entirely | Socket disconnect fires backend-initiated `releaseSessionLock` | | Network drops, socket reconnects | Heartbeat resumes normally, drone timer resets each heartbeat | | Network drops for >60 seconds | Drone auto-releases lock, IDE detects socket disconnect | | Backend process restarts | Drone detects socket disconnect (reconnection), eventually heartbeat timeout fires | | Drone crashes | IDE heartbeat callbacks stop firing → IDE detects socket disconnect | | Multiple rapid session switches | Cleanup fires per-session, old lock released before new one acquired | | No lock held, release requested | All handlers return `cb(true)` — successful no-op | | Wrong session tries to release | Drone logs warning but still releases (disconnect path may not carry full session context) | | Heartbeat arrives with no lock | Drone resets timer anyway — harmless | | Deliberate release + disconnect race | Both paths emit `releaseSessionLock` — duplicate is handled gracefully (second release finds no lock, returns `true`) | --- ## Always Release Held Locks Every code path that acquires a `sessionLock` must also release it: | Lock acquired | Must release here | Mechanism | |---------------|-------------------|-----------| | `ProjectManager.tsx` creates session + locks drone | `ChatSessionView` unmounts | `releaseSessionLock` in cleanup effect | | `ProjectManager.tsx` opens existing session | `ChatSessionView` unmounts | `releaseSessionLock` in cleanup effect | | Backend re-lock on socket reconnect | Backend disconnect handler | `releaseSessionLock` in `SocketService.onSocketDisconnect` | | Any path (heartbeat fails) | Drone auto-release | 60-second `heartbeatTimer` timeout | **Rule:** If you add a new code path that calls `requestSessionLock`, you must also ensure a corresponding `releaseSessionLock` path exists. The heartbeat timeout is the last resort — never rely on it as the primary release mechanism. --- ## Verification Checklist - [ ] `releaseSessionLock` message defined in `ide.ts`, registered in `socket.ts` - [ ] `sessionHeartbeat` message defined in `ide.ts`, registered in `socket.ts` - [ ] Frontend `SocketClient` has `releaseSessionLock()`, `startSessionHeartbeat()`, `stopSessionHeartbeat()` - [ ] `ChatSessionView` starts heartbeat on load, stops + releases on unmount - [ ] `CodeSession` registers and handles both messages - [ ] `SocketService.onSocketDisconnect` sends `releaseSessionLock` when a code session drops - [ ] Existing bug in disconnect handler (reading session after delete) is fixed - [ ] `GadgetDrone` registers and handles both messages - [ ] Drone clears `sessionLock` and resets to `Syncing` on release or heartbeat timeout - [ ] Heartbeat timer is cleaned up during `stop()` - [ ] All packages build without errors