• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

apowers313 / aiforge / 22173138776

19 Feb 2026 07:50AM UTC coverage: 81.939% (+0.9%) from 81.026%
22173138776

push

github

apowers313
fix: status indicators when shell is deselected, new shell death bugs

2162 of 2510 branches covered (86.14%)

Branch coverage included in aggregate %.

50 of 175 new or added lines in 10 files covered. (28.57%)

214 existing lines in 10 files now uncovered.

10269 of 12661 relevant lines covered (81.11%)

27.36 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

79.75
/src/server/services/shell/ShellSessionManager.ts
1
/**
2
 * ShellSessionManager - Single source of truth for shell session state
3
 *
4
 * This manager orchestrates all shell session operations, including:
5
 * - Opening sessions (spawn/reconnect daemon, load scrollback)
6
 * - Closing sessions (kill daemon, update status)
7
 * - Querying session state
8
 * - Periodic reconciliation to fix state inconsistencies
9
 */
10
import { EventEmitter } from 'events';
1✔
11
import { access, constants } from 'node:fs/promises';
1✔
12
import type { Shell, SessionCloseReason } from '@shared/types/index.js';
13
import type { ShellStore } from '../../storage/stores/ShellStore.js';
14
import type { PtyPool, PtySessionLike } from '../pty/PtyPool.js';
15
import { PtyDaemonClient } from '../pty/PtyDaemonClient.js';
1✔
16
import { SessionError } from './SessionError.js';
1✔
17
import { getSocketPath } from '../pty/daemon/protocol.js';
1✔
18
import { logger } from '../../utils/logger.js';
1✔
19

20
/**
21
 * Default terminal dimensions
22
 */
23
const DEFAULT_COLS = 80;
1✔
24
const DEFAULT_ROWS = 24;
1✔
25

26
/**
27
 * Session state as returned by getSessionState
28
 */
29
export interface SessionState {
30
  status: 'open' | 'closed';
31
  shell: Shell;
32
}
33

34
/**
35
 * Result of opening a session
36
 */
37
export interface OpenSessionResult {
38
  shell: Shell;
39
  scrollback: string;
40
  cols: number;
41
  rows: number;
42
}
43

44
/**
45
 * Session opened event data
46
 */
47
export interface SessionOpenedEvent {
48
  shellId: string;
49
  shell: Shell;
50
  scrollback: string;
51
  cols: number;
52
  rows: number;
53
}
54

55
/**
56
 * Session closed event data
57
 */
58
export interface SessionClosedEvent {
59
  shellId: string;
60
  reason: SessionCloseReason;
61
}
62

63
/**
64
 * Options for creating ShellSessionManager
65
 */
66
export interface ShellSessionManagerOptions {
67
  ptyPool: PtyPool;
68
  shellStore: ShellStore;
69
}
70

71
/**
72
 * Check if a socket file exists
73
 */
74
async function socketExists(socketPath: string): Promise<boolean> {
1✔
75
  try {
1✔
76
    await access(socketPath, constants.F_OK);
1✔
77
    return true;
1✔
78
  } catch {
1!
79
    return false;
×
80
  }
×
81
}
1✔
82

83
/**
84
 * Check if a process with the given PID exists
85
 */
86
function processExists(pid: number): boolean {
2✔
87
  try {
2✔
88
    process.kill(pid, 0);
2✔
89
    return true;
2✔
90
  } catch {
2✔
91
    return false;
2✔
92
  }
2✔
93
}
2✔
94

95
/**
96
 * ShellSessionManager is the single source of truth for shell session state
97
 */
