Skip to content

Commit 23e6e3b

Browse files
bra1nDumpclaudehappy-otter
committed
fix(cli): prevent zombie socket reconnections when MacBook lid is closed
When a MacBook lid closes, macOS powers down WiFi hardware even though caffeinate keeps the CPU alive. During Power Nap (~every 15 min), WiFi briefly activates for 1-3 seconds — just long enough for socket.io to auto-reconnect. The server sees these brief connections as "active" sessions, creating zombies that appear reachable but aren't. The fix: disable socket.io's built-in reconnection entirely and manage it ourselves. On disconnect, we check two conditions before reconnecting: 1. hasNetworkConnectivity() — os.networkInterfaces() has a non-internal IPv4 address (cross-platform, instant, no subprocess) 2. !isLidClosed() — ioreg AppleClamshellState is not Yes (macOS only, returns false on other platforms so reconnection proceeds normally) If either check fails, we poll every 5s until both pass. This was validated with a multi-probe test comparing four socket strategies side-by-side through multiple Power Nap cycles: - SOCK-BASELINE (socket.io auto): reconnected every Power Nap ✗ - SOCK-CAFF (with caffeinate): reconnected every Power Nap ✗ - SOCK-MANUAL (our fix): stayed disconnected through all Power Nap cycles, reconnected instantly when lid opened ✓ Additional changes: - Caffeinate only managed by daemon now (removed from individual sessions to prevent process leaks — was spawning per-session) - Orphaned caffeinate processes cleaned up on daemon startup - External display detection via system_profiler JSON for clamshell mode support (lid closed + monitor = stay connected) Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
1 parent 6b25656 commit 23e6e3b

7 files changed

Lines changed: 141 additions & 42 deletions

File tree

packages/happy-cli/src/api/apiMachine.ts

Lines changed: 37 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { backoff } from '@/utils/time';
1313
import { RpcHandlerManager } from './rpc/RpcHandlerManager';
1414
import { detectCLIAvailability, CLIAvailability } from '@/utils/detectCLI';
1515
import { detectResumeSupport, type ResumeSupport } from '@/resume/localHappyAgentAuth';
16+
import { shouldReconnect } from '@/utils/lidState';
1617

