@adhdev/session-host-core 1.0.82-rc.25 → 1.0.82-rc.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/ipc.ts CHANGED
@@ -4,6 +4,7 @@ import * as net from 'net';
4
4
  import { generateUUID } from './uuid.js';
5
5
  import { StringDecoder } from 'string_decoder';
6
6
  import type {
7
+ AttachSessionPayload,
7
8
  SessionHostEvent,
8
9
  SessionHostRequest,
9
10
  SessionHostRequestEnvelope,
@@ -122,6 +123,9 @@ function createLineParser<TEnvelope = SessionHostWireEnvelope>(
122
123
  export interface SessionHostClientOptions {
123
124
  endpoint?: SessionHostEndpoint;
124
125
  appName?: string;
126
+ requestTimeoutMs?: number;
127
+ /** Join the shared client registry (default true). Disable for short-lived probes. */
128
+ registerAsShared?: boolean;
125
129
  }
126
130
 
127
131
  /**
@@ -138,6 +142,34 @@ export type SessionHostDisconnectReason = 'error' | 'ended' | 'closed';
138
142
  * (host not up yet) — only the former is a session-host death signal.
139
143
  */
140
144
  const establishedSockets = new WeakSet<net.Socket>();
145
+ const activeClientRefs = new Set<WeakRef<SessionHostClient>>();
146
+
147
+ export function disconnectSessionHostClients(endpoint?: SessionHostEndpoint, error?: Error): void {
148
+ for (const ref of activeClientRefs) {
149
+ const client = ref.deref();
150
+ if (!client) {
151
+ activeClientRefs.delete(ref);
152
+ continue;
153
+ }
154
+ if (!endpoint || client.endpoint.path === endpoint.path) {
155
+ client.disconnect(error);
156
+ }
157
+ }
158
+ }
159
+
160
+ export function findConnectedSessionHostClient(endpoint?: SessionHostEndpoint): SessionHostClient | null {
161
+ for (const ref of activeClientRefs) {
162
+ const client = ref.deref();
163
+ if (!client) {
164
+ activeClientRefs.delete(ref);
165
+ continue;
166
+ }
167
+ if ((!endpoint || client.endpoint.path === endpoint.path) && client.isConnected()) {
168
+ return client;
169
+ }
170
+ }
171
+ return null;
172
+ }
141
173
 
142
174
  export interface SessionHostDisconnectInfo {
143
175
  reason: SessionHostDisconnectReason;
@@ -161,14 +193,34 @@ export class SessionHostClient {
161
193
  readonly endpoint: SessionHostEndpoint;
162
194
 
163
195
  private socket: net.Socket | null = null;
196
+ private connectPromise: Promise<void> | null = null;
197
+ private requestTimeoutMs = 30_000;
164
198
  private requestWaiters = new Map<string, { resolve: (value: SessionHostResponse) => void; reject: (error: Error) => void }>();
165
199
  private eventListeners = new Set<(event: SessionHostEvent) => void>();
166
200
  private disconnectListeners = new Set<(info: SessionHostDisconnectInfo) => void>();
167
201
  /** Guards against emitting both an 'error'-path and a 'close'-path disconnect for one socket. */
168
202
  private disconnectedSockets = new WeakSet<net.Socket>();
203
+ /** Session subscriptions established via create_session/attach_session, replayed automatically on reconnect. */
204
+ private attachedSessions = new Map<string, AttachSessionPayload>();
169
205
 
170
206
  constructor(options: SessionHostClientOptions = {}) {
171
207
  this.endpoint = options.endpoint || getDefaultSessionHostEndpoint(options.appName || 'adhdev');
208
+ if (typeof options.requestTimeoutMs === 'number' && options.requestTimeoutMs > 0) {
209
+ this.requestTimeoutMs = options.requestTimeoutMs;
210
+ }
211
+ if (options.registerAsShared !== false) activeClientRefs.add(new WeakRef(this));
212
+ }
213
+
214
+ static disconnectClients(endpoint?: SessionHostEndpoint, error?: Error): void {
215
+ disconnectSessionHostClients(endpoint, error);
216
+ }
217
+
218
+ static findConnectedClient(endpoint?: SessionHostEndpoint): SessionHostClient | null {
219
+ return findConnectedSessionHostClient(endpoint);
220
+ }
221
+
222
+ isConnected(): boolean {
223
+ return Boolean(this.socket && !this.socket.destroyed && establishedSockets.has(this.socket));
172
224
  }
173
225
 
174
226
  /**
@@ -228,6 +280,7 @@ export class SessionHostClient {
228
280
  // guard at the top of connect().
229
281
  if (this.socket === socket) {
230
282
  this.socket = null;
283
+ this.connectPromise = null;
231
284
  }
232
285
  try { socket.destroy(); } catch { /* noop */ }
233
286
 
@@ -246,13 +299,49 @@ export class SessionHostClient {
246
299
  }
247
300
  }
248
301
 
249
- async connect(): Promise<void> {
250
- if (this.socket && !this.socket.destroyed) return;
302
+ private sendRawRequestOnSocket<T = unknown>(
303
+ socket: net.Socket,
304
+ request: SessionHostRequest,
305
+ timeoutMs = this.requestTimeoutMs,
306
+ ): Promise<SessionHostResponse<T>> {
307
+ const requestId = generateUUID();
308
+ const envelope: SessionHostRequestEnvelope = {
309
+ kind: 'request',
310
+ requestId,
311
+ request,
312
+ };
313
+ const timeoutLabel = timeoutMs % 1000 === 0 ? `${timeoutMs / 1000}s` : `${timeoutMs}ms`;
314
+ return new Promise<SessionHostResponse<T>>((resolve, reject) => {
315
+ const timeout = setTimeout(() => {
316
+ const timeoutError = Object.assign(new Error(`Session host request timed out after ${timeoutLabel} (${request.type})`), { code: 'SESSION_HOST_REQUEST_TIMEOUT', requestType: request.type });
317
+ if (this.socket === socket) {
318
+ this.disconnect(timeoutError);
319
+ } else {
320
+ this.requestWaiters.delete(requestId);
321
+ this.disconnectedSockets.add(socket);
322
+ try { socket.destroy(); } catch { /* noop */ }
323
+ reject(timeoutError);
324
+ }
325
+ }, timeoutMs);
326
+ this.requestWaiters.set(requestId, {
327
+ resolve: (value) => { clearTimeout(timeout); resolve(value as SessionHostResponse<T>); },
328
+ reject: (error) => { clearTimeout(timeout); reject(error); },
329
+ });
330
+ socket.write(serializeEnvelope(envelope));
331
+ });
332
+ }
333
+
334
+ async connect(timeoutMs = this.requestTimeoutMs): Promise<void> {
335
+ if (this.socket && !this.socket.destroyed) {
336
+ if (this.connectPromise) await this.connectPromise;
337
+ return;
338
+ }
251
339
  // Cleanup stale socket reference left after error/disconnect
252
340
  if (this.socket) {
253
341
  try { this.socket.destroy(); } catch { /* noop */ }
254
342
  this.socket = null;
255
343
  }
344
+ this.connectPromise = null;
256
345
 
257
346
  const socket = net.createConnection(this.endpoint.path);
258
347
  this.socket = socket;
@@ -314,13 +403,48 @@ export class SessionHostClient {
314
403
  this.handleDisconnect(socket, 'ended', undefined, flushParser().length);
315
404
  });
316
405
 
317
- await new Promise<void>((resolve, reject) => {
318
- socket.once('connect', () => {
319
- establishedSockets.add(socket);
320
- resolve();
406
+ const connectTask = (async () => {
407
+ await new Promise<void>((resolve, reject) => {
408
+ const cleanup = () => {
409
+ clearTimeout(timer);
410
+ socket.off('connect', connected);
411
+ socket.off('error', failed);
412
+ socket.off('close', closed);
413
+ };
414
+ const connected = () => { cleanup(); establishedSockets.add(socket); resolve(); };
415
+ const failed = (error: Error) => { cleanup(); reject(error); };
416
+ const closed = () => failed(new Error('Session host connection closed before connect'));
417
+ const timer = setTimeout(() => {
418
+ const error = Object.assign(new Error(`Session host connect timed out after ${timeoutMs}ms`), { code: 'SESSION_HOST_CONNECT_TIMEOUT' });
419
+ failed(error);
420
+ if (this.socket === socket) this.disconnect(error);
421
+ }, timeoutMs);
422
+ socket.once('connect', connected);
423
+ socket.once('error', failed);
424
+ socket.once('close', closed);
321
425
  });
322
- socket.once('error', reject);
323
- });
426
+ if (this.attachedSessions.size > 0 && this.socket === socket && !socket.destroyed) {
427
+ for (const [sessionId, sub] of [...this.attachedSessions.entries()]) {
428
+ if (this.socket !== socket || socket.destroyed) break;
429
+ const replayResponse = await this.sendRawRequestOnSocket(socket, {
430
+ type: 'attach_session',
431
+ payload: sub,
432
+ }).catch(() => null);
433
+ if (replayResponse && !replayResponse.success) {
434
+ this.attachedSessions.delete(sessionId);
435
+ }
436
+ }
437
+ }
438
+ })();
439
+
440
+ this.connectPromise = connectTask;
441
+ try {
442
+ await connectTask;
443
+ } finally {
444
+ if (this.connectPromise === connectTask) {
445
+ this.connectPromise = null;
446
+ }
447
+ }
324
448
  }
325
449
 
326
450
  onEvent(listener: (event: SessionHostEvent) => void): () => void {
@@ -330,33 +454,76 @@ export class SessionHostClient {
330
454
  };
331
455
  }
332
456
 
333
- async request<T = unknown>(request: SessionHostRequest): Promise<SessionHostResponse<T>> {
334
- await this.connect();
335
- if (!this.socket) throw new Error('Session host socket unavailable');
336
-
337
- const requestId = generateUUID();
338
- const envelope: SessionHostRequestEnvelope = {
339
- kind: 'request',
340
- requestId,
341
- request,
342
- };
343
-
344
- const response = await new Promise<SessionHostResponse>((resolve, reject) => {
345
- const timeout = setTimeout(() => {
346
- this.requestWaiters.delete(requestId);
347
- reject(new Error(`Session host request timed out after 30s (${request.type})`));
348
- }, 30_000);
349
- this.requestWaiters.set(requestId, {
350
- resolve: (value) => { clearTimeout(timeout); resolve(value); },
351
- reject: (error) => { clearTimeout(timeout); reject(error); },
352
- });
353
- this.socket?.write(serializeEnvelope(envelope));
354
- });
457
+ async request<T = unknown>(request: SessionHostRequest, options: { timeoutMs?: number } = {}): Promise<SessionHostResponse<T>> {
458
+ const timeoutMs = Math.max(1, options.timeoutMs ?? this.requestTimeoutMs);
459
+ const deadline = Date.now() + timeoutMs;
460
+ const alreadyConnected = this.isConnected() && !this.connectPromise;
461
+ // A probe must also bound a connect/re-attach already in flight on this
462
+ // client; that task may have started with the normal 30s timeout.
463
+ if (options.timeoutMs !== undefined) {
464
+ let timer: ReturnType<typeof setTimeout> | undefined;
465
+ try {
466
+ await Promise.race([
467
+ this.connect(timeoutMs),
468
+ new Promise<never>((_resolve, reject) => {
469
+ timer = setTimeout(() => {
470
+ const error = Object.assign(new Error(`Session host connect timed out after ${timeoutMs}ms`), { code: 'SESSION_HOST_CONNECT_TIMEOUT' });
471
+ reject(error);
472
+ this.disconnect(error);
473
+ }, timeoutMs);
474
+ }),
475
+ ]);
476
+ } finally { if (timer) clearTimeout(timer); }
477
+ } else {
478
+ await this.connect(timeoutMs);
479
+ }
480
+ const requestSocket = this.socket;
481
+ if (!requestSocket) throw new Error('Session host socket unavailable');
482
+
483
+ const response = await this.sendRawRequestOnSocket<T>(requestSocket, request, options.timeoutMs === undefined || alreadyConnected ? timeoutMs : Math.max(1, deadline - Date.now()));
484
+ if (response.success) {
485
+ if (request.type === 'attach_session') {
486
+ this.attachedSessions.set(request.payload.sessionId, {
487
+ sessionId: request.payload.sessionId,
488
+ clientId: request.payload.clientId,
489
+ clientType: request.payload.clientType,
490
+ readOnly: request.payload.readOnly === true,
491
+ });
492
+ } else if (request.type === 'create_session') {
493
+ const createdId = (response.result as { sessionId?: string } | undefined)?.sessionId || request.payload.sessionId;
494
+ const clientId = typeof request.payload.clientId === 'string' ? request.payload.clientId.trim() : '';
495
+ if (createdId && clientId) {
496
+ this.attachedSessions.set(createdId, {
497
+ sessionId: createdId,
498
+ clientId,
499
+ clientType: request.payload.clientType || 'daemon',
500
+ readOnly: false,
501
+ });
502
+ }
503
+ } else if (request.type === 'detach_session' || request.type === 'delete_session') {
504
+ this.attachedSessions.delete(request.payload.sessionId);
505
+ }
506
+ }
507
+ return response;
508
+ }
355
509
 
356
- return response as SessionHostResponse<T>;
510
+ disconnect(error?: Error): void {
511
+ const socket = this.socket;
512
+ if (socket) {
513
+ this.handleDisconnect(socket, error ? 'error' : 'closed', error, 0);
514
+ return;
515
+ }
516
+ this.connectPromise = null;
517
+ const failure = error || new Error(`Session host client disconnected (${this.endpoint.path})`);
518
+ for (const waiter of this.requestWaiters.values()) {
519
+ waiter.reject(failure);
520
+ }
521
+ this.requestWaiters.clear();
357
522
  }
358
523
 
359
524
  async close(): Promise<void> {
525
+ this.attachedSessions.clear();
526
+ this.connectPromise = null;
360
527
  if (!this.socket) return;
361
528
  const socket = this.socket;
362
529
  this.socket = null;