98
export class ShellSessionManager extends EventEmitter {
1✔
99
  private readonly _ptyPool: PtyPool;
49✔
100
  private readonly _shellStore: ShellStore;
49✔
101
  private _reconciliationInterval: ReturnType<typeof setInterval> | null = null;
49✔
102

103
  constructor(options: ShellSessionManagerOptions) {
49✔
104
    super();
49✔
105
    this._ptyPool = options.ptyPool;
49✔
106
    this._shellStore = options.shellStore;
49✔
107

108
    // Listen for session exit events from ptyPool
109
    this._ptyPool.on('session:exited', (shellId: string, _exitCode: number) => {
49✔
110
      void this._handleSessionExited(shellId);
52✔
111
    });
49✔
112

113
    // Forward PTY activity events for activity indicator broadcasting
114
    this._ptyPool.on('session:output', (shellId: string) => {
49✔
115
      this.emit('session:activity', shellId);
4✔
116
    });
49✔
117
    this._ptyPool.on('session:input', (shellId: string) => {
49✔
118
      this.emit('session:activity', shellId);
3✔
119
    });
49✔
120
  }
49✔
121

122
  /**
123
   * Handle session exit events from ptyPool
124
   */
125
  private async _handleSessionExited(shellId: string): Promise<void> {
49✔
126
    logger.debug({ shellId }, 'Session exited, updating status');
52✔
127

128
    await this._shellStore.update(shellId, {
52✔
129
      status: 'inactive',
52✔
130
      pid: null,
52✔
131
    });
52✔
132

133
    this.emit('session:closed', {
52✔
134
      shellId,
52✔
135
      reason: 'daemon_exited',
52✔
136
    } satisfies SessionClosedEvent);
52✔
137
  }
52✔
138

139
  /**
140
   * Open a session for a shell
141
   *
142
   * This is an atomic operation that:
143
   * 1. Verifies the shell exists
144
   * 2. Spawns/reconnects to daemon if needed
145
   * 3. Loads scrollback
146
   * 4. Updates shell status
147
   */
148
  async openSession(shellId: string): Promise<OpenSessionResult> {
49✔
149
    logger.debug({ shellId }, 'Opening session');
27✔
150

151
    // 1. Get the shell from store
152
    const shell = await this._shellStore.getById(shellId);
27✔
153
    if (!shell) {
27✔
154
      throw SessionError.shellNotFound(shellId);
3✔
155
    }
3✔
156

157
    // 2. Check if session is already open in the pool
158
    let session: PtySessionLike | undefined = this._ptyPool.get(shellId);
24✔
159

160
    // Verify daemon liveness -- a stale pool entry (daemon running but
161
    // unresponsive after rapid server restarts) would otherwise be trusted,
162
    // leaving the session permanently broken.
163
    if (session && session instanceof PtyDaemonClient) {
27!
NEW
164
      const alive = await session.isAlive(3000);
×
NEW
165
      if (!alive) {
×
NEW
166
        logger.warn({ shellId }, 'Daemon in pool is unresponsive, evicting stale session');
×
167
        // Evict (disconnect) rather than kill -- the daemon process may still
168
        // be alive but slow. _spawnOrReconnect will attempt to re-attach and
169
        // handles ECONNREFUSED (truly dead daemon) gracefully.
NEW
170
        this._ptyPool.evict(shellId);
×
NEW
171
        session = undefined;
×
NEW
172
      }
×
NEW
173
    }
✔
174

175
    if (!session) {
27✔
176
      // Try to spawn/reconnect
177
      try {
22✔
178
        session = await this._spawnOrReconnect(shell);
22✔
179
      } catch (err) {
22✔
180
        // Handle race condition: session may have been created by concurrent request
181
        session = this._ptyPool.get(shellId);
1✔
182
        if (!session) {
1!
183
          const message = err instanceof Error ? err.message : 'Unknown error';
×
184
          throw SessionError.daemonSpawnFailed(shellId, message);
×
185
        }
×
186
        // Session exists now (created by concurrent request), continue
187
      }
1✔
188
    }
22✔
189

190
    // 3. Load scrollback
191
    const scrollback = await this._loadScrollback(shellId);
24✔
192

193
    // 4. Update shell status to active
194
    const updatedShell = await this._shellStore.update(shellId, {
24✔
195
      status: 'active',
24✔
196
    });
24✔
197

198
    const result: OpenSessionResult = {
24✔
199
      shell: updatedShell ?? shell,
27!
200
      scrollback,
27✔
201
      cols: this._getSessionCols(session),
27✔
202
      rows: this._getSessionRows(session),
27✔
203
    };
27✔
204

205
    // Emit event
206
    this.emit('session:opened', {
27✔
207
      shellId,
27✔
208
      shell: result.shell,
27✔
209
      scrollback: result.scrollback,
27✔
210
      cols: result.cols,
27✔
211
      rows: result.rows,
27✔
212
    } satisfies SessionOpenedEvent);
27✔
213

214
    logger.debug({ shellId }, 'Session opened successfully');
27✔
215

216
    return result;
27✔
217
  }
27✔
218

219
  /**
220
   * Close a session for a shell
221
   */
222
  async closeSession(shellId: string, reason: SessionCloseReason = 'requested'): Promise<void> {
49✔
223
    logger.debug({ shellId, reason }, 'Closing session');
8✔
224

225
    // Kill the session if it exists
226
    this._ptyPool.kill(shellId);
8✔
227

228
    // Update shell status
229
    await this._shellStore.update(shellId, {
8✔
230
      status: 'inactive',
8✔
231
      pid: null,
8✔
232
    });
8✔
233

234
    // Emit event
235
    this.emit('session:closed', {
8✔
236
      shellId,
8✔
237
      reason,
8✔
238
    } satisfies SessionClosedEvent);
8✔
239

240
    logger.debug({ shellId }, 'Session closed');
8✔
241
  }
8✔
242

243
  /**
244
   * Get the current state of a session
245
   */
246
  async getSessionState(shellId: string): Promise<SessionState | null> {
49✔
247
    const shell = await this._shellStore.getById(shellId);
7✔
248
    if (!shell) {
7✔
249
      return null;
2✔
250
    }
2✔
251

252
    const session = this._ptyPool.get(shellId);
5✔
253
    const isOpen = session !== undefined && shell.status === 'active';
7✔
254

255
    return {
7✔
256
      status: isOpen ? 'open' : 'closed',
7✔
257
      shell,
7✔
258
    };
7✔
259
  }
7✔
260

261
  /**
262
   * Get a session from the pool
263
   */
264
  getSession(shellId: string): PtySessionLike | undefined {
49✔
265
    return this._ptyPool.get(shellId);
21✔
266
  }
21✔
267

268
  /**
269
   * Reconcile state between store, pool, and filesystem
270
   *
271
   * This fixes inconsistencies such as:
272
   * - Shell marked active but daemon is dead
273
   * - Orphaned daemon sockets with no corresponding shell
274
   * - Session in pool but not in store
275
   */
276
  async reconcile(): Promise<void> {
49✔
277
    logger.debug('Running reconciliation');
4✔
278

279
    const shells = await this._shellStore.getAll();
4✔
280

281
    for (const shell of shells) {
4✔
282
      if (shell.status !== 'active') {
3!
283
        continue;
×
284
      }
×
285

286
      const session = this._ptyPool.get(shell.id);
3✔
287

288
      if (session) {
3!
289
        // Session is in pool, all good
290
        continue;
×
291
      }
×
292

293
      // Shell is marked active but no session in pool
294
      // Check if we can reconnect to an existing daemon
295
      if (this._ptyPool.usePersistentDaemons && shell.socketPath) {
3✔
296
        const exists = await socketExists(shell.socketPath);
1✔
297
        if (exists) {
1✔
298
          // Try to reconnect
299
          try {
1✔
300
            await this._ptyPool.attach(shell.id, shell.cwd);
1✔
301
            logger.info({ shellId: shell.id }, 'Reconnected to daemon during reconciliation');
1✔
302
            continue;
1✔
303
          } catch (err) {
1!
304
            logger.warn({ shellId: shell.id, err }, 'Failed to reconnect to daemon');
×
305
          }
×
306
        }
1✔
307
      }
1✔
308

309
      // Check legacy PID mode
310
      if (shell.pid !== null && processExists(shell.pid)) {
3!
311
        // Process exists but not in pool - this shouldn't happen normally
312
        logger.warn({ shellId: shell.id, pid: shell.pid }, 'Shell has running process but not in pool');
×
313
        continue;
×
314
      }
✔
315

316
      // Mark shell as inactive
317
      logger.info({ shellId: shell.id }, 'Marking orphaned shell as inactive');
2✔
318
      await this._shellStore.update(shell.id, {
2✔
319
        status: 'inactive',
2✔
320
        pid: null,
2✔
321
        socketPath: null,
2✔
322
      });
2✔
323
    }
2✔
324

325
    // Clean up orphaned sockets (sockets with no corresponding shell)
326
    if (this._ptyPool.daemonManager) {
4✔
327
      const validShellIds = shells.map((s) => s.id);
1✔
328
      await this._ptyPool.daemonManager.cleanupOrphanedSockets(validShellIds);
1✔
329
    }
1✔
330

331
    logger.debug('Reconciliation complete');
4✔
332
  }
4✔
333

334
  /**
335
   * Start the periodic reconciliation loop
336
   */
337
  startReconciliationLoop(intervalMs: number): void {
49✔
338
    this.stopReconciliationLoop();
2✔
339

340
    this._reconciliationInterval = setInterval(() => {
2✔
341
      void this.reconcile();
5✔
342
    }, intervalMs);
2✔
343

344
    logger.info({ intervalMs }, 'Started reconciliation loop');
2✔
345
  }
2✔
346

347
  /**
348
   * Stop the periodic reconciliation loop
349
   */
350
  stopReconciliationLoop(): void {
49✔
351
    if (this._reconciliationInterval) {
45✔
352
      clearInterval(this._reconciliationInterval);
2✔
353
      this._reconciliationInterval = null;
2✔
354
      logger.debug('Stopped reconciliation loop');
2✔
355
    }
2✔
356
  }
45✔
357

358
  /**
359
   * Shutdown the manager
360
   */
361
  shutdown(): void {
49✔
362
    this.stopReconciliationLoop();
41✔
363
    this.removeAllListeners();
41✔
364
  }
41✔
365

366
  /**
367
   * Spawn or reconnect to a daemon for a shell
368
   */
369
  private async _spawnOrReconnect(shell: Shell): Promise<PtySessionLike> {
49✔
370
    // In daemon mode, try to attach first if socket exists
371
    if (this._ptyPool.usePersistentDaemons && shell.socketPath) {
22!
372
      const exists = await socketExists(shell.socketPath);
×
373
      if (exists) {
×
374
        try {
×
375
          const client = await this._ptyPool.attach(shell.id, shell.cwd);
×
376
          if (client) {
×
377
            return client;
×
378
          }
×
379
        } catch (attachErr) {
×
380
          // Check if this is a stale socket (ECONNREFUSED means no daemon listening)
381
          const isStaleSocket =
×
382
            attachErr instanceof Error &&
×
383
            'code' in attachErr &&
×
384
            (attachErr as NodeJS.ErrnoException).code === 'ECONNREFUSED';
×
385

386
          if (isStaleSocket) {
×
387
            logger.warn({ shellId: shell.id, socketPath: shell.socketPath },
×
388
              'Detected stale socket during attach, will spawn new daemon');
×
389
          } else {
×
390
            // Other error (timeout, permission, etc.) - propagate it
391
            throw attachErr;
×
392
          }
×
393
        }
×
394
      }
×
395
      // Socket was stale or didn't exist, clear it from shell record
396
      await this._shellStore.update(shell.id, { socketPath: null });
×
397
    }
×
398

399
    // Load scrollback before spawning (for replay on attach)
400
    await this._ptyPool.loadScrollback(shell.id);
22✔
401

402
    // Add separator to scrollback if shell has previous output
403
    const scrollbackStore = this._ptyPool.scrollbackStore;
22✔
404
    if (scrollbackStore) {
22✔
405
      const existingEntries = scrollbackStore.getFromMemory(shell.id);
22✔
406
      if (existingEntries.length > 0) {
22✔
407
        const timestamp = new Date().toLocaleString();
3✔
408
        scrollbackStore.append(shell.id, 'output', `\r\n\r\n--- shell restarted ${timestamp} ---\r\n\r\n`);
3✔
409
      }
3✔
410
    }
22✔
411

412
    // Spawn new session
413
    const session = await this._ptyPool.spawnAsync(shell.id, {
22✔
414
      cwd: shell.cwd,
22✔
415
    });
22✔
416

417
    // Update socket path if in daemon mode
418
    if (this._ptyPool.usePersistentDaemons) {
22!
419
      const socketPath = getSocketPath(shell.id);
×
420
      await this._shellStore.update(shell.id, { socketPath });
×
421
    }
✔
422

423
    return session;
21✔
424
  }
22✔
425

426
  /**
427
   * Load scrollback for a shell
428
   */
429
  private async _loadScrollback(shellId: string): Promise<string> {
49✔
430
    const scrollbackStore = this._ptyPool.scrollbackStore;
24✔
431
    if (!scrollbackStore) {
24!
432
      return '';
×
433
    }
×
434

435
    // Try memory first
436
    let entries = scrollbackStore.getFromMemory(shellId);
24✔
437
    if (entries.length === 0) {
24✔
438
      // Load from disk
439
      entries = await scrollbackStore.load(shellId);
21✔
440
    }
21✔
441

442
    // Concatenate output entries
443
    return entries
24✔
444
      .filter((e) => e.type === 'output')
24✔
445
      .map((e) => e.data)
24✔
446
      .join('');
24✔
447
  }
24✔
448

449
  /**
450
   * Get columns from a session (with fallback)
451
   */
452
  private _getSessionCols(session: PtySessionLike): number {
49✔
453
    if ('cols' in session && typeof session.cols === 'number') {
24✔
454
      return session.cols;
24✔
455
    }
24!
456
    return DEFAULT_COLS;
×
457
  }
24✔
458

459
  /**
460
   * Get rows from a session (with fallback)
461
   */
462
  private _getSessionRows(session: PtySessionLike): number {
49✔
463
    if ('rows' in session && typeof session.rows === 'number') {
24✔
464
      return session.rows;
24✔
465
    }
24!
466
    return DEFAULT_ROWS;
×
467
  }
24✔
468
}
49✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc