@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/dist/index.d.mts +20 -4
- package/dist/index.d.ts +20 -4
- package/dist/index.js +200 -33
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +198 -33
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/cli-child-priority.ts +1 -1
- package/src/index.ts +2 -0
- package/src/ipc.ts +198 -31
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
|
-
|
|
250
|
-
|
|
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
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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.
|
|
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
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
});
|
|
353
|
-
|
|
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
|
-
|
|
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;
|