• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In
Build has been canceled!

apowers313 / aiforge / 21570337701

01 Feb 2026 09:11PM UTC coverage: 81.026% (-2.9%) from 83.954%
21570337701

push

github

apowers313
test: increase coverage to 80%+

2049 of 2382 branches covered (86.02%)

Branch coverage included in aggregate %.

1849 of 2529 new or added lines in 25 files covered. (73.11%)

681 existing lines in 21 files now uncovered.

9861 of 12317 relevant lines covered (80.06%)

26.33 hits per line

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

81.08
/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 { SessionError } from './SessionError.js';
1✔
16
import { getSocketPath } from '../pty/daemon/protocol.js';
1✔
17
import { logger } from '../../utils/logger.js';
1✔
18

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

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

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

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

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

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

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

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

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

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

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

113
  /**
114
   * Handle session exit events from ptyPool
115
   */
116
  private async _handleSessionExited(shellId: string): Promise<void> {
49✔
117
    logger.debug({ shellId }, 'Session exited, updating status');
52✔
118

119
    await this._shellStore.update(shellId, {
52✔
120
      status: 'inactive',
52✔
121
      pid: null,
52✔
122
    });
52✔
123

124
    this.emit('session:closed', {
52✔
125
      shellId,
52✔
126
      reason: 'daemon_exited',
52✔
127
    } satisfies SessionClosedEvent);
52✔
128
  }
52✔
129

130
  /**
131
   * Open a session for a shell
132
   *
133
   * This is an atomic operation that:
134
   * 1. Verifies the shell exists
135
   * 2. Spawns/reconnects to daemon if needed
136
   * 3. Loads scrollback
137
   * 4. Updates shell status
138
   */
139
  async openSession(shellId: string): Promise<OpenSessionResult> {
49✔
140
    logger.debug({ shellId }, 'Opening session');
27✔
141

142
    // 1. Get the shell from store
143
    const shell = await this._shellStore.getById(shellId);
27✔
144
    if (!shell) {
27✔
145
      throw SessionError.shellNotFound(shellId);
3✔
146
    }
3✔
147

148
    // 2. Check if session is already open in the pool
149
    let session = this._ptyPool.get(shellId);
24✔
150

151
    if (!session) {
27✔
152
      // Try to spawn/reconnect
153
      try {
22✔
154
        session = await this._spawnOrReconnect(shell);
22✔
155
      } catch (err) {
22✔
156
        // Handle race condition: session may have been created by concurrent request
157
        session = this._ptyPool.get(shellId);
1✔
158
        if (!session) {
1!
159
          const message = err instanceof Error ? err.message : 'Unknown error';
×
160
          throw SessionError.daemonSpawnFailed(shellId, message);
×
161
        }
×
162
        // Session exists now (created by concurrent request), continue
163
      }
1✔
164
    }
22✔
165

166
    // 3. Load scrollback
167
    const scrollback = await this._loadScrollback(shellId);
24✔
168

169
    // 4. Update shell status to active
170
    const updatedShell = await this._shellStore.update(shellId, {
24✔
171
      status: 'active',
24✔
172
    });
24✔
173

174
    const result: OpenSessionResult = {
24✔
175
      shell: updatedShell ?? shell,
27!
176
      scrollback,
27✔
177
      cols: this._getSessionCols(session),
27✔
178
      rows: this._getSessionRows(session),
27✔
179
    };
27✔
180

181
    // Emit event
182
    this.emit('session:opened', {
27✔
183
      shellId,
27✔
184
      shell: result.shell,
27✔
185
      scrollback: result.scrollback,
27✔
186
      cols: result.cols,
27✔
187
      rows: result.rows,
27✔
188
    } satisfies SessionOpenedEvent);
27✔
189

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

192
    return result;
27✔
193
  }
27✔
194

195
  /**
196
   * Close a session for a shell
197
   */
198
  async closeSession(shellId: string, reason: SessionCloseReason = 'requested'): Promise<void> {
49✔
199
    logger.debug({ shellId, reason }, 'Closing session');
8✔
200

201
    // Kill the session if it exists
202
    this._ptyPool.kill(shellId);
8✔
203

204
    // Update shell status
205
    await this._shellStore.update(shellId, {
8✔
206
      status: 'inactive',
8✔
207
      pid: null,
8✔
208
    });
8✔
209

210
    // Emit event
211
    this.emit('session:closed', {
8✔
212
      shellId,
8✔
213
      reason,
8✔
214
    } satisfies SessionClosedEvent);
8✔
215

216
    logger.debug({ shellId }, 'Session closed');
8✔
217
  }
