bunqueue-dashboard 0.0.31 → 0.0.33

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.
Files changed (107) hide show
  1. package/README.md +44 -15
  2. package/agent/db.ts +682 -68
  3. package/agent/dbQueryWorker.ts +41 -10
  4. package/agent/index.ts +4 -1
  5. package/agent/manager.ts +267 -41
  6. package/agent/server.ts +131 -36
  7. package/dist/.vite/manifest.json +215 -159
  8. package/dist/assets/AddJob-ytpBLTCJ.js +3 -0
  9. package/dist/assets/Alerts-C39RrGbQ.js +1 -0
  10. package/dist/assets/{AreaChart-2gM0YoZT.js → AreaChart-REK4ZsTC.js} +1 -1
  11. package/dist/assets/Benchmark-CM60tHbf.js +1 -0
  12. package/dist/assets/BulkAddJobs-BjL-xcQy.js +5 -0
  13. package/dist/assets/CopilotPanel-BGK1rw-V.js +44 -0
  14. package/dist/assets/Cron-s1e-jgOo.js +1 -0
  15. package/dist/assets/CronManager-BostU7BX.js +1 -0
  16. package/dist/assets/Database-ZluStWMz.js +3 -0
  17. package/dist/assets/Diagnostics-C0N5z5j-.js +1 -0
  18. package/dist/assets/Dlq-CtcLzs-b.js +1 -0
  19. package/dist/assets/DlqControl-B_Z8KKn5.js +1 -0
  20. package/dist/assets/DlqPro-B68en6AG.js +1 -0
  21. package/dist/assets/Flows-CD_bkCBM.js +1 -0
  22. package/dist/assets/JobInspector-DwRtyMz0.js +2 -0
  23. package/dist/assets/Jobs-BUtcBKKk.js +1 -0
  24. package/dist/assets/JobsPro-BK_xd4ji.js +1 -0
  25. package/dist/assets/Logs-CmVODYCI.js +1 -0
  26. package/dist/assets/LogsPro-CJ-Il3LP.js +1 -0
  27. package/dist/assets/McpServer-dMjyJKgT.js +22 -0
  28. package/dist/assets/Metrics-Dsbeplz-.js +1 -0
  29. package/dist/assets/MetricsPro-BqxurxrJ.js +1 -0
  30. package/dist/assets/Overview-DH9C2dsf.js +1 -0
  31. package/dist/assets/{Pagination-D4ki-pGz.js → Pagination-DpxiBnt8.js} +1 -1
  32. package/dist/assets/QueueActions-D_QGBaVm.js +1 -0
  33. package/dist/assets/QueueControl-V0DJ6qk6.js +1 -0
  34. package/dist/assets/QueueDetail-BgKAMJqf.js +1 -0
  35. package/dist/assets/QueueDetailPro-BkHYzGiq.js +1 -0
  36. package/dist/assets/Queues-2_v7DcNi.js +1 -0
  37. package/dist/assets/QueuesOverview-BySSkmB4.js +1 -0
  38. package/dist/assets/S3Backup-CPADzKQE.js +1 -0
  39. package/dist/assets/S3BackupPro-CSrQYIDL.js +2 -0
  40. package/dist/assets/ServerControl-C0tI1VDx.js +3 -0
  41. package/dist/assets/Settings-CSzoM5up.js +1 -0
  42. package/dist/assets/Usage-B4B7tmrc.js +1 -0
  43. package/dist/assets/UsagePro-CCBpgSDy.js +1 -0
  44. package/dist/assets/Webhooks-aJz-GisQ.js +1 -0
  45. package/dist/assets/Workers-Bg8xKjkg.js +1 -0
  46. package/dist/assets/WorkersPro-BOOCrXf8.js +1 -0
  47. package/dist/assets/connectionStore-Cd9-3Fw0.js +1 -0
  48. package/dist/assets/{exportFile-tdUeW5m5.js → exportFile-C7xhE8Yh.js} +1 -1
  49. package/dist/assets/flowMutationSafety-DVD1IMr-.js +1 -0
  50. package/dist/assets/form-DsUq0w8s.js +1 -0
  51. package/dist/assets/index-BWoUj86M.css +1 -0
  52. package/dist/assets/index-Bn2R06qm.js +8 -0
  53. package/dist/assets/{install-DhTYJCUC.js → install-CUS8Kijb.js} +2 -2
  54. package/dist/assets/jobActions-LQD4KfCc.js +1 -0
  55. package/dist/assets/promisePool-BVfQ9n9Q.js +1 -0
  56. package/dist/assets/{react-vendor-BcouBxpZ.js → react-vendor-Bjui4wS4.js} +1 -1
  57. package/dist/assets/upstreamPaths-nG8N1XAz.js +1 -0
  58. package/dist/assets/useServerActionGuard-CItj3hvh.js +1 -0
  59. package/dist/assets/useThroughputSeries-tb26_lkb.js +1 -0
  60. package/dist/index.html +5 -4
  61. package/package.json +11 -3
  62. package/scripts/embedded.gen.ts +132 -126
  63. package/scripts/serve.ts +501 -108
  64. package/dist/assets/AddJob-CAhdg38n.js +0 -3
  65. package/dist/assets/Alerts-70bxf-PT.js +0 -1
  66. package/dist/assets/Benchmark-CFZ3WCvj.js +0 -1
  67. package/dist/assets/BulkAddJobs-BzgzpGg_.js +0 -5
  68. package/dist/assets/CopilotPanel-DZUGIf1q.js +0 -44
  69. package/dist/assets/Cron-ltjSoTjj.js +0 -1
  70. package/dist/assets/CronManager-BcFALDeT.js +0 -1
  71. package/dist/assets/Database-CV2yt5O2.js +0 -3
  72. package/dist/assets/Diagnostics-D-EK4GEi.js +0 -1
  73. package/dist/assets/Dlq-fZAKwQoC.js +0 -1
  74. package/dist/assets/DlqControl-eTrrQCrn.js +0 -1
  75. package/dist/assets/DlqPro-CF79339e.js +0 -1
  76. package/dist/assets/Flows-Ba8G7mcC.js +0 -1
  77. package/dist/assets/JobInspector--d_8ca9p.js +0 -2
  78. package/dist/assets/Jobs-DEqAc1A5.js +0 -1
  79. package/dist/assets/JobsPro-BcaokZIf.js +0 -1
  80. package/dist/assets/Logs-DBTukPIX.js +0 -1
  81. package/dist/assets/LogsPro-Wl_e6hZM.js +0 -1
  82. package/dist/assets/McpServer-CnWJsKw7.js +0 -22
  83. package/dist/assets/Metrics-DzeIhNL-.js +0 -1
  84. package/dist/assets/MetricsPro-O9eVJSH0.js +0 -1
  85. package/dist/assets/Overview-Bnu7rWqt.js +0 -1
  86. package/dist/assets/QueueActions-DHoSkWRz.js +0 -1
  87. package/dist/assets/QueueControl-DKqEXnkM.js +0 -1
  88. package/dist/assets/QueueDetail-DLRow04B.js +0 -1
  89. package/dist/assets/QueueDetailPro-BYmOVd4T.js +0 -1
  90. package/dist/assets/Queues-CoyjRFN7.js +0 -1
  91. package/dist/assets/QueuesOverview-BeWdJpKi.js +0 -1
  92. package/dist/assets/S3Backup-BaLFgl9d.js +0 -1
  93. package/dist/assets/S3BackupPro-DJB4oWgz.js +0 -1
  94. package/dist/assets/ServerControl-_0lVB33E.js +0 -3
  95. package/dist/assets/Settings-zKFJTtnq.js +0 -1
  96. package/dist/assets/Usage-CKGkvl0j.js +0 -1
  97. package/dist/assets/UsagePro-B-TJ6fp4.js +0 -1
  98. package/dist/assets/Webhooks-C3zmvY-_.js +0 -1
  99. package/dist/assets/Workers-jKhhXl3W.js +0 -1
  100. package/dist/assets/WorkersPro-mvY5VX8I.js +0 -1
  101. package/dist/assets/connectionStore-Ci04lJmR.js +0 -1
  102. package/dist/assets/form-DGinTEEb.js +0 -1
  103. package/dist/assets/index-BOcegTHa.js +0 -8
  104. package/dist/assets/index-DWV3tgrk.css +0 -1
  105. package/dist/assets/jobActions-CuOdMglt.js +0 -1
  106. package/dist/assets/promisePool-Daxkc1OB.js +0 -1
  107. package/dist/assets/useThroughputSeries-Chp5OLx3.js +0 -1
