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.
- package/README.md +44 -15
- package/agent/db.ts +682 -68
- package/agent/dbQueryWorker.ts +41 -10
- package/agent/index.ts +4 -1
- package/agent/manager.ts +267 -41
- package/agent/server.ts +131 -36
- package/dist/.vite/manifest.json +215 -159
- package/dist/assets/AddJob-ytpBLTCJ.js +3 -0
- package/dist/assets/Alerts-C39RrGbQ.js +1 -0
- package/dist/assets/{AreaChart-2gM0YoZT.js → AreaChart-REK4ZsTC.js} +1 -1
- package/dist/assets/Benchmark-CM60tHbf.js +1 -0
- package/dist/assets/BulkAddJobs-BjL-xcQy.js +5 -0
- package/dist/assets/CopilotPanel-BGK1rw-V.js +44 -0
- package/dist/assets/Cron-s1e-jgOo.js +1 -0
- package/dist/assets/CronManager-BostU7BX.js +1 -0
- package/dist/assets/Database-ZluStWMz.js +3 -0
- package/dist/assets/Diagnostics-C0N5z5j-.js +1 -0
- package/dist/assets/Dlq-CtcLzs-b.js +1 -0
- package/dist/assets/DlqControl-B_Z8KKn5.js +1 -0
- package/dist/assets/DlqPro-B68en6AG.js +1 -0
- package/dist/assets/Flows-CD_bkCBM.js +1 -0
- package/dist/assets/JobInspector-DwRtyMz0.js +2 -0
- package/dist/assets/Jobs-BUtcBKKk.js +1 -0
- package/dist/assets/JobsPro-BK_xd4ji.js +1 -0
- package/dist/assets/Logs-CmVODYCI.js +1 -0
- package/dist/assets/LogsPro-CJ-Il3LP.js +1 -0
- package/dist/assets/McpServer-dMjyJKgT.js +22 -0
- package/dist/assets/Metrics-Dsbeplz-.js +1 -0
- package/dist/assets/MetricsPro-BqxurxrJ.js +1 -0
- package/dist/assets/Overview-DH9C2dsf.js +1 -0
- package/dist/assets/{Pagination-D4ki-pGz.js → Pagination-DpxiBnt8.js} +1 -1
- package/dist/assets/QueueActions-D_QGBaVm.js +1 -0
- package/dist/assets/QueueControl-V0DJ6qk6.js +1 -0
- package/dist/assets/QueueDetail-BgKAMJqf.js +1 -0
- package/dist/assets/QueueDetailPro-BkHYzGiq.js +1 -0
- package/dist/assets/Queues-2_v7DcNi.js +1 -0
- package/dist/assets/QueuesOverview-BySSkmB4.js +1 -0
- package/dist/assets/S3Backup-CPADzKQE.js +1 -0
- package/dist/assets/S3BackupPro-CSrQYIDL.js +2 -0
- package/dist/assets/ServerControl-C0tI1VDx.js +3 -0
- package/dist/assets/Settings-CSzoM5up.js +1 -0
- package/dist/assets/Usage-B4B7tmrc.js +1 -0
- package/dist/assets/UsagePro-CCBpgSDy.js +1 -0
- package/dist/assets/Webhooks-aJz-GisQ.js +1 -0
- package/dist/assets/Workers-Bg8xKjkg.js +1 -0
- package/dist/assets/WorkersPro-BOOCrXf8.js +1 -0
- package/dist/assets/connectionStore-Cd9-3Fw0.js +1 -0
- package/dist/assets/{exportFile-tdUeW5m5.js → exportFile-C7xhE8Yh.js} +1 -1
- package/dist/assets/flowMutationSafety-DVD1IMr-.js +1 -0
- package/dist/assets/form-DsUq0w8s.js +1 -0
- package/dist/assets/index-BWoUj86M.css +1 -0
- package/dist/assets/index-Bn2R06qm.js +8 -0
- package/dist/assets/{install-DhTYJCUC.js → install-CUS8Kijb.js} +2 -2
- package/dist/assets/jobActions-LQD4KfCc.js +1 -0
- package/dist/assets/promisePool-BVfQ9n9Q.js +1 -0
- package/dist/assets/{react-vendor-BcouBxpZ.js → react-vendor-Bjui4wS4.js} +1 -1
- package/dist/assets/upstreamPaths-nG8N1XAz.js +1 -0
- package/dist/assets/useServerActionGuard-CItj3hvh.js +1 -0
- package/dist/assets/useThroughputSeries-tb26_lkb.js +1 -0
- package/dist/index.html +5 -4
- package/package.json +11 -3
- package/scripts/embedded.gen.ts +132 -126
- package/scripts/serve.ts +501 -108
- package/dist/assets/AddJob-CAhdg38n.js +0 -3
- package/dist/assets/Alerts-70bxf-PT.js +0 -1
- package/dist/assets/Benchmark-CFZ3WCvj.js +0 -1
- package/dist/assets/BulkAddJobs-BzgzpGg_.js +0 -5
- package/dist/assets/CopilotPanel-DZUGIf1q.js +0 -44
- package/dist/assets/Cron-ltjSoTjj.js +0 -1
- package/dist/assets/CronManager-BcFALDeT.js +0 -1
- package/dist/assets/Database-CV2yt5O2.js +0 -3
- package/dist/assets/Diagnostics-D-EK4GEi.js +0 -1
- package/dist/assets/Dlq-fZAKwQoC.js +0 -1
- package/dist/assets/DlqControl-eTrrQCrn.js +0 -1
- package/dist/assets/DlqPro-CF79339e.js +0 -1
- package/dist/assets/Flows-Ba8G7mcC.js +0 -1
- package/dist/assets/JobInspector--d_8ca9p.js +0 -2
- package/dist/assets/Jobs-DEqAc1A5.js +0 -1
- package/dist/assets/JobsPro-BcaokZIf.js +0 -1
- package/dist/assets/Logs-DBTukPIX.js +0 -1
- package/dist/assets/LogsPro-Wl_e6hZM.js +0 -1
- package/dist/assets/McpServer-CnWJsKw7.js +0 -22
- package/dist/assets/Metrics-DzeIhNL-.js +0 -1
- package/dist/assets/MetricsPro-O9eVJSH0.js +0 -1
- package/dist/assets/Overview-Bnu7rWqt.js +0 -1
- package/dist/assets/QueueActions-DHoSkWRz.js +0 -1
- package/dist/assets/QueueControl-DKqEXnkM.js +0 -1
- package/dist/assets/QueueDetail-DLRow04B.js +0 -1
- package/dist/assets/QueueDetailPro-BYmOVd4T.js +0 -1
- package/dist/assets/Queues-CoyjRFN7.js +0 -1
- package/dist/assets/QueuesOverview-BeWdJpKi.js +0 -1
- package/dist/assets/S3Backup-BaLFgl9d.js +0 -1
- package/dist/assets/S3BackupPro-DJB4oWgz.js +0 -1
- package/dist/assets/ServerControl-_0lVB33E.js +0 -3
- package/dist/assets/Settings-zKFJTtnq.js +0 -1
- package/dist/assets/Usage-CKGkvl0j.js +0 -1
- package/dist/assets/UsagePro-B-TJ6fp4.js +0 -1
- package/dist/assets/Webhooks-C3zmvY-_.js +0 -1
- package/dist/assets/Workers-jKhhXl3W.js +0 -1
- package/dist/assets/WorkersPro-mvY5VX8I.js +0 -1
- package/dist/assets/connectionStore-Ci04lJmR.js +0 -1
- package/dist/assets/form-DGinTEEb.js +0 -1
- package/dist/assets/index-BOcegTHa.js +0 -8
- package/dist/assets/index-DWV3tgrk.css +0 -1
- package/dist/assets/jobActions-CuOdMglt.js +0 -1
- package/dist/assets/promisePool-Daxkc1OB.js +0 -1
- package/dist/assets/useThroughputSeries-Chp5OLx3.js +0 -1
package/agent/dbQueryWorker.ts
CHANGED
|
@@ -1,21 +1,52 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Disposable worker that runs ONE read-only
|
|
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)
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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 =
|
|
194
|
-
this.push('sys', `started: ${
|
|
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(
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
if (
|
|
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
|
-
|
|
219
|
-
|
|
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) =>
|
|
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
|
-
|
|
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();
|