pmxtjs 2.25.2 → 2.26.0

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.
@@ -31,6 +31,29 @@ export class ServerManager {
31
31
  private lockPath: string;
32
32
  private static readonly DEFAULT_PORT = 3847;
33
33
 
34
+ // Process-wide coalescing of concurrent ensureServerRunning() calls.
35
+ //
36
+ // Each `Exchange` instance constructs its own ServerManager and each one
37
+ // kicks off ensureServerRunning() from its constructor. Without
38
+ // coalescing, N Exchange instances created in parallel all see "no
39
+ // server running", all spawn their own sidecar via pmxt-ensure-server,
40
+ // and the lock file ends up pointing at whichever spawn wrote last. Each
41
+ // Exchange instance has already captured its own basePath at
42
+ // construction time, so most of them end up talking to a sidecar whose
43
+ // access token does NOT match the token they later read from the lock
44
+ // file — every request returns 401 Unauthorized.
45
+ //
46
+ // The fix is process-wide: when a ensureServerRunning call is in
47
+ // flight, all subsequent callers await the same promise. After the
48
+ // in-flight call settles (success OR failure) the cache is cleared so
49
+ // later callers can re-check the sidecar state (e.g. if it was killed
50
+ // by the user between ticks).
51
+ //
52
+ // This is static on purpose — all ServerManager instances in the
53
+ // process share the same sidecar and the same lock file, so they must
54
+ // share the same in-flight promise.
55
+ private static ensurePromise: Promise<void> | null = null;
56
+
34
57
  constructor(options: ServerManagerOptions = {}) {
35
58
  this.baseUrl = options.baseUrl || `http://localhost:${ServerManager.DEFAULT_PORT}`;
36
59
  this.maxRetries = options.maxRetries || 30;
@@ -124,8 +147,22 @@ export class ServerManager {
124
147
 
125
148
  /**
126
149
  * Ensure the server is running, starting it if necessary.
150
+ *
151
+ * Concurrent calls across all ServerManager instances in the process
152
+ * are coalesced onto a single in-flight promise. See the comment on
153
+ * `ServerManager.ensurePromise` for why this matters.
127
154
  */
128
155
  async ensureServerRunning(): Promise<void> {
156
+ if (ServerManager.ensurePromise) {
157
+ return ServerManager.ensurePromise;
158
+ }
159
+ ServerManager.ensurePromise = this.doEnsureServerRunning().finally(() => {
160
+ ServerManager.ensurePromise = null;
161
+ });
162
+ return ServerManager.ensurePromise;
163
+ }
164
+
165
+ private async doEnsureServerRunning(): Promise<void> {
129
166
  // Check for force restart
130
167
  if (process.env.PMXT_ALWAYS_RESTART === '1') {
131
168
  await this.killOldServer();
@@ -235,6 +272,78 @@ export class ServerManager {
235
272
  await this.ensureServerRunning();
236
273
  }
237
274
 
275
+ /**
276
+ * Start the server if it is not already running.
277
+ *
278
+ * Idempotent: if the server is already running and healthy this returns
279
+ * immediately without restarting.
280
+ */
281
+ async start(): Promise<void> {
282
+ await this.ensureServerRunning();
283
+ }
284
+
285
+ /**
286
+ * Get a structured snapshot of the sidecar server state.
287
+ *
288
+ * Returns a fresh object on every call (no shared mutable state).
289
+ */
290
+ async status(): Promise<{
291
+ running: boolean;
292
+ pid: number | null;
293
+ port: number | null;
294
+ version: string | null;
295
+ uptimeSeconds: number | null;
296
+ lockFile: string;
297
+ }> {
298
+ const info = this.getServerInfo();
299
+ const running = await this.isServerRunning();
300
+
301
+ let uptimeSeconds: number | null = null;
302
+ if (info && typeof info.timestamp === "number") {
303
+ const nowSeconds = Date.now() / 1000;
304
+ const tsSeconds = info.timestamp > 1e12 ? info.timestamp / 1000 : info.timestamp;
305
+ const delta = nowSeconds - tsSeconds;
306
+ if (delta >= 0) uptimeSeconds = delta;
307
+ }
308
+
309
+ return {
310
+ running,
311
+ pid: info?.pid ?? null,
312
+ port: info?.port ?? null,
313
+ version: info?.version ?? null,
314
+ uptimeSeconds,
315
+ lockFile: this.lockPath,
316
+ };
317
+ }
318
+
319
+ /**
320
+ * Check whether the server's /health endpoint is currently responsive.
321
+ */
322
+ async health(): Promise<boolean> {
323
+ return this.isServerRunning();
324
+ }
325
+
326
+ /**
327
+ * Return the last `n` lines from the sidecar server log file.
328
+ *
329
+ * The launcher writes server stdout/stderr to ~/.pmxt/server.log.
330
+ * Returns an empty array if no log file is present.
331
+ */
332
+ logs(n: number = 50): string[] {
333
+ if (n <= 0) return [];
334
+ const logPath = join(dirname(this.lockPath), "server.log");
335
+ try {
336
+ if (!existsSync(logPath)) return [];
337
+ const content = readFileSync(logPath, "utf-8");
338
+ const lines = content.split(/\r?\n/);
339
+ // split on a trailing newline produces an empty final element; drop it
340
+ if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
341
+ return lines.length > n ? lines.slice(lines.length - n) : lines;
342
+ } catch {
343
+ return [];
344
+ }
345
+ }
346
+
238
347
  private async killOldServer(): Promise<void> {
239
348
  const info = this.getServerInfo();
240
349
  if (info && info.pid) {