@@ -1,21 +1,52 @@
1
1
  /**
2
- * Disposable worker that runs ONE read-only query and posts the result back.
2
+ * Disposable worker that runs ONE read-only database operation and posts the
3
+ * result back: an arbitrary query or an atomic table CSV export.
3
4
  *
4
- * queryWithTimeout() (agent/db.ts) spawns one of these per /db/query request and
5
- * races it against a wall clock; on timeout the parent terminate()s this worker,
6
- * so a runaway O(N^k) scan is killed instead of pinning the agent's only thread
7
- * and freezing the process-control endpoints. The connection is opened readonly
8
- * inside dbQuery, so nothing here can mutate the store.
5
+ * queryWithTimeout() and exportWithTimeout() (agent/db.ts) spawn one of these
6
+ * per request and race it against a wall clock, so a runaway scan/sort can't
7
+ * freeze the agent's process-control endpoints. terminate() does NOT preempt a
8
+ * synchronous sqlite3_step, though — it abandons the response, and this thread
9
+ * keeps running until SQLite finishes, which is why db.ts caps how many can be
10
+ * alive at once. Both dbQuery and dbExportCsv open readonly connections, so
11
+ * nothing here can mutate the store.
9
12
  */
10
- import { dbQuery } from './db';
13
+ import { type DbFilter, dbExportCsv, dbQuery, MissingDbError } from './db';
11
14
 