8✔
218

219
  /**
220
   * Get the current state of a session
221
   */
222
  async getSessionState(shellId: string): Promise<SessionState | null> {
49✔
223
    const shell = await this._shellStore.getById(shellId);
7✔
224
    if (!shell) {
7✔
225
      return null;
2✔
226
    }
2✔
227

228
    const session = this._ptyPool.get(shellId);
5✔
229
    const isOpen = session !== undefined && shell.status === 'active';
7✔
230

231
    return {
7✔
232
      status: isOpen ? 'open' : 'closed',
7✔
233
      shell,
7✔
234
    };
7✔
235
  }
7✔
236

237
  /**
238
   * Get a session from the pool
239
   */
240
  getSession(shellId: string): PtySessionLike | undefined {
49✔
241
    return this._ptyPool.get(shellId);
21✔
242
  }
21✔
243

244
  /**
245
   * Reconcile state between store, pool, and filesystem
246
   *
247
   * This fixes inconsistencies such as:
248
   * - Shell marked active but daemon is dead
249
   * - Orphaned daemon sockets with no corresponding shell
250
   * - Session in pool but not in store
251
   */
252
  async reconcile(): Promise<void> {
49✔
253
    logger.debug('Running reconciliation');
4✔
254

255
    const shells = await this._shellStore.getAll();
4✔
256

257
    for (const shell of shells) {
4✔
258
      if (shell.status !== 'active') {
3!
259
        continue;
×
260
      }
×
261

262
      const session = this._ptyPool.get(shell.id);
3✔
263

264
      if (session) {
3!
265
        // Session is in pool, all good
266
        continue;
×
267
      }
×
268

269
      // Shell is marked active but no session in pool
270
      // Check if we can reconnect to an existing daemon
271
      if (this._ptyPool.usePersistentDaemons && shell.socketPath) {
3✔
272
        const exists = await socketExists(shell.socketPath);
1✔
273
        if (exists) {
1✔
274
          // Try to reconnect
275
          try {
1✔
276
            await this._ptyPool.attach(shell.id, shell.cwd);
1✔
277
            logger.info({ shellId: shell.id }, 'Reconnected to daemon during reconciliation');
1✔
278
            continue;
1✔
279
          } catch (err) {
1!
280
            logger.warn({ shellId: shell.id, err }, 'Failed to reconnect to daemon');
×
281
          }
×
282
        }
1✔
283
      }
1✔
284

285
      // Check legacy PID mode
286
      if (shell.pid !== null && processExists(shell.pid)) {
3!
287
        // Process exists but not in pool - this shouldn't happen normally
288
        logger.warn({ shellId: shell.id, pid: shell.pid }, 'Shell has running process but not in pool');
×
289
        continue;
×
290
      }
✔
291

292
      // Mark shell as inactive
293
      logger.info({ shellId: shell.id }, 'Marking orphaned shell as inactive');
2✔
294
      await this._shellStore.update(shell.id, {
2✔
295
        status: 'inactive',
2✔
296
        pid: null,
2✔
297
        socketPath: null,
2✔
298
      });
2✔
299
    }
2✔
300

301
    // Clean up orphaned sockets (sockets with no corresponding shell)
302
    if (this._ptyPool.daemonManager) {
4✔
303
      const validShellIds = shells.map((s) => s.id);
1✔
304
      await this._ptyPool.daemonManager.cleanupOrphanedSockets(validShellIds);
1✔
305
    }
1✔
306

307
    logger.debug('Reconciliation complete');
4✔
308
  }
4✔
309

310
  /**
311
   * Start the periodic reconciliation loop
312
   */
313
  startReconciliationLoop(intervalMs: number): void {
49✔
314
    this.stopReconciliationLoop();
2✔
315

316
    this._reconciliationInterval = setInterval(() => {
2✔
317
      void this.reconcile();
5✔
318
    }, intervalMs);
2✔
319

320
    logger.info({ intervalMs }, 'Started reconciliation loop');
2✔
321
  }
2✔
322

323
  /**
324
   * Stop the periodic reconciliation loop
325
   */
326
  stopReconciliationLoop(): void {
49✔
327
    if (this._reconciliationInterval) {
45✔
328
      clearInterval(this._reconciliationInterval);
2✔
329
      this._reconciliationInterval = null;
2✔
330
      logger.debug('Stopped reconciliation loop');
2✔
331
    }
2✔
332
  }
45✔
333

334
  /**
335
   * Shutdown the manager
336
   */
337
  shutdown(): void {
49✔
338
    this.stopReconciliationLoop();
41✔
339
    this.removeAllListeners();
41✔
340
  }
