9.9 KiB
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:
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.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 toSyncingstate.
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:
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:
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
class SocketClient {
private heartbeatInterval: ReturnType<typeof setInterval> | null = null;
releaseSessionLock(registration, project, chatSession): Promise<boolean>;
startSessionHeartbeat(): void;
stopSessionHeartbeat(): void;
}
releaseSessionLock()wrapssocket.emit("releaseSessionLock", ...)in a Promise.startSessionHeartbeat()starts asetIntervalat 19 seconds that emitssessionHeartbeatwith an ack callback.stopSessionHeartbeat()clears the interval.disconnect()automatically callsstopSessionHeartbeat().
4. ChatSessionView — gadget-code/frontend/src/pages/ChatSessionView.tsx
- On mount after
sessionandprojectare loaded: starts heartbeat. - On unmount: stops heartbeat, then sends
releaseSessionLockusing the drone registration fromlocalStorage(dtp_drone_registration). - Uses
sessionRef/projectRefto capture latest state for the unmount closure.
5. Backend CodeSession — gadget-code/src/lib/code-session.ts
onReleaseSessionLock(registration, project, chatSession, cb):
- Looks up
DroneSessionviaSocketService.getDroneSession(registration). - Forwards
releaseSessionLockto the drone socket. - On success callback: calls
SocketService.unregisterChatSession(), clearsdroneSession.chatSessionId, clears localselectedDrone,chatSession,project. - Calls
cb(success).
onSessionHeartbeat(cb):
- Guards
this.selectedDrone— returnscb(false)if not set. - Looks up
DroneSessionviaSocketService.getDroneSession(). - Forwards heartbeat to drone socket with the ack callback.
6. Backend Disconnect — gadget-code/src/services/socket.ts
When a CodeSession disconnects:
- Retrieve the
CodeSessionfromcodeSessionsbefore deleting (fixes an existing bug where the session was read after deletion). - Call
disconnectingCodeSession.selectedDroneIdgetter to check if a drone was selected. - If yes, look up the
DroneSessionindroneRegistrationIndex. - Emit
releaseSessionLockto the drone (fire-and-forget, no callback needed since the socket is already going away). - Clean up
codeSessionUserIndexandchatSessionIndex. - Delete from
codeSessionsmap.
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:
private heartbeatTimer: ReturnType<typeof setTimeout> | null = null;
onReleaseSessionLock(registration, project, chatSession, cb):
- Validates registration (must match self).
- If no lock held:
cb(true)— nothing to do. - If lock held by different session: logs warning but still releases (caller knows what it's doing).
- Clears
sessionLock, setsworkspaceMode = Syncing. - Emits
"session lock released"status. cb(true).
onSessionHeartbeat(cb):
- Clears existing
heartbeatTimerif set. - Sets new 60-second
heartbeatTimerthat: clearssessionLock, setsworkspaceMode = Syncing, emits status about heartbeat timeout. - Guards
isShuttingDownin the timeout handler. 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
releaseSessionLockmessage defined inide.ts, registered insocket.tssessionHeartbeatmessage defined inide.ts, registered insocket.ts- Frontend
SocketClienthasreleaseSessionLock(),startSessionHeartbeat(),stopSessionHeartbeat() ChatSessionViewstarts heartbeat on load, stops + releases on unmountCodeSessionregisters and handles both messagesSocketService.onSocketDisconnectsendsreleaseSessionLockwhen a code session drops- Existing bug in disconnect handler (reading session after delete) is fixed
GadgetDroneregisters and handles both messages- Drone clears
sessionLockand resets toSyncingon release or heartbeat timeout - Heartbeat timer is cleaned up during
stop() - All packages build without errors