12
15
  declare const self: Worker;
13
16
 
14
17
  self.addEventListener('message', (ev: MessageEvent) => {
15
- const { path, sql } = ev.data as { path: string; sql: string };
18
+ const request = ev.data as
19
+ | { kind?: 'query'; path: string; sql: string }
20
+ | {
21
+ kind: 'export';
22
+ path: string;
23
+ table: string;
24
+ orderBy?: string;
25
+ dir: 'asc' | 'desc';
26
+ filter?: DbFilter;
27
+ };
16
28
  try {
17
- self.postMessage({ ok: true, result: dbQuery(path, sql) });
29
+ if (request.kind === 'export') {
30
+ const exported = dbExportCsv(
31
+ request.path,
32
+ request.table,
33
+ request.orderBy,
34
+ request.dir,
35
+ request.filter
36
+ );
37
+ // Transfer the bounded CSV buffer instead of cloning it or JSON-encoding
38
+ // up to 16 MiB a second time in either isolate.
39
+ self.postMessage({ ok: true, export: exported }, [exported.content.buffer]);
40
+ } else {
41
+ self.postMessage({ ok: true, result: dbQuery(request.path, request.sql) });
42
+ }
18
43
  } catch (e) {
19
- self.postMessage({ ok: false, error: (e as Error).message ?? String(e) });
44
+ // postMessage can't carry the error class, so flag the one the route branches
45
+ // on (MissingDbError → 404) and let db.ts rebuild it on the parent side.
46
+ self.postMessage({
47
+ ok: false,
48
+ error: (e as Error).message ?? String(e),
49
+ missing: e instanceof MissingDbError,
50
+ });
20
51
  }
21
52
  });
package/agent/index.ts CHANGED
@@ -41,7 +41,10 @@ const shutdown = (signal: string) => {
41
41
  if (shuttingDown) return;
42
42
  shuttingDown = true;
43
43
  logger.info({ signal }, 'signal received, stopping managed server');
44
- void mgr.stop().finally(() => process.exit(0));
44
+ // shutdown() (not stop()) latches the manager closed first: a plain stop()
45
+ // racing an in-flight restart() returns successfully *because* restart's
46
+ // start() already spawned a replacement — which process.exit() would orphan.
47
+ void mgr.shutdown().finally(() => process.exit(0));
45
48
  };