41✔
341

342
  /**
343
   * Spawn or reconnect to a daemon for a shell
344
   */
345
  private async _spawnOrReconnect(shell: Shell): Promise<PtySessionLike> {
49✔
346
    // In daemon mode, try to attach first if socket exists
347
    if (this._ptyPool.usePersistentDaemons && shell.socketPath) {
22!
348
      const exists = await socketExists(shell.socketPath);
×
349
      if (exists) {
×
350
        try {
×
351
          const client = await this._ptyPool.attach(shell.id, shell.cwd);
×
352
          if (client) {
×
353
            return client;
×
354
          }
×
UNCOV
355
        } catch (attachErr) {
×
356
          // Check if this is a stale socket (ECONNREFUSED means no daemon listening)
357
          const isStaleSocket =
×
UNCOV
358
            attachErr instanceof Error &&
×
UNCOV
359
            'code' in attachErr &&
×
UNCOV
360
            (attachErr as NodeJS.ErrnoException).code === 'ECONNREFUSED';
×
361

UNCOV
362
          if (isStaleSocket) {
×
UNCOV
363
            logger.warn({ shellId: shell.id, socketPath: shell.socketPath },
×
UNCOV
364
              'Detected stale socket during attach, will spawn new daemon');
×
UNCOV
365
          } else {
×
366
            // Other error (timeout, permission, etc.) - propagate it
UNCOV
367
            throw attachErr;
×
UNCOV
368
          }
×
UNCOV
369
        }
×
UNCOV
370
      }
×
371
      // Socket was stale or didn't exist, clear it from shell record
UNCOV
372
      await this._shellStore.update(shell.id, { socketPath: null });
×
UNCOV
373
    }
×
374

375
    // Load scrollback before spawning (for replay on attach)
376
    await this._ptyPool.loadScrollback(shell.id);
22✔
377

378
    // Add separator to scrollback if shell has previous output
379
    const scrollbackStore = this._ptyPool.scrollbackStore;
22✔
380
    if (scrollbackStore) {
22✔
381
      const existingEntries = scrollbackStore.getFromMemory(shell.id);
22✔
382
      if (existingEntries.length > 0) {
22✔
383
        const timestamp = new Date().toLocaleString();
3✔
384
        scrollbackStore.append(shell.id, 'output', `\r\n\r\n--- shell restarted ${timestamp} ---\r\n\r\n`);
3✔
385
      }
3✔
386
    }
22✔
387

388
    // Spawn new session
389
    const session = await this._ptyPool.spawnAsync(shell.id, {
22✔
390
      cwd: shell.cwd,
22✔
391
    });
22✔
392

393
    // Update socket path if in daemon mode
394
    if (this._ptyPool.usePersistentDaemons) {
22!
UNCOV
395
      const socketPath = getSocketPath(shell.id);
×
UNCOV
396
      await this._shellStore.update(shell.id, { socketPath });
×
UNCOV
397
    }
✔
398

399
    return session;
21✔
400
  }
22✔
401

402
  /**
403
   * Load scrollback for a shell
404
   */
405
  private async _loadScrollback(shellId: string): Promise<string> {
49✔
406
    const scrollbackStore = this._ptyPool.scrollbackStore;
24✔
407
    if (!scrollbackStore) {
24!
UNCOV
408
      return '';
×
UNCOV
409
    }
×
410

411
    // Try memory first
412
    let entries = scrollbackStore.getFromMemory(shellId);
24✔
413
    if (entries.length === 0) {
24✔
414
      // Load from disk
415
      entries = await scrollbackStore.load(shellId);
21✔
416
    }
21✔
417

418
    // Concatenate output entries
419
    return entries
24✔
420
      .filter((e) => e.type === 'output')
24✔
421
      .map((e) => e.data)
24✔
422
      .join('');
24✔
423
  }
24✔
424

425
  /**
426
   * Get columns from a session (with fallback)
427
   */
428
  private _getSessionCols(session: PtySessionLike): number {
49✔
429
    if ('cols' in session && typeof session.cols === 'number') {
24✔
430
      return session.cols;
24✔
431
    }
24!
UNCOV
432
    return DEFAULT_COLS;
×
433
  }
24✔
434

435
  /**
436
   * Get rows from a session (with fallback)
437
   */
438
  private _getSessionRows(session: PtySessionLike): number {
49✔
439
    if ('rows' in session && typeof session.rows === 'number') {
24✔
440
      return session.rows;
24✔
441
    }
24!
UNCOV
442
    return DEFAULT_ROWS;
×
443
  }
24✔
444
}
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