1718
interface ServerToDaemonEvents {
1819
update: (data: Update) => void;
@@ -85,6 +86,7 @@ export class ApiMachineClient {
8586
private lastKnownResumeSupport: ResumeSupport | null = null;
8687
private rpcHandlerManager: RpcHandlerManager;
8788
private resumeSessionHandler: ((sessionId: string) => Promise<SpawnSessionResult>) | null = null;
89+
private reconnectInterval: NodeJS.Timeout | null = null;
8890

8991
constructor(
9092
private token: string,
@@ -272,17 +274,17 @@ export class ApiMachineClient {
272274
machineId: this.machine.id
273275
},
274276
path: '/v1/updates',
275-
reconnection: true,
276-
reconnectionDelay: 1000,
277-
reconnectionDelayMax: 5000
277+
reconnection: false,
278278
});
279279

280280
this.socket.on('connect', () => {
281281
logger.debug('[API MACHINE] Connected to server');
282282

283-
// Update daemon state to running
284-
// We need to override previous state because the daemon (this process)
285-
// has restarted with new PID & port
283+
if (this.reconnectInterval) {
284+
clearInterval(this.reconnectInterval);
285+
this.reconnectInterval = null;
286+
}
287+
286288
this.updateDaemonState((state) => ({
287289
...state,
288290
status: 'running',
@@ -291,19 +293,16 @@ export class ApiMachineClient {
291293
startedAt: Date.now()
292294
}));
293295

294-
295-
// Register all handlers
296296
this.rpcHandlerManager.onSocketConnect(this.socket);
297297
this.syncResumeSessionRpcRegistration(detectResumeSupport().rpcAvailable);
298-
299-
// Start keep-alive
300298
this.startKeepAlive();
301299
});
302300

303-
this.socket.on('disconnect', () => {
304-
logger.debug('[API MACHINE] Disconnected from server');
301+
this.socket.on('disconnect', (reason) => {
302+
logger.debug(`[API MACHINE] Disconnected from server — reason: ${reason}`);
305303
this.rpcHandlerManager.onSocketDisconnect();
306304
this.stopKeepAlive();
305+
this.startSmartReconnect();
307306
});
308307

309308
// Single consolidated RPC handler
@@ -383,6 +382,28 @@ export class ApiMachineClient {
383382
logger.debug('[API MACHINE] Keep-alive started (20s interval)');
384383
}
385384

385+
private startSmartReconnect() {
386+
if (this.reconnectInterval) return;
387+
388+
if (shouldReconnect()) {
389+
logger.debug('[API MACHINE] Network up + lid open — reconnecting in 1s');
390+
setTimeout(() => { if (!this.socket.connected) this.socket.connect() }, 1000);
391+
return;
392+
}
393+
394+
logger.debug('[API MACHINE] Conditions not met for reconnect — polling every 5s');
395+
this.reconnectInterval = setInterval(() => {
396+
if (!shouldReconnect()) {
397+
logger.debug('[API MACHINE] Still not ready to reconnect');
398+
return;
399+
}
400+
logger.debug('[API MACHINE] Conditions met — reconnecting');
401+
clearInterval(this.reconnectInterval!);
402+
this.reconnectInterval = null;
403+
this.socket.connect();
404+
}, 5000);
405+
}
406+
386407
private stopKeepAlive() {
387408
if (this.keepAliveInterval) {
388409
clearInterval(this.keepAliveInterval);
@@ -394,6 +415,10 @@ export class ApiMachineClient {
394415
shutdown() {
395416
logger.debug('[API MACHINE] Shutting down');
396417
this.stopKeepAlive();
418+
if (this.reconnectInterval) {
419+
clearInterval(this.reconnectInterval);
420+
this.reconnectInterval = null;
421+
}
397422
if (this.socket) {
398423
this.socket.close();
399424
logger.debug('[API MACHINE] Socket closed');

packages/happy-cli/src/api/apiSession.ts

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { AsyncLock } from '@/utils/lock';
1111
import { RpcHandlerManager } from './rpc/RpcHandlerManager';
1212
import { registerCommonHandlers } from '../modules/common/registerCommonHandlers';
1313
import { calculateCost } from '@/utils/pricing';
14+
import { shouldReconnect } from '@/utils/lidState';
1415
import { type SessionEnvelope, type SessionTurnEndStatus } from '@slopus/happy-wire';
1516
import {
1617
closeClaudeTurnWithStatus,
@@ -86,6 +87,7 @@ export class ApiSessionClient extends EventEmitter {
8687
private metadataLock = new AsyncLock();
8788
private encryptionKey: Uint8Array;
8889
private encryptionVariant: 'legacy' | 'dataKey';
90+
private reconnectInterval: NodeJS.Timeout | null = null;
8991
private claudeSessionProtocolState: ClaudeSessionProtocolState = {
9092
currentTurnId: null,
9193
uuidToProviderSubagent: new Map<string, string>(),
@@ -135,10 +137,7 @@ export class ApiSessionClient extends EventEmitter {
135137
sessionId: this.sessionId
136138
},
137139
path: '/v1/updates',
138-
reconnection: true,
139-
reconnectionAttempts: Infinity,
140-
reconnectionDelay: 1000,
141-
reconnectionDelayMax: 5000,
140+
reconnection: false,
142141
transports: ['websocket'],
143142
withCredentials: true,
144143
autoConnect: false
@@ -150,6 +149,10 @@ export class ApiSessionClient extends EventEmitter {
150149

151150
this.socket.on('connect', () => {
152151
logger.debug('Socket connected successfully');
152+
if (this.reconnectInterval) {
153+
clearInterval(this.reconnectInterval);
154+
this.reconnectInterval = null;
155+
}
153156
this.rpcHandlerManager.onSocketConnect(this.socket);
154157
this.receiveSync.invalidate();
155158
})
@@ -160,8 +163,9 @@ export class ApiSessionClient extends EventEmitter {
160163
})
161164

162165
this.socket.on('disconnect', (reason) => {
163-
logger.debug('[API] Socket disconnected:', reason);
166+
logger.debug(`[API] Socket disconnected: ${reason}`);
164167
this.rpcHandlerManager.onSocketDisconnect();
168+
this.startSmartReconnect();
165169
})
166170

167171
this.socket.on('connect_error', (error) => {
@@ -616,6 +620,32 @@ export class ApiSessionClient extends EventEmitter {
616620
logger.debug('[API] socket.close() called');
617621
this.sendSync.stop();
618622
this.receiveSync.stop();
623+
if (this.reconnectInterval) {
624+
clearInterval(this.reconnectInterval);
625+
this.reconnectInterval = null;
626+
}
619627
this.socket.close();
620628
}
629+
630+
private startSmartReconnect() {
631+
if (this.reconnectInterval) return;
632+
633+
if (shouldReconnect()) {
634+
logger.debug('[API] Network up + lid open — reconnecting in 1s');
635+
setTimeout(() => { if (!this.socket.connected) this.socket.connect() }, 1000);
636+
return;
637+
}
638+
639+
logger.debug('[API] Conditions not met for reconnect — polling every 5s');
640+
this.reconnectInterval = setInterval(() => {
641+
if (!shouldReconnect()) {
642+
logger.debug('[API] Still not ready to reconnect');
643+
return;
644+
}
645+
logger.debug('[API] Conditions met — reconnecting');
646+
clearInterval(this.reconnectInterval!);
647+
this.reconnectInterval = null;
648+
this.socket.connect();
649+
}, 5000);
650+
}
621651
}

packages/happy-cli/src/claude/runClaude.ts

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import { Credentials, readSettings } from '@/persistence';
1010
import { EnhancedMode, PermissionMode } from './loop';
1111
import { MessageQueue2 } from '@/utils/MessageQueue2';
1212
import { hashObject } from '@/utils/deterministicJson';
13-
import { startCaffeinate, stopCaffeinate } from '@/utils/caffeinate';
1413
import { parseSpecialCommand } from '@/parsers/specialCommands';
1514
import { getEnvironmentInfo } from '@/ui/doctor';
1615
import { configuration } from '@/configuration';
@@ -157,7 +156,6 @@ export async function runClaude(credentials: Credentials, options: StartOptions
157156
});
158157
} finally {
159158
reconnection.cancel();
160-
stopCaffeinate();
161159
}
162160
process.exit(0);
163161
}
@@ -223,12 +221,6 @@ export async function runClaude(credentials: Credentials, options: StartOptions
223221
controlledByUser: options.startingMode !== 'remote'
224222
}));
225223

226-
// Start caffeinate to prevent sleep on macOS
227-
const caffeinateStarted = startCaffeinate();
228-
if (caffeinateStarted) {
229-
logger.infoDeveloper('Sleep prevention enabled (macOS)');
230-
}
231-
232224
// Import MessageQueue2 and create message queue
233225
const messageQueue = new MessageQueue2<EnhancedMode>(mode => hashObject({
234226
isPlan: mode.permissionMode === 'plan',
@@ -400,9 +392,6 @@ export async function runClaude(credentials: Credentials, options: StartOptions
400392
await session.close();
401393
}
402394

403-
// Stop caffeinate
404-
stopCaffeinate();
405-
406395
// Stop Happy MCP server
407396
happyServer.stop();
408397

@@ -484,10 +473,6 @@ export async function runClaude(credentials: Credentials, options: StartOptions
484473
logger.debug('Closing session...');
485474
await session.close();
486475

487-
// Stop caffeinate before exiting
488-
stopCaffeinate();
489-
logger.debug('Stopped sleep prevention');
490-
491476
// Stop Happy MCP server
492477
happyServer.stop();
493478
logger.debug('Stopped Happy MCP server');

packages/happy-cli/src/codex/runCodex.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ import { trimIdent } from "@/utils/trimIdent";
2424
import { CHANGE_TITLE_INSTRUCTION } from '@/gemini/constants';
2525
import { notifyDaemonSessionStarted } from "@/daemon/controlClient";
2626
import { registerKillSessionHandler } from "@/claude/registerKillSessionHandler";
27-
import { stopCaffeinate } from "@/utils/caffeinate";
2827
import { connectionState } from '@/utils/serverConnectionErrors';
2928
import { setupOfflineReconnection } from '@/utils/setupOfflineReconnection';
3029
import type { ApiSessionClient } from '@/api/apiSession';
@@ -330,9 +329,6 @@ export async function runCodex(opts: {
330329
logger.debug('[Codex] Error disconnecting Codex during termination', e);
331330
}
332331

333-
// Stop caffeinate
334-
stopCaffeinate();
335-
336332
// Stop Happy MCP server
337333
happyServer.stop();
338334

packages/happy-cli/src/gemini/runGemini.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@ import { startHappyServer } from '@/claude/utils/startHappyServer';
2626
import { MessageBuffer } from '@/ui/ink/messageBuffer';
2727
import { notifyDaemonSessionStarted } from '@/daemon/controlClient';
2828
import { registerKillSessionHandler } from '@/claude/registerKillSessionHandler';
29-
import { stopCaffeinate } from '@/utils/caffeinate';
3029
import { connectionState } from '@/utils/serverConnectionErrors';
3130
import { setupOfflineReconnection } from '@/utils/setupOfflineReconnection';
3231
import type { ApiSessionClient } from '@/api/apiSession';
@@ -390,7 +389,6 @@ export async function runGemini(opts: {
390389
await session.close();
391390
}
392391

393-
stopCaffeinate();
394392
happyServer.stop();
395393

396394
if (geminiBackend) {

packages/happy-cli/src/utils/caffeinate.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
* Uses the built-in macOS caffeinate command to keep the system awake
44
*/
55

6-
import { spawn, ChildProcess } from 'child_process'
6+
import { spawn, execSync, ChildProcess } from 'child_process'
77
import { logger } from '@/ui/logger'
88
import { configuration } from '@/configuration'
99

@@ -34,10 +34,10 @@ export function startCaffeinate(): boolean {
3434
return true
3535
}
3636

37+
// Kill any orphaned caffeinate -im processes from previous daemon instances
38+
killOrphanedCaffeinateProcesses()
39+
3740
try {
38-
// Spawn caffeinate with flags:
39-
// -i: Prevent system from idle sleeping
40-
// -m: Prevent disk from sleeping
4141
caffeinateProcess = spawn('caffeinate', ['-im'], {
4242
stdio: 'ignore',
4343
detached: false
@@ -137,4 +137,14 @@ function setupCleanupHandlers(): void {
137137
logger.debug('[caffeinate] Unhandled rejection, cleaning up:', reason)
138138
cleanup()
139139
})
140+
}
141+
142+
export function killOrphanedCaffeinateProcesses(): void {
143+
if (process.platform !== 'darwin') return
144+
try {
145+
execSync('pkill -f "caffeinate -im"', { timeout: 5000, stdio: 'ignore' })
146+
logger.debug('[caffeinate] Killed orphaned caffeinate processes')
147+
} catch {
148+
// pkill exits with 1 if no processes matched — expected
149+
}
140150
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import os from 'os'
2+
import { execSync } from 'child_process'
3+
4+
export function hasNetworkConnectivity(): boolean {
5+
const interfaces = os.networkInterfaces()
6+
for (const name of Object.keys(interfaces)) {
7+
for (const iface of interfaces[name] || []) {
8+
if (!iface.internal && iface.family === 'IPv4') return true
9+
}
10+
}
11+
return false
12+
}
13+
14+
export function isLidClosed(): boolean {
15+
if (process.platform !== 'darwin') return false
16+
try {
17+
const output = execSync('ioreg -r -k AppleClamshellState -d 4', {
18+
timeout: 5000,
19+
encoding: 'utf-8',
20+
})
21+
return output.includes('"AppleClamshellState" = Yes')
22+
} catch {
23+
return false
24+
}
25+
}
26+
27+
export function hasExternalDisplay(): boolean {
28+
if (process.platform !== 'darwin') return false
29+
try {
30+
const output = execSync('system_profiler SPDisplaysDataType -json 2>/dev/null', {
31+
timeout: 10000,
32+
encoding: 'utf-8',
33+
})
34+
const data = JSON.parse(output)
35+
const gpus: any[] = data.SPDisplaysDataType || []
36+
for (const gpu of gpus) {
37+
const displays: any[] = gpu.spdisplays_ndrvs || []
38+
for (const display of displays) {
39+
const isBuiltIn =
40+
display.spdisplays_builtin === 'spdisplays_yes' ||
41+
display.spdisplays_connection_type === 'spdisplays_internal'
42+
if (!isBuiltIn) return true
43+
}
44+
}
45+
return false
46+
} catch {
47+
return false
48+
}
49+
}
50+
51+
export function shouldReconnect(): boolean {
52+
if (!hasNetworkConnectivity()) return false
53+
if (isLidClosed() && !hasExternalDisplay()) return false
54+
return true
55+
}

0 commit comments

Comments
 (0)