46
49
  process.on('SIGINT', () => shutdown('SIGINT'));
47
50
  process.on('SIGTERM', () => shutdown('SIGTERM'));
package/agent/manager.ts CHANGED
@@ -48,6 +48,17 @@ export interface StatusSnapshot {
48
48
 
49
49
  const MAX_LOGS = 800;
50
50
  const STOP_TIMEOUT_MS = 8000;
51
+ /**
52
+ * Hard byte cap for a single log line. The ring buffer trims by COUNT, so a
53
+ * child that dumps a large blob without a newline (binary file, one-line JSON)
54
+ * would otherwise grow the reader's in-flight buffer — and then one retained
55
+ * LogLine — without limit.
56
+ */
57
+ const MAX_LINE = 8192;
58
+
59
+ interface CancelablePipeReader {
60
+ cancel(reason?: unknown): Promise<void>;
61
+ }
51
62
 
52
63
  function defaultConfig(): ServerConfig {
53
64
  return {
@@ -59,6 +70,92 @@ function defaultConfig(): ServerConfig {
59
70
  };
60
71
  }
61
72
 
73
+ const CONFIG_KEYS = new Set<keyof ServerConfig>([
74
+ 'command',
75
+ 'httpPort',
76
+ 'tcpPort',
77
+ 'dataPath',
78
+ 'extraEnv',
79
+ ]);
80
+
81
+ function isRecord(value: unknown): value is Record<string, unknown> {
82
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return false;
83
+ const proto = Object.getPrototypeOf(value);
84
+ return proto === Object.prototype || proto === null;
85
+ }
86
+
87
+ function validatePort(name: 'httpPort' | 'tcpPort', value: unknown): number {
88
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < 1 || value > 65_535) {
89
+ throw new Error(`${name} must be an integer between 1 and 65535`);
90
+ }
91
+ return value;
92
+ }
93
+
94
+ /**
95
+ * Validate an untrusted PUT /control/config body before any field is merged.
96
+ * Returning a fresh object (including a fresh extraEnv) makes setConfig atomic:
97
+ * one invalid field cannot leave earlier fields applied, and the caller cannot
98
+ * mutate the accepted config later through a retained object reference.
99
+ */
100
+ export function validateConfigPatch(value: unknown): Partial<ServerConfig> {
101
+ if (!isRecord(value)) throw new Error('Config must be an object');
102
+
103
+ for (const key of Object.keys(value)) {
104
+ if (!CONFIG_KEYS.has(key as keyof ServerConfig)) {
105
+ throw new Error(`Unknown config key: ${key}`);
106
+ }
107
+ }
108
+
109
+ const patch: Partial<ServerConfig> = {};
110
+ if (Object.hasOwn(value, 'command')) {
111
+ if (typeof value.command !== 'string' || value.command.trim() === '') {
112
+ throw new Error('command must be a non-empty string');
113
+ }
114
+ patch.command = value.command;
115
+ }
116
+ if (Object.hasOwn(value, 'httpPort')) {
117
+ patch.httpPort = validatePort('httpPort', value.httpPort);
118
+ }
119
+ if (Object.hasOwn(value, 'tcpPort')) {
120
+ patch.tcpPort = validatePort('tcpPort', value.tcpPort);
121
+ }
122
+ if (Object.hasOwn(value, 'dataPath')) {
123
+ if (typeof value.dataPath !== 'string') throw new Error('dataPath must be a string');
124
+ patch.dataPath = value.dataPath;
125
+ }
126
+ if (Object.hasOwn(value, 'extraEnv')) {
127
+ if (!isRecord(value.extraEnv)) {
128
+ throw new Error('extraEnv must be an object containing only string values');
129
+ }
130
+ const entries = Object.entries(value.extraEnv);
131
+ for (const [key, entry] of entries) {
132
+ if (typeof entry !== 'string') {
133
+ throw new Error(`extraEnv.${key} must be a string`);
134
+ }
135
+ }
136
+ // fromEntries defines "__proto__" as ordinary data instead of invoking the
137
+ // legacy Object.prototype setter via indexed assignment.
138
+ patch.extraEnv = Object.fromEntries(entries) as Record<string, string>;
139
+ }
140
+ return patch;
141
+ }
142
+
143
+ /** Validate the complete snapshot start() is about to pass to the child. */
144
+ function validateServerConfig(value: unknown): ServerConfig {
145
+ if (!isRecord(value)) throw new Error('Config must be an object');
146
+ const config = validateConfigPatch(value);
147
+ for (const key of CONFIG_KEYS) {
148
+ if (!Object.hasOwn(value, key)) {
149
+ throw new Error(`Missing config key: ${key}`);
150
+ }
151
+ }
152
+ return config as ServerConfig;
153
+ }
154
+
155
+ function copyConfig(config: ServerConfig): ServerConfig {
156
+ return { ...config, extraEnv: { ...config.extraEnv } };
157
+ }
158
+
62
159
  export class ProcessManager {
63
160
  private proc: ReturnType<typeof Bun.spawn> | null = null;
64
161
  private status: Status = 'stopped';
@@ -75,9 +172,36 @@ export class ProcessManager {
75
172
  * a concurrent start() brought up (and vice-versa).
76
173
  */
77
174
  private procToken = 0;
175
+ /**
176
+ * The stop() currently in flight (null when none). A stop() still owns a live
177
+ * child until its await resolves, so start() waits on this before spawning —
178
+ * otherwise a start racing a stop leaves two managed servers alive at once,
179
+ * fighting over the ports and the SQLite db.
180
+ */
181
+ private stopping: Promise<StatusSnapshot> | null = null;
182
+ /** Latched by shutdown(): once set, start() refuses to spawn a new child. */
183
+ private shuttingDown = false;
184
+ /** Active stdout/stderr readers, grouped by the process generation they own. */
185
+ private pipeReaders = new Map<number, Set<CancelablePipeReader>>();
186
+
187
+ /**
188
+ * Actively break pending reader.read() calls for a finished generation. A
189
+ * descendant may inherit the child's pipe descriptor and keep it open after
190
+ * the managed process exits; token checks alone cannot release that reader.
191
+ */
192
+ private cancelPipes(token: number): void {
193
+ const readers = this.pipeReaders.get(token);
194
+ if (!readers) return;
195
+ this.pipeReaders.delete(token);
196
+ for (const reader of readers) {
197
+ void reader.cancel('process generation ended').catch(() => {
198
+ /* already closed/cancelled */
199
+ });
200
+ }
201
+ }
78
202
 
79
203
  getConfig(): ServerConfig {
80
- return this.config;
204
+ return copyConfig(this.config);
81
205
  }
82
206
 
83
207
  /**
@@ -87,8 +211,13 @@ export class ProcessManager {
87
211
  * changed in place.
88
212
  */
89
213
  setConfig(patch: Partial<ServerConfig>): ServerConfig {
90
- this.config = { ...this.config, ...patch, extraEnv: patch.extraEnv ?? this.config.extraEnv };
91
- return this.config;
214
+ const valid = validateConfigPatch(patch);
215
+ this.config = {
216
+ ...this.config,
217
+ ...valid,
218
+ extraEnv: valid.extraEnv ?? this.config.extraEnv,
219
+ };
220
+ return copyConfig(this.config);
92
221
  }
93
222
 
94
223
  getStatus(): StatusSnapshot {
@@ -97,8 +226,8 @@ export class ProcessManager {
97
226
  pid: this.proc?.pid ?? null,
98
227
  startedAt: this.startedAt,
99
228
  exitCode: this.exitCode,
100
- config: this.config,
101
- runningConfig: this.runningConfig,
229
+ config: copyConfig(this.config),
230
+ runningConfig: this.runningConfig ? copyConfig(this.runningConfig) : null,
102
231
  };
103
232
  }
104
233
 
@@ -135,31 +264,50 @@ export class ProcessManager {
135
264
  }
136
265
 
137
266
  private push(stream: LogLine['stream'], line: string): void {
138
- this.logs.push({ seq: this.seq++, ts: Date.now(), stream, line });
267
+ // Backstop byte cap (the reader also splits oversized chunks) so no single
268
+ // entry can pin an unbounded amount of memory in the ring buffer.
269
+ const capped = line.length > MAX_LINE ? `${line.slice(0, MAX_LINE)}…[truncated]` : line;
270
+ this.logs.push({ seq: this.seq++, ts: Date.now(), stream, line: capped });
139
271
  // Amortized trim: only splice once the buffer overshoots by a slack margin, so
140
272
  // steady-state logging isn't an O(n) array shift on every single line.
141
273
  if (this.logs.length > MAX_LOGS + 256) this.logs.splice(0, this.logs.length - MAX_LOGS);
142
274
  }
143
275
 
144
276
  async start(): Promise<StatusSnapshot> {
277
+ if (this.shuttingDown) return this.getStatus();
145
278
  if (this.status === 'running' || this.status === 'starting') return this.getStatus();
279
+ // Never spawn while a stop() still owns a live child: wait it out, then
280
+ // re-check the guards (a concurrent start may have won the race meanwhile).
281
+ if (this.stopping) await this.stopping;
282
+ if (this.shuttingDown) return this.getStatus();
283
+ // Read through a local: TS narrowed `this.status` at the guard above and
284
+ // doesn't widen it across the await, but a concurrent start() can have
285
+ // moved it in the meantime — that interleaving is exactly what this checks.
286
+ const statusAfterWait = this.status as Status;
287
+ if (statusAfterWait === 'running' || statusAfterWait === 'starting') return this.getStatus();
288
+
289
+ // Validate the COMPLETE launch snapshot BEFORE mutating status / burning a
290
+ // proc token. setConfig() already validates API patches, but defaultConfig()
291
+ // also reads environment values; e.g. HTTP_PORT=70000 must not reach spawn.
292
+ // Doing this later can wedge status at 'starting' and strand stale process
293
+ // metadata when validation throws.
294
+ const launchConfig = validateServerConfig(this.config);
295
+ const parts = launchConfig.command.trim().split(/\s+/);
296
+ const [cmd, ...args] = parts;
297
+
146
298
  this.status = 'starting';
147
299
  this.exitCode = null;
148
300
  const token = ++this.procToken;
149
301
 
150
- const parts = this.config.command.trim().split(/\s+/);
151
- const [cmd, ...args] = parts;
152
- if (!cmd) {
153
- this.status = 'stopped';
154
- throw new Error('Empty command');
155
- }
156
-
302
+ // extraEnv is spread FIRST so the explicit port/data-path config always
303
+ // wins: otherwise a user env var named HTTP_PORT silently moved the child
304
+ // while runningConfig (and the agent's /health probe) reported the old port.
157
305
  const env: Record<string, string> = {
158
306
  ...(process.env as Record<string, string>),
159
- HTTP_PORT: String(this.config.httpPort),
160
- TCP_PORT: String(this.config.tcpPort),
161
- BUNQUEUE_DATA_PATH: this.config.dataPath,
162
- ...this.config.extraEnv,
307
+ ...launchConfig.extraEnv,
308
+ HTTP_PORT: String(launchConfig.httpPort),
309
+ TCP_PORT: String(launchConfig.tcpPort),
310
+ BUNQUEUE_DATA_PATH: launchConfig.dataPath,
163
311
  };
164
312
 
165
313
  try {
@@ -168,6 +316,9 @@ export class ProcessManager {
168
316
  stdout: 'pipe',
169
317
  stderr: 'pipe',
170
318
  onExit: (_p, code) => {
319
+ // Always tear down this generation's readers, even if shared state
320
+ // already belongs to a replacement process.
321
+ this.cancelPipes(token);
171
322
  // Ignore the exit of a process a newer start() has already replaced.
172
323
  if (this.procToken !== token) return;
173
324
  this.exitCode = code ?? null;
@@ -190,42 +341,95 @@ export class ProcessManager {
190
341
 
191
342
  this.startedAt = Date.now();
192
343
  this.status = 'running';
193
- this.runningConfig = { ...this.config, extraEnv: { ...this.config.extraEnv } };
194
- this.push('sys', `started: ${this.config.command} (pid ${this.proc.pid})`);
344
+ this.runningConfig = copyConfig(launchConfig);
345
+ this.push('sys', `started: ${launchConfig.command} (pid ${this.proc.pid})`);
195
346
  // stdout/stderr are spawned as pipes, so they are ReadableStreams at
196
347
  // runtime; Bun types them as a union that also includes a numeric fd.
197
- void this.pipe(this.proc.stdout as unknown as ReadableStream<Uint8Array>, 'stdout');
198
- void this.pipe(this.proc.stderr as unknown as ReadableStream<Uint8Array>, 'stderr');
348
+ void this.pipe(this.proc.stdout as unknown as ReadableStream<Uint8Array>, 'stdout', token);
349
+ void this.pipe(this.proc.stderr as unknown as ReadableStream<Uint8Array>, 'stderr', token);
199
350
  return this.getStatus();
200
351
  }
201
352
 
202
- private async pipe(stream: ReadableStream<Uint8Array>, name: 'stdout' | 'stderr'): Promise<void> {
353
+ private async pipe(
354
+ stream: ReadableStream<Uint8Array>,
355
+ name: 'stdout' | 'stderr',
356
+ token: number
357
+ ): Promise<void> {
203
358
  const reader = stream.getReader();
359
+ let readers = this.pipeReaders.get(token);
360
+ if (!readers) {
361
+ readers = new Set();
362
+ this.pipeReaders.set(token, readers);
363
+ }
364
+ readers.add(reader);
204
365
  const decoder = new TextDecoder();
205
366
  let buffer = '';
206
367
  try {
207
- while (true) {
208
- const { done, value } = await reader.read();
209
- if (done) break;
210
- buffer += decoder.decode(value, { stream: true });
211
- let i: number;
212
- while ((i = buffer.indexOf('\n')) !== -1) {
213
- const line = buffer.slice(0, i);
214
- buffer = buffer.slice(i + 1);
215
- if (line.trim()) this.push(name, line);
368
+ try {
369
+ while (true) {
370
+ const { done, value } = await reader.read();
371
+ // A process can exit while a descendant keeps its inherited pipe
372
+ // open. If a replacement has started by the time bytes arrive, those
373
+ // bytes belong to the obsolete generation and must never enter the
374
+ // current log view.
375
+ if (this.procToken !== token) return;
376
+ if (done) break;
377
+ buffer += decoder.decode(value, { stream: true });
378
+ let i: number;
379
+ while ((i = buffer.indexOf('\n')) !== -1) {
380
+ const line = buffer.slice(0, i);
381
+ buffer = buffer.slice(i + 1);
382
+ if (line.trim() && this.procToken === token) this.push(name, line);
383
+ }
384
+ // No newline in sight: flush in MAX_LINE slices instead of letting the
385
+ // buffer (and the eventual single LogLine) grow to the whole output.
386
+ while (buffer.length > MAX_LINE) {
387
+ if (this.procToken !== token) return;
388
+ this.push(name, buffer.slice(0, MAX_LINE));
389
+ buffer = buffer.slice(MAX_LINE);
390
+ }
216
391
  }
392
+ } catch {
393
+ /* stream closed */
217
394
  }
218
- } catch {
219
- /* stream closed */
395
+ if (this.procToken !== token) return;
396
+ // Flush the final chunk when the stream ends without a trailing newline
397
+ // (e.g. a crash cause written via a bare write()) — otherwise the last,
398
+ // often most important, line never reaches the log buffer.
399
+ const tail = buffer + decoder.decode();
400
+ if (tail.trim() && this.procToken === token) this.push(name, tail);
401
+ } finally {
402
+ const activeReaders = this.pipeReaders.get(token);
403
+ activeReaders?.delete(reader);
404
+ if (activeReaders?.size === 0) this.pipeReaders.delete(token);
405
+ reader.releaseLock();
220
406
  }
221
- // Flush the final chunk when the stream ends without a trailing newline
222
- // (e.g. a crash cause written via a bare write()) — otherwise the last,
223
- // often most important, line never reaches the log buffer.
224
- const tail = buffer + decoder.decode();
225
- if (tail.trim()) this.push(name, tail);
226
407
  }
227
408
 
228
409
  async stop(): Promise<StatusSnapshot> {
410
+ // Publish the in-flight stop so a racing start() can await it (see
411
+ // `stopping`). stopOnce() runs its synchronous prefix — including
412
+ // status='stopping' and the SIGTERM — before we return the promise.
413
+ const p = this.stopOnce();
414
+ this.stopping = p;
415
+ try {
416
+ return await p;
417
+ } finally {
418
+ if (this.stopping === p) this.stopping = null;
419
+ }
420
+ }
421
+
422
+ /**
423
+ * Stop the managed server and latch the manager closed, so a start() racing
424
+ * process shutdown (e.g. an in-flight restart() whose stop() resolves after
425
+ * SIGINT) cannot spawn a child that would be orphaned by process.exit().
426
+ */
427
+ async shutdown(): Promise<StatusSnapshot> {
428
+ this.shuttingDown = true;
429
+ return this.stop();
430
+ }
431
+
432
+ private async stopOnce(): Promise<StatusSnapshot> {
229
433
  if (!this.proc) {
230
434
  this.status = 'stopped';
231
435
  this.runningConfig = null;
@@ -236,18 +440,40 @@ export class ProcessManager {
236
440
  const proc = this.proc;
237
441
  const token = this.procToken;
238
442
  proc.kill();
443
+ // Timer cleared either way (this is the COMMON path): a pending one keeps
444
+ // the event loop alive for the full timeout after the race is decided.
445
+ let stopTimer: ReturnType<typeof setTimeout> | undefined;
239
446
  const timedOut = await Promise.race([
240
447
  proc.exited.then(() => false),
241
- new Promise<boolean>((r) => setTimeout(() => r(true), STOP_TIMEOUT_MS)),
242
- ]);
448
+ new Promise<boolean>((r) => {
449
+ stopTimer = setTimeout(() => r(true), STOP_TIMEOUT_MS);
450
+ }),
451
+ ]).finally(() => clearTimeout(stopTimer));
243
452
  // Always escalate to SIGKILL on the CAPTURED proc (never `this.proc`) so a
244
453
  // child that ignores SIGTERM is still reaped even if a concurrent start()
245
454
  // has since swapped in a new process — `proc` targets this generation only.
246
455
  if (timedOut) {
247
456
  this.push('sys', 'SIGTERM timed out, sending SIGKILL');
248
457
  proc.kill(9);
249
- await proc.exited;
458
+ // Bounded: a child wedged in uninterruptible sleep never resolves
459
+ // `exited` even after SIGKILL, and start() now awaits this promise, so an
460
+ // unbounded wait here would pin the manager at 'stopping' and hang every
461
+ // later start() forever. Give up on the wait (not on the kill) and let the
462
+ // state below finalize — the pid is already unusable. The timer is always
463
+ // cleared: a pending one keeps the event loop alive after the race is
464
+ // decided, which delays process exit for the whole timeout.
465
+ let reapTimer: ReturnType<typeof setTimeout> | undefined;
466
+ const reaped = await Promise.race([
467
+ proc.exited.then(() => true),
468
+ new Promise<boolean>((r) => {
469
+ reapTimer = setTimeout(() => r(false), STOP_TIMEOUT_MS);
470
+ }),
471
+ ]).finally(() => clearTimeout(reapTimer));
472
+ if (!reaped) this.push('sys', `pid ${proc.pid} did not exit after SIGKILL — giving up on it`);
250
473
  }
474
+ // onExit normally performs this cancellation. Repeat it idempotently for
475
+ // a process whose `exited` promise/callback never settles after SIGKILL.
476
+ this.cancelPipes(token);
251
477
  // Only finalize shared state if no newer start() replaced this generation
252
478
  // while we awaited — otherwise we'd null out the wrong (live) process.
253
479
  if (this.procToken !== token) return this.getStatus();