@adhdev/session-host-core 1.0.82-rc.24 → 1.0.82-rc.26
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 +11 -2
- package/dist/index.d.ts +11 -2
- package/dist/index.js +137 -30
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +136 -30
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/cli-child-priority.ts +1 -1
- package/src/index.ts +1 -0
- package/src/ipc.ts +138 -28
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,7 @@ function createLineParser<TEnvelope = SessionHostWireEnvelope>(
|
|
|
122
123
|
export interface SessionHostClientOptions {
|
|
123
124
|
endpoint?: SessionHostEndpoint;
|
|
124
125
|
appName?: string;
|
|
126
|
+
requestTimeoutMs?: number;
|
|
125
127
|
}
|
|
126
128
|
|
|
127
129
|
/**
|
|
@@ -138,6 +140,20 @@ export type SessionHostDisconnectReason = 'error' | 'ended' | 'closed';
|
|
|
138
140
|
* (host not up yet) — only the former is a session-host death signal.
|
|
139
141
|
*/
|
|
140
142
|
const establishedSockets = new WeakSet<net.Socket>();
|
|
143
|
+
const activeClientRefs = new Set<WeakRef<SessionHostClient>>();
|
|
144
|
+
|
|
145
|
+
export function disconnectSessionHostClients(endpoint?: SessionHostEndpoint, error?: Error): void {
|
|
146
|
+
for (const ref of activeClientRefs) {
|
|
147
|
+
const client = ref.deref();
|
|
148
|
+
if (!client) {
|
|
149
|
+
activeClientRefs.delete(ref);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (!endpoint || client.endpoint.path === endpoint.path) {
|
|
153
|
+
client.disconnect(error);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
141
157
|
|
|
142
158
|
export interface SessionHostDisconnectInfo {
|
|
143
159
|
reason: SessionHostDisconnectReason;
|
|
@@ -161,14 +177,26 @@ export class SessionHostClient {
|
|
|
161
177
|
readonly endpoint: SessionHostEndpoint;
|
|
162
178
|
|
|
163
179
|
private socket: net.Socket | null = null;
|
|
180
|
+
private connectPromise: Promise<void> | null = null;
|
|
181
|
+
private requestTimeoutMs = 30_000;
|
|
164
182
|
private requestWaiters = new Map<string, { resolve: (value: SessionHostResponse) => void; reject: (error: Error) => void }>();
|
|
165
183
|
private eventListeners = new Set<(event: SessionHostEvent) => void>();
|
|
166
184
|
private disconnectListeners = new Set<(info: SessionHostDisconnectInfo) => void>();
|
|
167
185
|
/** Guards against emitting both an 'error'-path and a 'close'-path disconnect for one socket. */
|
|
168
186
|
private disconnectedSockets = new WeakSet<net.Socket>();
|
|
187
|
+
/** Session subscriptions established via create_session/attach_session, replayed automatically on reconnect. */
|
|
188
|
+
private attachedSessions = new Map<string, AttachSessionPayload>();
|
|
169
189
|
|
|
170
190
|
constructor(options: SessionHostClientOptions = {}) {
|
|
171
191
|
this.endpoint = options.endpoint || getDefaultSessionHostEndpoint(options.appName || 'adhdev');
|
|
192
|
+
if (typeof options.requestTimeoutMs === 'number' && options.requestTimeoutMs > 0) {
|
|
193
|
+
this.requestTimeoutMs = options.requestTimeoutMs;
|
|
194
|
+
}
|
|
195
|
+
activeClientRefs.add(new WeakRef(this));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
static disconnectClients(endpoint?: SessionHostEndpoint, error?: Error): void {
|
|
199
|
+
disconnectSessionHostClients(endpoint, error);
|
|
172
200
|
}
|
|
173
201
|
|
|
174
202
|
/**
|
|
@@ -228,6 +256,7 @@ export class SessionHostClient {
|
|
|
228
256
|
// guard at the top of connect().
|
|
229
257
|
if (this.socket === socket) {
|
|
230
258
|
this.socket = null;
|
|
259
|
+
this.connectPromise = null;
|
|
231
260
|
}
|
|
232
261
|
try { socket.destroy(); } catch { /* noop */ }
|
|
233
262
|
|
|
@@ -246,13 +275,49 @@ export class SessionHostClient {
|
|
|
246
275
|
}
|
|
247
276
|
}
|
|
248
277
|
|
|
278
|
+
private sendRawRequestOnSocket<T = unknown>(
|
|
279
|
+
socket: net.Socket,
|
|
280
|
+
request: SessionHostRequest,
|
|
281
|
+
): Promise<SessionHostResponse<T>> {
|
|
282
|
+
const requestId = generateUUID();
|
|
283
|
+
const envelope: SessionHostRequestEnvelope = {
|
|
284
|
+
kind: 'request',
|
|
285
|
+
requestId,
|
|
286
|
+
request,
|
|
287
|
+
};
|
|
288
|
+
const timeoutMs = this.requestTimeoutMs > 0 ? this.requestTimeoutMs : 30_000;
|
|
289
|
+
const timeoutLabel = timeoutMs % 1000 === 0 ? `${timeoutMs / 1000}s` : `${timeoutMs}ms`;
|
|
290
|
+
return new Promise<SessionHostResponse<T>>((resolve, reject) => {
|
|
291
|
+
const timeout = setTimeout(() => {
|
|
292
|
+
const timeoutError = new Error(`Session host request timed out after ${timeoutLabel} (${request.type})`);
|
|
293
|
+
if (this.socket === socket) {
|
|
294
|
+
this.disconnect(timeoutError);
|
|
295
|
+
} else {
|
|
296
|
+
this.requestWaiters.delete(requestId);
|
|
297
|
+
this.disconnectedSockets.add(socket);
|
|
298
|
+
try { socket.destroy(); } catch { /* noop */ }
|
|
299
|
+
reject(timeoutError);
|
|
300
|
+
}
|
|
301
|
+
}, timeoutMs);
|
|
302
|
+
this.requestWaiters.set(requestId, {
|
|
303
|
+
resolve: (value) => { clearTimeout(timeout); resolve(value as SessionHostResponse<T>); },
|
|
304
|
+
reject: (error) => { clearTimeout(timeout); reject(error); },
|
|
305
|
+
});
|
|
306
|
+
socket.write(serializeEnvelope(envelope));
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
|
|
249
310
|
async connect(): Promise<void> {
|
|
250
|
-
if (this.socket && !this.socket.destroyed)
|
|
311
|
+
if (this.socket && !this.socket.destroyed) {
|
|
312
|
+
if (this.connectPromise) await this.connectPromise;
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
251
315
|
// Cleanup stale socket reference left after error/disconnect
|
|
252
316
|
if (this.socket) {
|
|
253
317
|
try { this.socket.destroy(); } catch { /* noop */ }
|
|
254
318
|
this.socket = null;
|
|
255
319
|
}
|
|
320
|
+
this.connectPromise = null;
|
|
256
321
|
|
|
257
322
|
const socket = net.createConnection(this.endpoint.path);
|
|
258
323
|
this.socket = socket;
|
|
@@ -314,13 +379,36 @@ export class SessionHostClient {
|
|
|
314
379
|
this.handleDisconnect(socket, 'ended', undefined, flushParser().length);
|
|
315
380
|
});
|
|
316
381
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
382
|
+
const connectTask = (async () => {
|
|
383
|
+
await new Promise<void>((resolve, reject) => {
|
|
384
|
+
socket.once('connect', () => {
|
|
385
|
+
establishedSockets.add(socket);
|
|
386
|
+
resolve();
|
|
387
|
+
});
|
|
388
|
+
socket.once('error', reject);
|
|
321
389
|
});
|
|
322
|
-
socket.
|
|
323
|
-
|
|
390
|
+
if (this.attachedSessions.size > 0 && this.socket === socket && !socket.destroyed) {
|
|
391
|
+
for (const [sessionId, sub] of [...this.attachedSessions.entries()]) {
|
|
392
|
+
if (this.socket !== socket || socket.destroyed) break;
|
|
393
|
+
const replayResponse = await this.sendRawRequestOnSocket(socket, {
|
|
394
|
+
type: 'attach_session',
|
|
395
|
+
payload: sub,
|
|
396
|
+
}).catch(() => null);
|
|
397
|
+
if (replayResponse && !replayResponse.success) {
|
|
398
|
+
this.attachedSessions.delete(sessionId);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
})();
|
|
403
|
+
|
|
404
|
+
this.connectPromise = connectTask;
|
|
405
|
+
try {
|
|
406
|
+
await connectTask;
|
|
407
|
+
} finally {
|
|
408
|
+
if (this.connectPromise === connectTask) {
|
|
409
|
+
this.connectPromise = null;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
324
412
|
}
|
|
325
413
|
|
|
326
414
|
onEvent(listener: (event: SessionHostEvent) => void): () => void {
|
|
@@ -332,31 +420,53 @@ export class SessionHostClient {
|
|
|
332
420
|
|
|
333
421
|
async request<T = unknown>(request: SessionHostRequest): Promise<SessionHostResponse<T>> {
|
|
334
422
|
await this.connect();
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
const
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
423
|
+
const requestSocket = this.socket;
|
|
424
|
+
if (!requestSocket) throw new Error('Session host socket unavailable');
|
|
425
|
+
|
|
426
|
+
const response = await this.sendRawRequestOnSocket<T>(requestSocket, request);
|
|
427
|
+
if (response.success) {
|
|
428
|
+
if (request.type === 'attach_session') {
|
|
429
|
+
this.attachedSessions.set(request.payload.sessionId, {
|
|
430
|
+
sessionId: request.payload.sessionId,
|
|
431
|
+
clientId: request.payload.clientId,
|
|
432
|
+
clientType: request.payload.clientType,
|
|
433
|
+
readOnly: request.payload.readOnly === true,
|
|
434
|
+
});
|
|
435
|
+
} else if (request.type === 'create_session') {
|
|
436
|
+
const createdId = (response.result as { sessionId?: string } | undefined)?.sessionId || request.payload.sessionId;
|
|
437
|
+
const clientId = typeof request.payload.clientId === 'string' ? request.payload.clientId.trim() : '';
|
|
438
|
+
if (createdId && clientId) {
|
|
439
|
+
this.attachedSessions.set(createdId, {
|
|
440
|
+
sessionId: createdId,
|
|
441
|
+
clientId,
|
|
442
|
+
clientType: request.payload.clientType || 'daemon',
|
|
443
|
+
readOnly: false,
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
} else if (request.type === 'detach_session' || request.type === 'delete_session') {
|
|
447
|
+
this.attachedSessions.delete(request.payload.sessionId);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
return response;
|
|
451
|
+
}
|
|
355
452
|
|
|
356
|
-
|
|
453
|
+
disconnect(error?: Error): void {
|
|
454
|
+
const socket = this.socket;
|
|
455
|
+
if (socket) {
|
|
456
|
+
this.handleDisconnect(socket, error ? 'error' : 'closed', error, 0);
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
this.connectPromise = null;
|
|
460
|
+
const failure = error || new Error(`Session host client disconnected (${this.endpoint.path})`);
|
|
461
|
+
for (const waiter of this.requestWaiters.values()) {
|
|
462
|
+
waiter.reject(failure);
|
|
463
|
+
}
|
|
464
|
+
this.requestWaiters.clear();
|
|
357
465
|
}
|
|
358
466
|
|
|
359
467
|
async close(): Promise<void> {
|
|
468
|
+
this.attachedSessions.clear();
|
|
469
|
+
this.connectPromise = null;
|
|
360
470
|
if (!this.socket) return;
|
|
361
471
|
const socket = this.socket;
|
|
362
472
|
this.socket = null;
|