@sockethub/server 5.0.0-alpha.11 → 5.0.0-alpha.12

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.
@@ -2,6 +2,7 @@ import { type ChildProcess, fork } from "node:child_process";
2
2
  import { join } from "node:path";
3
3
 
4
4
  import { type JobDataDecrypted, JobQueue } from "@sockethub/data-layer";
5
+ import { createLogger } from "@sockethub/logger";
5
6
  import type {
6
7
  ActivityStream,
7
8
  CompletedJobHandler,
@@ -9,9 +10,11 @@ import type {
9
10
  Logger,
10
11
  PlatformConfig,
11
12
  } from "@sockethub/schemas";
13
+ import {
14
+ buildCanonicalContext,
15
+ INTERNAL_PLATFORM_CONTEXT_URL,
16
+ } from "@sockethub/schemas";
12
17
  import type { Socket } from "socket.io";
13
-
14
- import { createLogger } from "@sockethub/logger";
15
18
  import config from "./config.js";
16
19
  import { getSocket } from "./listener.js";
17
20
  import { __dirname } from "./util.js";
@@ -29,19 +32,29 @@ export interface PlatformInstanceParams {
29
32
  type EnvFormat = {
30
33
  LOG_LEVEL?: string;
31
34
  REDIS_URL: string;
35
+ SOCKETHUB_PLATFORM_CHILD?: string;
36
+ SOCKETHUB_PLATFORM_HEARTBEAT_INTERVAL_MS?: string;
37
+ SOCKETHUB_PLATFORM_HEARTBEAT_TIMEOUT_MS?: string;
32
38
  };
33
39
 
34
- interface MessageFromPlatform extends Array<string | ActivityStream> {
35
- 0: string;
36
- 1: ActivityStream;
37
- 2: string;
38
- }
40
+ type MessageFromPlatform =
41
+ | ["updateActor", ActivityStream | undefined, string]
42
+ | ["error", string]
43
+ | ["heartbeat", ActivityStream]
44
+ | [string, ActivityStream, string?];
39
45
 
40
46
  export interface MessageFromParent extends Array<string | unknown> {
41
47
  0: string;
42
48
  1: unknown;
43
49
  }
44
50
 
51
+ const HEARTBEAT_INTERVAL_MS = Number(
52
+ config.get("platformHeartbeat:intervalMs") ?? 5000,
53
+ );
54
+ const HEARTBEAT_TIMEOUT_MS = Number(
55
+ config.get("platformHeartbeat:timeoutMs") ?? 15000,
56
+ );
57
+
45
58
  export default class PlatformInstance {
46
59
  id: string;
47
60
  flaggedForTermination = false;
@@ -51,16 +64,24 @@ export default class PlatformInstance {
51
64
  readonly global: boolean = false;
52
65
  readonly completedJobHandlers: Map<string, CompletedJobHandler> = new Map();
53
66
  config: PlatformConfig;
67
+ contextUrl?: string;
54
68
  private initialized = false;
55
69
  readonly name: string;
56
70
  process: ChildProcess;
57
71
  readonly log: Logger;
58
72
  readonly parentId: string;
59
73
  readonly sessions: Set<string> = new Set();
60
- readonly sessionCallbacks: object = {
61
- close: (() => new Map())(),
62
- message: (() => new Map())(),
74
+ readonly sessionCallbacks: Record<
75
+ "close" | "message",
76
+ Map<string, (...args: Array<unknown>) => void | Promise<void>>
77
+ > = {
78
+ close: new Map(),
79
+ message: new Map(),
63
80
  };
81
+ private heartbeatLastSeen = Date.now();
82
+ private heartbeatMonitor?: NodeJS.Timeout;
83
+ private heartbeatListener?: (message: MessageFromPlatform) => void;
84
+ private heartbeatFailureHandled = false;
64
85
  private readonly actor?: string;
65
86
 
66
87
  constructor(params: PlatformInstanceParams) {
@@ -81,9 +102,20 @@ export default class PlatformInstance {
81
102
  if (process.env.LOG_LEVEL) {
82
103
  env.LOG_LEVEL = process.env.LOG_LEVEL;
83
104
  }
105
+ const heartbeatInterval = config.get("platformHeartbeat:intervalMs");
106
+ if (typeof heartbeatInterval !== "undefined") {
107
+ env.SOCKETHUB_PLATFORM_HEARTBEAT_INTERVAL_MS =
108
+ String(heartbeatInterval);
109
+ }
110
+ const heartbeatTimeout = config.get("platformHeartbeat:timeoutMs");
111
+ if (typeof heartbeatTimeout !== "undefined") {
112
+ env.SOCKETHUB_PLATFORM_HEARTBEAT_TIMEOUT_MS =
113
+ String(heartbeatTimeout);
114
+ }
84
115
 
85
116
  this.createQueue();
86
117
  this.initProcess(this.parentId, this.name, this.id, env);
118
+ this.startHeartbeatMonitor();
87
119
  this.createGetSocket();
88
120
  }
89
121
 
@@ -119,23 +151,31 @@ export default class PlatformInstance {
119
151
  this.flaggedForTermination = true;
120
152
 
121
153
  try {
154
+ if (this.heartbeatMonitor) {
155
+ clearInterval(this.heartbeatMonitor);
156
+ this.heartbeatMonitor = undefined;
157
+ }
158
+ if (this.heartbeatListener) {
159
+ this.process.removeListener("message", this.heartbeatListener);
160
+ this.heartbeatListener = undefined;
161
+ }
122
162
  this.process.removeAllListeners("close");
123
163
  this.process.unref();
124
164
  this.process.kill();
125
- } catch (e) {
165
+ } catch (_e) {
126
166
  // needs to happen
127
167
  }
128
168
 
129
169
  try {
130
170
  await this.queue.shutdown();
131
171
  this.queue = undefined;
132
- } catch (e) {
172
+ } catch (_e) {
133
173
  // this needs to happen
134
174
  }
135
175
 
136
176
  try {
137
177
  platformInstances.delete(this.id);
138
- } catch (e) {
178
+ } catch (_e) {
139
179
  // this needs to happen
140
180
  }
141
181
  }
@@ -179,7 +219,9 @@ export default class PlatformInstance {
179
219
  public registerSession(sessionId: string) {
180
220
  if (!this.sessions.has(sessionId)) {
181
221
  this.sessions.add(sessionId);
182
- for (const type of Object.keys(this.sessionCallbacks)) {
222
+ for (const type of Object.keys(this.sessionCallbacks) as Array<
223
+ "close" | "message"
224
+ >) {
183
225
  const cb = this.callbackFunction(type, sessionId);
184
226
  this.process.on(type, cb);
185
227
  this.sessionCallbacks[type].set(sessionId, cb);
@@ -197,11 +239,11 @@ export default class PlatformInstance {
197
239
  return this.getSocket(sessionId).then(
198
240
  (socket: Socket) => {
199
241
  try {
200
- // this property should never be exposed externally
201
- // biome-ignore lint/performance/noDelete: <explanation>
202
- delete msg.sessionSecret;
242
+ this.toExternalPayload(msg as ActivityStream);
203
243
  } finally {
204
- msg.context = this.name;
244
+ const contextUrl =
245
+ this.contextUrl ?? INTERNAL_PLATFORM_CONTEXT_URL;
246
+ msg["@context"] = buildCanonicalContext(contextUrl);
205
247
  if (
206
248
  msg.type === "error" &&
207
249
  typeof msg.actor === "undefined" &&
@@ -213,7 +255,7 @@ export default class PlatformInstance {
213
255
  socket.emit("message", msg as ActivityStream);
214
256
  }
215
257
  },
216
- (err) => this.log.error(`sendToClient ${err}`),
258
+ (err) => this.log.error(`sendToClient ${String(err)}`),
217
259
  );
218
260
  }
219
261
 
@@ -227,6 +269,24 @@ export default class PlatformInstance {
227
269
  }
228
270
  }
229
271
 
272
+ /**
273
+ * Remove internal-only transport metadata before returning payloads to clients.
274
+ */
275
+ private toExternalPayload(payload: ActivityStream): ActivityStream {
276
+ const external = payload as InternalActivityStream & {
277
+ context?: unknown;
278
+ };
279
+ delete external.sessionSecret;
280
+ delete external.context;
281
+ if (
282
+ typeof external.platform !== "string" ||
283
+ external.platform.length === 0
284
+ ) {
285
+ external.platform = this.name;
286
+ }
287
+ return payload;
288
+ }
289
+
230
290
  // handle job results coming in on the queue from platform instances
231
291
  private async handleJobResult(
232
292
  state: string,
@@ -248,6 +308,8 @@ export default class PlatformInstance {
248
308
  payload = job.msg;
249
309
  }
250
310
 
311
+ payload = this.toExternalPayload(payload);
312
+
251
313
  // send result to client
252
314
  const callback = this.completedJobHandlers.get(job.title);
253
315
  if (callback) {
@@ -300,7 +362,9 @@ export default class PlatformInstance {
300
362
  */
301
363
  private async reportError(sessionId: string, errorMessage: string) {
302
364
  const errorObject: ActivityStream = {
303
- context: this.name,
365
+ "@context": buildCanonicalContext(
366
+ this.contextUrl ?? INTERNAL_PLATFORM_CONTEXT_URL,
367
+ ),
304
368
  type: "error",
305
369
  actor: { id: this.actor, type: "unknown" },
306
370
  error: errorMessage,
@@ -312,7 +376,11 @@ export default class PlatformInstance {
312
376
  await this.sendToClient(sessionId, errorObject);
313
377
  }
314
378
  } catch (err) {
315
- this.log.error(`Failed to send error to client: ${err.message}`);
379
+ this.log.error(
380
+ `Failed to send error to client: ${
381
+ err instanceof Error ? err.message : String(err)
382
+ }`,
383
+ );
316
384
  }
317
385
 
318
386
  this.sessions.clear();
@@ -335,8 +403,11 @@ export default class PlatformInstance {
335
403
  * @param listener
336
404
  * @param sessionId
337
405
  */
338
- private callbackFunction(listener: string, sessionId: string) {
339
- const funcs = {
406
+ private callbackFunction(listener: "close" | "message", sessionId: string) {
407
+ const funcs: Record<
408
+ "close" | "message",
409
+ (...args: Array<unknown>) => Promise<void>
410
+ > = {
340
411
  close: async (e: object) => {
341
412
  this.log.error(`close event triggered ${this.id}: ${e}`);
342
413
  // Check if process is still connected before attempting error reporting
@@ -354,10 +425,33 @@ export default class PlatformInstance {
354
425
  },
355
426
  message: async ([first, second, third]: MessageFromPlatform) => {
356
427
  if (first === "updateActor") {
428
+ // Internal control message: platform process is reporting a new actor id.
357
429
  // We need to update the key to the store in order to find it in the future.
358
430
  this.updateIdentifier(third);
359
- } else if (first === "error" && typeof second === "string") {
360
- await this.reportError(sessionId, second);
431
+ } else if (first === "error") {
432
+ // Error messages travel over IPC as plain objects; normalize to a string.
433
+ let normalizedError: string;
434
+ if (typeof second === "string") {
435
+ normalizedError = second;
436
+ } else if (
437
+ second &&
438
+ typeof second === "object" &&
439
+ "message" in (second as Record<string, unknown>)
440
+ ) {
441
+ normalizedError = String(
442
+ (second as Record<string, unknown>).message,
443
+ );
444
+ } else {
445
+ try {
446
+ normalizedError = JSON.stringify(second);
447
+ } catch {
448
+ normalizedError = String(second);
449
+ }
450
+ }
451
+ await this.reportError(sessionId, normalizedError);
452
+ } else if (first === "heartbeat") {
453
+ // Internal heartbeat signals are handled by the monitor listener only.
454
+ return;
361
455
  } else {
362
456
  // treat like a message to clients
363
457
  await this.sendToClient(sessionId, second);
@@ -366,4 +460,49 @@ export default class PlatformInstance {
366
460
  };
367
461
  return funcs[listener];
368
462
  }
463
+
464
+ private markHeartbeat() {
465
+ this.heartbeatLastSeen = Date.now();
466
+ }
467
+
468
+ private startHeartbeatMonitor() {
469
+ if (
470
+ !Number.isFinite(HEARTBEAT_INTERVAL_MS) ||
471
+ HEARTBEAT_INTERVAL_MS <= 0 ||
472
+ !Number.isFinite(HEARTBEAT_TIMEOUT_MS) ||
473
+ HEARTBEAT_TIMEOUT_MS <= 0
474
+ ) {
475
+ return;
476
+ }
477
+ if (!this.process?.on) {
478
+ return;
479
+ }
480
+ // Track last heartbeat to detect hung platform processes.
481
+ this.heartbeatLastSeen = Date.now();
482
+ this.heartbeatListener = (message: MessageFromPlatform) => {
483
+ if (Array.isArray(message) && message[0] === "heartbeat") {
484
+ this.markHeartbeat();
485
+ }
486
+ };
487
+ this.process.on("message", this.heartbeatListener);
488
+ this.heartbeatMonitor = setInterval(() => {
489
+ // Avoid double-handling once shutdown starts or a timeout was already handled.
490
+ if (this.flaggedForTermination || this.heartbeatFailureHandled) {
491
+ return;
492
+ }
493
+ if (!this.process?.connected) {
494
+ return;
495
+ }
496
+ const elapsed = Date.now() - this.heartbeatLastSeen;
497
+ if (elapsed > HEARTBEAT_TIMEOUT_MS) {
498
+ this.heartbeatFailureHandled = true;
499
+ this.log.error(
500
+ `heartbeat timeout for ${this.id} after ${elapsed}ms`,
501
+ );
502
+ // The child is unresponsive; mark for termination and trigger shutdown.
503
+ this.flaggedForTermination = true;
504
+ void this.shutdown();
505
+ }
506
+ }, HEARTBEAT_INTERVAL_MS);
507
+ }
369
508
  }
@@ -42,7 +42,11 @@ describe("platform.ts credential handling", () => {
42
42
 
43
43
  validCredentials = {
44
44
  type: "credentials",
45
- context: "xmpp",
45
+ "@context": [
46
+ "https://www.w3.org/ns/activitystreams",
47
+ "https://sockethub.org/ns/context/v1.jsonld",
48
+ "https://sockethub.org/ns/context/platform/xmpp/v1.jsonld",
49
+ ],
46
50
  actor: {
47
51
  id: "testuser@localhost",
48
52
  type: "person",
@@ -75,7 +79,12 @@ describe("platform.ts credential handling", () => {
75
79
  title: "xmpp-job-1",
76
80
  msg: {
77
81
  type: "connect",
78
- context: "xmpp",
82
+ "@context": [
83
+ "https://www.w3.org/ns/activitystreams",
84
+ "https://sockethub.org/ns/context/v1.jsonld",
85
+ "https://sockethub.org/ns/context/platform/xmpp/v1.jsonld",
86
+ ],
87
+ platform: "xmpp",
79
88
  actor: { id: "testuser@localhost", type: "person" },
80
89
  sessionSecret: "secret123",
81
90
  },
package/src/platform.ts CHANGED
@@ -10,13 +10,13 @@
10
10
  * it and sockethub will start up another process. This ensures memory safety.
11
11
  */
12
12
  import { crypto, getPlatformId } from "@sockethub/crypto";
13
+ import type { JobHandler } from "@sockethub/data-layer";
13
14
  import {
14
15
  CredentialsStore,
15
16
  type JobDataDecrypted,
16
17
  JobWorker,
17
18
  } from "@sockethub/data-layer";
18
- import type { JobHandler } from "@sockethub/data-layer";
19
- import { type Logger, createLogger, setLoggerContext } from "@sockethub/logger";
19
+ import { createLogger, type Logger, setLoggerContext } from "@sockethub/logger";
20
20
  import type {
21
21
  ActivityStream,
22
22
  CredentialsObject,
@@ -25,6 +25,10 @@ import type {
25
25
  PlatformInterface,
26
26
  PlatformSession,
27
27
  } from "@sockethub/schemas";
28
+ import {
29
+ buildCanonicalContext,
30
+ INTERNAL_PLATFORM_CONTEXT_URL,
31
+ } from "@sockethub/schemas";
28
32
  import config from "./config";
29
33
 
30
34
  // Simple wrapper function to help with testing
@@ -53,7 +57,16 @@ async function startPlatformProcess() {
53
57
 
54
58
  // conditionally initialize sentry
55
59
  let sentry: { readonly reportError: (err: Error) => void } = {
56
- reportError: (err: Error) => {},
60
+ reportError: (err: Error) => {
61
+ logger.debug(
62
+ "Sentry not configured; error not reported to Sentry",
63
+ {
64
+ error: err,
65
+ errorMessage: err?.message ?? String(err),
66
+ errorStack: err?.stack,
67
+ },
68
+ );
69
+ },
57
70
  };
58
71
  (async () => {
59
72
  if (config.get("sentry:dsn")) {
@@ -103,16 +116,43 @@ async function startPlatformProcess() {
103
116
  return p as PlatformInterface;
104
117
  })();
105
118
 
119
+ type PlatformHandlerWithCredentials = (
120
+ msg: ActivityStream,
121
+ credentials: CredentialsObject,
122
+ cb: PlatformCallback,
123
+ ) => void;
124
+ type PlatformHandler = (msg: ActivityStream, cb: PlatformCallback) => void;
125
+ function getPlatformHandler(
126
+ instance: PlatformInterface,
127
+ name: string,
128
+ ): PlatformHandlerWithCredentials | PlatformHandler | undefined {
129
+ const candidate = (
130
+ instance as PlatformInterface & Record<string, unknown>
131
+ )[name];
132
+ return typeof candidate === "function"
133
+ ? (candidate as PlatformHandlerWithCredentials | PlatformHandler)
134
+ : undefined;
135
+ }
136
+
137
+ const heartbeatIntervalMs = Number(
138
+ config.get("platformHeartbeat:intervalMs") ?? 5000,
139
+ );
140
+ let heartbeatTimer: NodeJS.Timeout | undefined;
141
+
106
142
  /**
107
- * Safely send error message to parent process, handling IPC channel closure
143
+ * Safely send message to parent process, handling IPC channel closure
108
144
  */
109
- function safeProcessSend(message: [string, string]) {
145
+ function safeProcessSend(message: [string, unknown]) {
110
146
  if (process.send && process.connected) {
111
147
  try {
112
148
  process.send(message);
113
149
  } catch (ipcErr) {
114
150
  console.error(
115
- `Failed to report error via IPC: ${ipcErr.message}`,
151
+ `Failed to report error via IPC: ${
152
+ ipcErr instanceof Error
153
+ ? ipcErr.message
154
+ : String(ipcErr)
155
+ }`,
116
156
  );
117
157
  }
118
158
  } else {
@@ -120,6 +160,34 @@ async function startPlatformProcess() {
120
160
  }
121
161
  }
122
162
 
163
+ function startHeartbeat() {
164
+ if (!Number.isFinite(heartbeatIntervalMs) || heartbeatIntervalMs <= 0) {
165
+ return;
166
+ }
167
+ if (heartbeatTimer) {
168
+ return;
169
+ }
170
+ heartbeatTimer = setInterval(() => {
171
+ safeProcessSend([
172
+ "heartbeat",
173
+ {
174
+ type: "heartbeat",
175
+ "@context": buildCanonicalContext(
176
+ INTERNAL_PLATFORM_CONTEXT_URL,
177
+ ),
178
+ actor: {
179
+ id: "sockethub",
180
+ type: "platform",
181
+ },
182
+ object: {
183
+ type: "heartbeat",
184
+ timestamp: Date.now(),
185
+ },
186
+ } as ActivityStream,
187
+ ]);
188
+ }, heartbeatIntervalMs);
189
+ }
190
+
123
191
  /**
124
192
  * Type guard to check if a platform is persistent and has credentialsHash.
125
193
  */
@@ -157,6 +225,12 @@ async function startPlatformProcess() {
157
225
  process.exit(1);
158
226
  });
159
227
 
228
+ process.once("exit", () => {
229
+ if (heartbeatTimer) {
230
+ clearInterval(heartbeatTimer);
231
+ }
232
+ });
233
+
160
234
  /**
161
235
  * Incoming messages from the worker to this platform. Data is an array, the first property is the
162
236
  * method to call, the rest are params.
@@ -170,6 +244,7 @@ async function startPlatformProcess() {
170
244
  parentSecret1 = parentSecret;
171
245
  parentSecret2 = parentSecret3;
172
246
  await startQueueListener();
247
+ startHeartbeat();
173
248
  } else {
174
249
  throw new Error("received unknown command from parent thread");
175
250
  }
@@ -202,7 +277,6 @@ async function startPlatformProcess() {
202
277
  url: redisUrl,
203
278
  },
204
279
  );
205
- // biome-ignore lint/performance/noDelete: <explanation>
206
280
  delete job.msg.sessionSecret;
207
281
 
208
282
  let jobCallbackCalled = false;
@@ -223,10 +297,12 @@ async function startPlatformProcess() {
223
297
  try {
224
298
  errMsg = err.toString();
225
299
  } catch (err) {
226
- errMsg = err;
300
+ errMsg = err instanceof Error ? err : String(err);
227
301
  }
228
- sentry.reportError(new Error(errMsg as string));
229
- reject(new Error(errMsg as string));
302
+ const errorMessage =
303
+ errMsg instanceof Error ? errMsg.message : errMsg;
304
+ sentry.reportError(new Error(errorMessage));
305
+ reject(new Error(errorMessage));
230
306
  } else {
231
307
  jobLog.debug(`completed ${job.title} ${job.msg.type}`);
232
308
 
@@ -280,7 +356,21 @@ async function startPlatformProcess() {
280
356
  };
281
357
 
282
358
  // Proceed with platform method call
283
- platform[job.msg.type](
359
+ const handler = getPlatformHandler(
360
+ platform,
361
+ job.msg.type,
362
+ );
363
+ if (!handler) {
364
+ doneCallback(
365
+ new Error(
366
+ `platform method ${job.msg.type} not available`,
367
+ ),
368
+ null,
369
+ );
370
+ return;
371
+ }
372
+ (handler as PlatformHandlerWithCredentials).call(
373
+ platform,
284
374
  job.msg,
285
375
  credentials,
286
376
  wrappedCallback,
@@ -288,7 +378,7 @@ async function startPlatformProcess() {
288
378
  })
289
379
  .catch((err) => {
290
380
  // Credential store error (invalid/missing credentials)
291
- jobLog.error(`credential error ${err.toString()}`);
381
+ jobLog.error(`credential error ${String(err)}`);
292
382
 
293
383
  /**
294
384
  * Critical distinction: handle credential errors differently based on platform state.
@@ -315,11 +405,20 @@ async function startPlatformProcess() {
315
405
  */
316
406
  if (platform.isInitialized()) {
317
407
  // Platform already running - reject job only, preserve platform instance
318
- doneCallback(err, null);
408
+ doneCallback(
409
+ err instanceof Error
410
+ ? err
411
+ : new Error(String(err)),
412
+ null,
413
+ );
319
414
  } else {
320
415
  // Platform not initialized - terminate platform process
321
- sentry.reportError(err);
322
- reject(err);
416
+ const error =
417
+ err instanceof Error
418
+ ? err
419
+ : new Error(String(err));
420
+ sentry.reportError(error);
421
+ reject(error);
323
422
  }
324
423
  });
325
424
  } else if (
@@ -333,11 +432,28 @@ async function startPlatformProcess() {
333
432
  );
334
433
  } else {
335
434
  try {
336
- platform[job.msg.type](job.msg, doneCallback);
435
+ const handler = getPlatformHandler(
436
+ platform,
437
+ job.msg.type,
438
+ );
439
+ if (!handler) {
440
+ throw new Error(
441
+ `platform method ${job.msg.type} not available`,
442
+ );
443
+ }
444
+ (handler as PlatformHandler).call(
445
+ platform,
446
+ job.msg,
447
+ doneCallback,
448
+ );
337
449
  } catch (err) {
338
- jobLog.error(`platform call failed ${err.toString()}`);
339
- sentry.reportError(err);
340
- reject(err);
450
+ const error =
451
+ err instanceof Error ? err : new Error(String(err));
452
+ jobLog.error(
453
+ `platform call failed ${error.toString()}`,
454
+ );
455
+ sentry.reportError(error);
456
+ reject(error);
341
457
  }
342
458
  }
343
459
  });
@@ -2,9 +2,9 @@ import { getPlatformId } from "@sockethub/crypto";
2
2
 
3
3
  import type { IInitObject } from "./bootstrap/init.js";
4
4
  import PlatformInstance, {
5
- platformInstances,
6
- type PlatformInstanceParams,
7
5
  type MessageFromParent,
6
+ type PlatformInstanceParams,
7
+ platformInstances,
8
8
  } from "./platform-instance.js";
9
9
 
10
10
  class ProcessManager {
@@ -41,6 +41,7 @@ class ProcessManager {
41
41
  pi = this.ensureProcess(platform);
42
42
  }
43
43
  pi.config = platformDetails.config;
44
+ pi.contextUrl = platformDetails.contextUrl;
44
45
  return pi;
45
46
  }
46
47
 
@@ -74,15 +75,40 @@ class ProcessManager {
74
75
  actor?: string,
75
76
  ): PlatformInstance {
76
77
  const identifier = getPlatformId(platform, actor);
78
+ const existing = platformInstances.get(identifier);
77
79
  const platformInstance =
78
- platformInstances.get(identifier) ||
79
- this.createPlatformInstance(identifier, platform, actor);
80
+ existing && this.isProcessAlive(existing)
81
+ ? existing
82
+ : this.createPlatformInstance(identifier, platform, actor);
83
+ if (existing && existing !== platformInstance) {
84
+ void existing.shutdown();
85
+ }
80
86
  if (sessionId) {
81
87
  platformInstance.registerSession(sessionId);
82
88
  }
83
89
  platformInstances.set(identifier, platformInstance);
84
90
  return platformInstance;
85
91
  }
92
+
93
+ private isProcessAlive(platformInstance: PlatformInstance): boolean {
94
+ const pid = platformInstance.process?.pid;
95
+ if (!pid) {
96
+ return false;
97
+ }
98
+ if (platformInstance.process.exitCode !== null) {
99
+ return false;
100
+ }
101
+ try {
102
+ process.kill(pid, 0);
103
+ return true;
104
+ } catch (error) {
105
+ const err = error as NodeJS.ErrnoException;
106
+ if (err && err.code === "EPERM") {
107
+ return true;
108
+ }
109
+ return false;
110
+ }
111
+ }
86
112
  }
87
113
 
88
114
  export default ProcessManager;