@dokebi/cli 0.2.3 → 0.2.5
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/AI.md +19 -12
- package/README.md +2 -2
- package/dist/bin.js +48 -16
- package/dist/config-edit.d.ts +6 -0
- package/dist/config-edit.js +16 -0
- package/dist/configuration-scaffold.js +3 -0
- package/dist/configuration.d.ts +2 -2
- package/dist/configuration.js +11 -2
- package/dist/diagnostics.d.ts +21 -0
- package/dist/diagnostics.js +87 -0
- package/dist/install-module.js +13 -2
- package/dist/log-writer.d.ts +1 -0
- package/dist/log-writer.js +133 -0
- package/dist/logging-config.d.ts +14 -0
- package/dist/logging-config.js +48 -0
- package/dist/npm-client.js +9 -0
- package/dist/output.d.ts +1 -1
- package/dist/output.js +1 -1
- package/dist/project-inspector.js +9 -0
- package/dist/project.js +32 -7
- package/dist/rotating-log.d.ts +27 -0
- package/dist/rotating-log.js +166 -0
- package/dist/server.d.ts +55 -3
- package/dist/server.js +452 -92
- package/dist/transaction.js +10 -1
- package/dist/workspace.js +6 -2
- package/package.json +1 -1
package/dist/server.js
CHANGED
|
@@ -1,26 +1,36 @@
|
|
|
1
1
|
// Server commands (D-0044): dev, start, stop, status and logs for the project's one process
|
|
2
|
-
// (`src/main.ts`). The background process is tracked by `.dokebi/run/app.pid
|
|
3
|
-
// stderr
|
|
4
|
-
//
|
|
2
|
+
// (`src/main.ts`). The background process is tracked by `.dokebi/run/app.pid`. Its stdout and
|
|
3
|
+
// stderr are pipes into a detached log writer (`log-writer.ts`, D-0053) that writes
|
|
4
|
+
// `.dokebi/run/app.log` and rotates it by size (`app.log.1` … `app.log.<files>`). A pid is only
|
|
5
|
+
// acted on when `ps` (/proc on Linux) shows it runs this project's entry and started when the pid
|
|
6
|
+
// file says, so a reused pid is never signalled.
|
|
5
7
|
import { execFile, spawn } from 'node:child_process';
|
|
6
8
|
import { existsSync, readFileSync, watch } from 'node:fs';
|
|
7
|
-
import { mkdir, open, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
8
|
-
import { dirname, join } from 'node:path';
|
|
9
|
+
import { mkdir, open, readdir, readFile, rename, rm, stat, writeFile, } from 'node:fs/promises';
|
|
10
|
+
import { basename, dirname, join } from 'node:path';
|
|
9
11
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
10
12
|
import { fileURLToPath } from 'node:url';
|
|
11
13
|
import { promisify } from 'node:util';
|
|
14
|
+
import { debug, debugEnabled, isMissing, reportError } from './diagnostics.js';
|
|
12
15
|
import { CliError } from './errors.js';
|
|
13
16
|
import { ProjectInspector } from './project-inspector.js';
|
|
17
|
+
import { logFiles } from './rotating-log.js';
|
|
14
18
|
const exec = promisify(execFile);
|
|
15
19
|
const ENTRY = 'src/main.ts';
|
|
16
20
|
const RUN = '.dokebi/run';
|
|
17
21
|
/** Written by the generated `src/main.ts` once `app.start()` resolved. */
|
|
18
22
|
const STARTED = 'Dokebi application started';
|
|
19
|
-
|
|
23
|
+
/** Rotation defaults and limits (D-0053): `--log-max-mb`, `--log-files`. */
|
|
24
|
+
export const LOG_LIMITS = {
|
|
25
|
+
maxMb: { default: 50, min: 1, max: 10_240 },
|
|
26
|
+
files: { default: 5, min: 1, max: 100 },
|
|
27
|
+
};
|
|
28
|
+
const MB = 1024 * 1024;
|
|
20
29
|
const paths = (root) => ({
|
|
21
30
|
run: join(root, RUN),
|
|
22
31
|
pid: join(root, RUN, 'app.pid'),
|
|
23
32
|
log: join(root, RUN, 'app.log'),
|
|
33
|
+
writerLog: join(root, RUN, 'log-writer.log'),
|
|
24
34
|
lock: join(root, RUN, 'start.lock'),
|
|
25
35
|
entry: join(root, ENTRY),
|
|
26
36
|
});
|
|
@@ -33,15 +43,21 @@ async function readRecord(root) {
|
|
|
33
43
|
try {
|
|
34
44
|
text = await readFile(paths(root).pid, 'utf8');
|
|
35
45
|
}
|
|
36
|
-
catch {
|
|
46
|
+
catch (error) {
|
|
47
|
+
// No pid file means not started; anything else is reported and treated the same way.
|
|
48
|
+
if (!isMissing(error))
|
|
49
|
+
reportError('PID_FILE_READ_FAILED', 'Unable to read the pid file', error);
|
|
37
50
|
return undefined;
|
|
38
51
|
}
|
|
39
52
|
try {
|
|
40
53
|
const value = JSON.parse(text);
|
|
41
54
|
if (Number.isSafeInteger(value.pid) && value.pid > 0 && typeof value.entry === 'string')
|
|
42
55
|
return value;
|
|
56
|
+
debug('Pid file ignored: unexpected content', { file: `${RUN}/app.pid` });
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
reportError('PID_FILE_INVALID', 'Pid file is not valid JSON; treating the app as stopped', error);
|
|
43
60
|
}
|
|
44
|
-
catch { }
|
|
45
61
|
return undefined;
|
|
46
62
|
}
|
|
47
63
|
/**
|
|
@@ -68,8 +84,11 @@ async function procInfo(pid) {
|
|
|
68
84
|
command: cmdline.split('\0').join(' ').trim(),
|
|
69
85
|
};
|
|
70
86
|
}
|
|
71
|
-
catch {
|
|
72
|
-
|
|
87
|
+
catch (error) {
|
|
88
|
+
// ENOENT: no such process. Anything else (permissions, a changed /proc) is reported.
|
|
89
|
+
if (!isMissing(error))
|
|
90
|
+
reportError('PROCESS_INFO_FAILED', 'Unable to read process information from /proc', error);
|
|
91
|
+
return undefined;
|
|
73
92
|
}
|
|
74
93
|
}
|
|
75
94
|
async function processInfo(pid) {
|
|
@@ -87,29 +106,69 @@ async function processInfo(pid) {
|
|
|
87
106
|
return undefined;
|
|
88
107
|
return { startedAt, rssKb: Number(rss), command: command.join(' ') };
|
|
89
108
|
}
|
|
90
|
-
catch {
|
|
91
|
-
|
|
109
|
+
catch (error) {
|
|
110
|
+
// ps exits 1 when the pid does not exist; anything else (no ps, killed) is reported.
|
|
111
|
+
if (error.code !== 1)
|
|
112
|
+
reportError('PROCESS_INFO_FAILED', 'Unable to read process information with ps', error);
|
|
113
|
+
return undefined;
|
|
92
114
|
}
|
|
93
115
|
}
|
|
94
116
|
/** The recorded process, when it is still alive and still this project's process. */
|
|
95
117
|
async function live(record) {
|
|
96
118
|
const info = await processInfo(record.pid);
|
|
97
|
-
if (!info?.command.includes(record.entry))
|
|
119
|
+
if (!info?.command.includes(record.entry)) {
|
|
120
|
+
if (info)
|
|
121
|
+
debug('Recorded pid runs another command; treating it as stale', { pid: record.pid });
|
|
98
122
|
return undefined;
|
|
123
|
+
}
|
|
99
124
|
// lstart has one-second resolution; the pid file is written right after spawn.
|
|
100
|
-
if (Math.abs(info.startedAt - Date.parse(record.startedAt)) > 5000)
|
|
125
|
+
if (Math.abs(info.startedAt - Date.parse(record.startedAt)) > 5000) {
|
|
126
|
+
debug('Recorded pid was reused by a later process; treating it as stale', { pid: record.pid });
|
|
101
127
|
return undefined;
|
|
128
|
+
}
|
|
102
129
|
return info;
|
|
103
130
|
}
|
|
131
|
+
/** Whether the record's log writer still runs (its command names the writer and the entry). */
|
|
132
|
+
async function writerAlive(record) {
|
|
133
|
+
if (record.logWriter === undefined)
|
|
134
|
+
return false;
|
|
135
|
+
const info = await processInfo(record.logWriter);
|
|
136
|
+
return Boolean(info?.command.includes('log-writer') && info.command.includes(record.entry));
|
|
137
|
+
}
|
|
138
|
+
async function logInfo(root) {
|
|
139
|
+
const { log, writerLog } = paths(root);
|
|
140
|
+
const sizes = async (file) => stat(file).then((stats) => stats.size, (error) => {
|
|
141
|
+
// Rotated away between listing and stat: it is simply no longer there.
|
|
142
|
+
if (!isMissing(error))
|
|
143
|
+
reportError('LOG_READ_FAILED', 'Unable to read a log file size', error);
|
|
144
|
+
return undefined;
|
|
145
|
+
});
|
|
146
|
+
const files = [];
|
|
147
|
+
for (const file of logFiles(log)) {
|
|
148
|
+
const bytes = await sizes(file);
|
|
149
|
+
if (bytes !== undefined)
|
|
150
|
+
files.push({ file, bytes });
|
|
151
|
+
}
|
|
152
|
+
const writerBytes = await sizes(writerLog);
|
|
153
|
+
return { logFiles: files, ...(writerBytes ? { logWriterLog: writerLog } : {}) };
|
|
154
|
+
}
|
|
104
155
|
export async function status(root) {
|
|
105
156
|
const { pid, log } = paths(root);
|
|
106
157
|
const record = await readRecord(root);
|
|
158
|
+
const logs = await logInfo(root);
|
|
107
159
|
if (!record)
|
|
108
|
-
return { running: false, log };
|
|
160
|
+
return { running: false, log, ...logs };
|
|
109
161
|
const info = await live(record);
|
|
110
162
|
if (!info) {
|
|
111
163
|
await rm(pid, { force: true });
|
|
112
|
-
return {
|
|
164
|
+
return {
|
|
165
|
+
running: false,
|
|
166
|
+
stale: true,
|
|
167
|
+
pid: record.pid,
|
|
168
|
+
startedAt: record.startedAt,
|
|
169
|
+
log,
|
|
170
|
+
...logs,
|
|
171
|
+
};
|
|
113
172
|
}
|
|
114
173
|
return {
|
|
115
174
|
running: true,
|
|
@@ -118,6 +177,10 @@ export async function status(root) {
|
|
|
118
177
|
uptimeSec: Math.round((Date.now() - Date.parse(record.startedAt)) / 1000),
|
|
119
178
|
rssMb: Math.round(info.rssKb / 1024),
|
|
120
179
|
log,
|
|
180
|
+
...logs,
|
|
181
|
+
...(record.logWriter !== undefined
|
|
182
|
+
? { logWriter: { pid: record.logWriter, running: await writerAlive(record) } }
|
|
183
|
+
: {}),
|
|
121
184
|
};
|
|
122
185
|
}
|
|
123
186
|
async function checkProject(root) {
|
|
@@ -137,30 +200,70 @@ const environment = (mode) => ({
|
|
|
137
200
|
...process.env,
|
|
138
201
|
NODE_ENV: process.env.NODE_ENV ?? mode,
|
|
139
202
|
});
|
|
140
|
-
|
|
203
|
+
/**
|
|
204
|
+
* The last `lines` lines of one file (reading at most its last `maxBytes`). An open handle is
|
|
205
|
+
* read up to `end` (the size it had when opened) and left open.
|
|
206
|
+
*/
|
|
207
|
+
async function lastLines(source, lines, maxBytes = MB) {
|
|
208
|
+
if (lines <= 0)
|
|
209
|
+
return [];
|
|
141
210
|
let handle;
|
|
142
211
|
try {
|
|
143
|
-
|
|
144
|
-
|
|
212
|
+
const opened = typeof source === 'string' ? await open(source, 'r') : source.handle;
|
|
213
|
+
if (typeof source === 'string')
|
|
214
|
+
handle = opened;
|
|
215
|
+
const size = typeof source === 'string' ? (await opened.stat()).size : source.end;
|
|
145
216
|
const length = Math.min(size, maxBytes);
|
|
146
217
|
const buffer = Buffer.alloc(length);
|
|
147
|
-
await
|
|
218
|
+
await opened.read(buffer, 0, length, size - length);
|
|
148
219
|
const text = buffer.toString('utf8').split('\n');
|
|
149
220
|
if (text.at(-1) === '')
|
|
150
221
|
text.pop();
|
|
151
|
-
return text.slice(-lines)
|
|
222
|
+
return text.slice(-lines);
|
|
152
223
|
}
|
|
153
|
-
catch {
|
|
154
|
-
|
|
224
|
+
catch (error) {
|
|
225
|
+
// Rotated away while reading: the next older file has those lines.
|
|
226
|
+
if (!isMissing(error))
|
|
227
|
+
reportError('LOG_READ_FAILED', 'Unable to read the app log', error);
|
|
228
|
+
return [];
|
|
155
229
|
}
|
|
156
230
|
finally {
|
|
157
231
|
await handle?.close();
|
|
158
232
|
}
|
|
159
233
|
}
|
|
160
|
-
|
|
234
|
+
/**
|
|
235
|
+
* The last `lines` lines across app.log and its rotated files (D-0053). With `current` (app.log
|
|
236
|
+
* opened by the follower), app.log is read through it up to where following starts.
|
|
237
|
+
*/
|
|
238
|
+
async function tail(file, lines, current) {
|
|
239
|
+
let result = [];
|
|
240
|
+
const files = logFiles(file);
|
|
241
|
+
if (current && files[0] !== file)
|
|
242
|
+
files.unshift(file);
|
|
243
|
+
for (const each of files) {
|
|
244
|
+
const source = current && each === file ? current : each;
|
|
245
|
+
result = [...(await lastLines(source, lines - result.length)), ...result];
|
|
246
|
+
if (result.length >= lines)
|
|
247
|
+
break;
|
|
248
|
+
}
|
|
249
|
+
return result.length ? `${result.join('\n')}\n` : '';
|
|
250
|
+
}
|
|
251
|
+
/** Rejects rotation settings outside LOG_LIMITS; bytes below 1 MB are allowed for tests. */
|
|
252
|
+
function logSettings(options) {
|
|
253
|
+
const maxBytes = options.logMaxBytes ?? LOG_LIMITS.maxMb.default * MB;
|
|
254
|
+
const files = options.logFiles ?? LOG_LIMITS.files.default;
|
|
255
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1024 || maxBytes > LOG_LIMITS.maxMb.max * MB)
|
|
256
|
+
throw new CliError('USAGE', `--log-max-mb must be an integer from ${LOG_LIMITS.maxMb.min} to ${LOG_LIMITS.maxMb.max}`);
|
|
257
|
+
if (!Number.isSafeInteger(files) || files < LOG_LIMITS.files.min || files > LOG_LIMITS.files.max)
|
|
258
|
+
throw new CliError('USAGE', `--log-files must be an integer from ${LOG_LIMITS.files.min} to ${LOG_LIMITS.files.max}`);
|
|
259
|
+
return { maxBytes, files };
|
|
260
|
+
}
|
|
261
|
+
export async function start(root, options = {},
|
|
161
262
|
/** Tests replace the project inspection; the CLI always inspects. */
|
|
162
263
|
check = checkProject) {
|
|
163
264
|
daemonOnly();
|
|
265
|
+
const { maxBytes, files: keep } = logSettings(options);
|
|
266
|
+
const waitMs = options.waitMs ?? 10_000;
|
|
164
267
|
const files = paths(root);
|
|
165
268
|
const began = performance.now();
|
|
166
269
|
const current = await status(root);
|
|
@@ -175,69 +278,126 @@ check = checkProject) {
|
|
|
175
278
|
try {
|
|
176
279
|
await writeFile(files.lock, String(process.pid), { flag: 'wx' });
|
|
177
280
|
}
|
|
178
|
-
catch {
|
|
179
|
-
|
|
281
|
+
catch (error) {
|
|
282
|
+
if (error.code !== 'EEXIST')
|
|
283
|
+
throw error;
|
|
284
|
+
const age = Date.now() -
|
|
285
|
+
(await stat(files.lock).catch((cause) => {
|
|
286
|
+
// The other start removed its lock meanwhile: the lock counts as stale.
|
|
287
|
+
if (!isMissing(cause))
|
|
288
|
+
throw cause;
|
|
289
|
+
return { mtimeMs: 0 };
|
|
290
|
+
})).mtimeMs;
|
|
180
291
|
if (age < 60_000)
|
|
181
292
|
throw new CliError('START_IN_PROGRESS', 'Another dokebi start is running for this project');
|
|
293
|
+
debug('Replacing a stale start lock', { ageMs: age });
|
|
182
294
|
await writeFile(files.lock, String(process.pid));
|
|
183
295
|
}
|
|
184
296
|
const warnings = [];
|
|
297
|
+
let writer;
|
|
185
298
|
try {
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
warnings.push(`${RUN}/app.log is ${Math.round(before.size / 1048576)} MB; it is not rotated`);
|
|
189
|
-
const offset = before?.size ?? 0;
|
|
190
|
-
const log = await open(files.log, 'a');
|
|
191
|
-
let child;
|
|
299
|
+
// The writer's own diagnostics (it has no app logger) go to log-writer.log.
|
|
300
|
+
const writerLog = await open(files.writerLog, 'a');
|
|
192
301
|
try {
|
|
193
|
-
|
|
194
|
-
|
|
302
|
+
writer = spawn(process.execPath, [
|
|
303
|
+
fileURLToPath(new URL('./log-writer.js', import.meta.url)),
|
|
304
|
+
files.log,
|
|
305
|
+
String(maxBytes),
|
|
306
|
+
String(keep),
|
|
307
|
+
entry,
|
|
308
|
+
], {
|
|
195
309
|
cwd: root,
|
|
196
310
|
detached: true,
|
|
197
|
-
stdio: ['ignore',
|
|
311
|
+
stdio: ['ignore', 'ignore', writerLog.fd, 'ipc'],
|
|
198
312
|
env: environment('production'),
|
|
199
313
|
});
|
|
200
314
|
}
|
|
201
315
|
finally {
|
|
202
|
-
await
|
|
316
|
+
await writerLog.close();
|
|
203
317
|
}
|
|
204
|
-
const
|
|
205
|
-
if (
|
|
206
|
-
throw new CliError('START_FAILED', 'The process could not be spawned');
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
318
|
+
const writerPid = writer.pid;
|
|
319
|
+
if (writerPid === undefined)
|
|
320
|
+
throw new CliError('START_FAILED', 'The log writer process could not be spawned');
|
|
321
|
+
const state = {};
|
|
322
|
+
writer.on('message', (message) => {
|
|
323
|
+
if (typeof message?.spawned === 'number')
|
|
324
|
+
state.pid = message.spawned;
|
|
325
|
+
if (message?.ready)
|
|
326
|
+
state.ready = true;
|
|
327
|
+
if (message && 'exited' in message)
|
|
328
|
+
state.exited = message.signal ? `signal ${message.signal}` : `code ${message.exited}`;
|
|
329
|
+
if (typeof message?.failed === 'string')
|
|
330
|
+
state.failed = message.failed;
|
|
210
331
|
});
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
332
|
+
writer.once('exit', (code, signal) => {
|
|
333
|
+
// The writer ends with 0 only after the app ended (its message may still be on the way).
|
|
334
|
+
if (code === 0)
|
|
335
|
+
state.exited ??= 'an unknown code (see the log)';
|
|
336
|
+
else
|
|
337
|
+
state.failed ??= `the log writer exited (${signal ?? code})`;
|
|
338
|
+
});
|
|
339
|
+
debug('Log writer spawned; waiting for the app', { writer: writerPid, waitMs, maxBytes, keep });
|
|
340
|
+
const failure = async (message) => {
|
|
341
|
+
await rm(files.pid, { force: true });
|
|
342
|
+
return new CliError('START_FAILED', message, {
|
|
343
|
+
details: {
|
|
344
|
+
log: files.log,
|
|
345
|
+
tail: await tail(files.log, 30),
|
|
346
|
+
logWriterLog: (await lastLines(files.writerLog, 30)).join('\n'),
|
|
347
|
+
},
|
|
348
|
+
});
|
|
217
349
|
};
|
|
218
|
-
await writeFile(`${files.pid}.tmp`, `${JSON.stringify(record)}\n`);
|
|
219
|
-
await rename(`${files.pid}.tmp`, files.pid);
|
|
220
350
|
const deadline = performance.now() + waitMs;
|
|
221
|
-
let
|
|
351
|
+
let recorded;
|
|
222
352
|
while (performance.now() < deadline) {
|
|
223
|
-
await delay(
|
|
224
|
-
if (
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
353
|
+
await delay(50);
|
|
354
|
+
if (state.pid !== undefined && recorded === undefined) {
|
|
355
|
+
recorded = state.pid;
|
|
356
|
+
const record = {
|
|
357
|
+
pid: state.pid,
|
|
358
|
+
startedAt: new Date().toISOString(),
|
|
359
|
+
entry,
|
|
360
|
+
node: process.version,
|
|
361
|
+
logWriter: writerPid,
|
|
362
|
+
logMaxBytes: maxBytes,
|
|
363
|
+
logFiles: keep,
|
|
364
|
+
};
|
|
365
|
+
await writeFile(`${files.pid}.tmp`, `${JSON.stringify(record)}\n`);
|
|
366
|
+
await rename(`${files.pid}.tmp`, files.pid);
|
|
367
|
+
debug('App process spawned; waiting for the start line', { pid: state.pid });
|
|
229
368
|
}
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
369
|
+
if (state.exited !== undefined)
|
|
370
|
+
throw await failure(`The application exited with ${state.exited}`);
|
|
371
|
+
if (state.failed !== undefined)
|
|
372
|
+
throw await failure(`The application could not be started: ${state.failed}`);
|
|
373
|
+
if (state.ready && recorded !== undefined)
|
|
233
374
|
break;
|
|
234
|
-
}
|
|
235
375
|
}
|
|
376
|
+
if (recorded === undefined) {
|
|
377
|
+
// The writer never started the app: do not leave it (or a late app) behind. The writer
|
|
378
|
+
// leads its own process group (detached), so the group signal reaches both.
|
|
379
|
+
process.kill(-writerPid, 'SIGKILL');
|
|
380
|
+
throw await failure(`The log writer did not start the application within ${waitMs} ms`);
|
|
381
|
+
}
|
|
382
|
+
const ready = state.ready === true;
|
|
236
383
|
if (!ready)
|
|
237
384
|
warnings.push(`"${STARTED}" was not logged within ${waitMs} ms; the process is running but may not be ready`);
|
|
238
|
-
return {
|
|
385
|
+
return {
|
|
386
|
+
pid: recorded,
|
|
387
|
+
log: files.log,
|
|
388
|
+
logWriter: writerPid,
|
|
389
|
+
logMaxBytes: maxBytes,
|
|
390
|
+
logFiles: keep,
|
|
391
|
+
ready,
|
|
392
|
+
ms: Math.round(performance.now() - began),
|
|
393
|
+
warnings,
|
|
394
|
+
};
|
|
239
395
|
}
|
|
240
396
|
finally {
|
|
397
|
+
// The writer and the app outlive this command.
|
|
398
|
+
if (writer?.connected)
|
|
399
|
+
writer.disconnect();
|
|
400
|
+
writer?.unref();
|
|
241
401
|
await rm(files.lock, { force: true });
|
|
242
402
|
}
|
|
243
403
|
}
|
|
@@ -252,57 +412,243 @@ export async function stop(root, timeoutMs = 30_000, force = false) {
|
|
|
252
412
|
return { stopped: false, pid: record.pid, stale: true };
|
|
253
413
|
}
|
|
254
414
|
const began = performance.now();
|
|
255
|
-
const gone = async (limit) => {
|
|
415
|
+
const gone = async (alive, limit) => {
|
|
256
416
|
const until = performance.now() + limit;
|
|
257
417
|
while (performance.now() < until) {
|
|
258
|
-
if (!(await
|
|
418
|
+
if (!(await alive()))
|
|
259
419
|
return true;
|
|
260
420
|
await delay(100);
|
|
261
421
|
}
|
|
262
422
|
return false;
|
|
263
423
|
};
|
|
424
|
+
const app = async () => Boolean(await live(record));
|
|
425
|
+
// SIGTERM goes to the app (core drain); the log writer exits once the app's output ends.
|
|
426
|
+
debug('Sending SIGTERM', { pid: record.pid, timeoutMs });
|
|
264
427
|
process.kill(record.pid, 'SIGTERM');
|
|
265
428
|
let forced = false;
|
|
266
|
-
if (!(await gone(timeoutMs))) {
|
|
429
|
+
if (!(await gone(app, timeoutMs))) {
|
|
267
430
|
if (!force)
|
|
268
431
|
throw new CliError('STOP_TIMEOUT', `pid ${record.pid} did not stop within ${timeoutMs} ms; it is still draining or stuck (use --force)`);
|
|
432
|
+
debug('Stop timed out; sending SIGKILL', { pid: record.pid });
|
|
269
433
|
process.kill(record.pid, 'SIGKILL');
|
|
270
434
|
forced = true;
|
|
271
|
-
if (!(await gone(5000)))
|
|
435
|
+
if (!(await gone(app, 5000)))
|
|
272
436
|
throw new CliError('STOP_TIMEOUT', `pid ${record.pid} survived SIGKILL`);
|
|
273
437
|
}
|
|
274
438
|
await rm(files.pid, { force: true });
|
|
275
|
-
|
|
439
|
+
const warnings = [];
|
|
440
|
+
if (record.logWriter !== undefined && !(await gone(() => writerAlive(record), 5000))) {
|
|
441
|
+
debug('Log writer still running after the app stopped', { writer: record.logWriter });
|
|
442
|
+
warnings.push(`the log writer (pid ${record.logWriter}) was still running 5 s after the app stopped; a process the app started may hold its output`);
|
|
443
|
+
}
|
|
444
|
+
return {
|
|
445
|
+
stopped: true,
|
|
446
|
+
pid: record.pid,
|
|
447
|
+
ms: Math.round(performance.now() - began),
|
|
448
|
+
forced,
|
|
449
|
+
...(warnings.length ? { warnings } : {}),
|
|
450
|
+
};
|
|
276
451
|
}
|
|
277
|
-
/**
|
|
278
|
-
|
|
452
|
+
/**
|
|
453
|
+
* Prints the last `lines` lines (across app.log and its rotated files); with `follow`, keeps
|
|
454
|
+
* printing what is appended until SIGINT (or `signal`), following app.log across rotations.
|
|
455
|
+
* The follower remembers the inode of the file it reads. When app.log has become another file,
|
|
456
|
+
* it finishes its file, then reads each rotated file newer than it (found by inode, oldest
|
|
457
|
+
* first) before following the new app.log, so several rotations between two polls skip no line.
|
|
458
|
+
*/
|
|
459
|
+
export async function logs(root, lines = 100, follow = false, write = (text) => process.stdout.write(text), signal) {
|
|
279
460
|
const file = paths(root).log;
|
|
280
|
-
if (!follow
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
461
|
+
if (!follow) {
|
|
462
|
+
if (!existsSync(file))
|
|
463
|
+
throw new CliError('NO_LOG', `${RUN}/app.log does not exist; the app was never started`);
|
|
464
|
+
write(await tail(file, lines));
|
|
284
465
|
return;
|
|
285
|
-
|
|
466
|
+
}
|
|
286
467
|
const stopped = new AbortController();
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
468
|
+
const onInt = () => stopped.abort();
|
|
469
|
+
process.once('SIGINT', onInt);
|
|
470
|
+
signal?.addEventListener('abort', onInt, { once: true });
|
|
471
|
+
const follower = new LogFollower(file, write, stopped.signal);
|
|
472
|
+
try {
|
|
473
|
+
// app.log is opened first and the tail ends where following starts: no line falls between.
|
|
474
|
+
write(await tail(file, lines, await follower.open()));
|
|
475
|
+
await follower.run();
|
|
476
|
+
}
|
|
477
|
+
finally {
|
|
478
|
+
process.off('SIGINT', onInt);
|
|
479
|
+
await follower.close();
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
/** `logs -f` (D-0053): reads app.log as it grows and follows it across rotations by inode. */
|
|
483
|
+
class LogFollower {
|
|
484
|
+
file;
|
|
485
|
+
write;
|
|
486
|
+
signal;
|
|
487
|
+
#handle;
|
|
488
|
+
/** The inode of the file being read. */
|
|
489
|
+
#reading;
|
|
490
|
+
#position = 0;
|
|
491
|
+
constructor(file, write, signal) {
|
|
492
|
+
this.file = file;
|
|
493
|
+
this.write = write;
|
|
494
|
+
this.signal = signal;
|
|
495
|
+
}
|
|
496
|
+
/** Opens app.log at its end; the handle and that end, when it exists. */
|
|
497
|
+
async open() {
|
|
498
|
+
const opened = await this.#open(this.file);
|
|
499
|
+
await this.#use(opened, 'end');
|
|
500
|
+
return opened && { handle: opened.handle, end: opened.size };
|
|
501
|
+
}
|
|
502
|
+
async close() {
|
|
503
|
+
const handle = this.#handle;
|
|
504
|
+
this.#handle = undefined;
|
|
505
|
+
await handle?.close();
|
|
506
|
+
}
|
|
507
|
+
/** Prints what is appended until the signal aborts. */
|
|
508
|
+
async run() {
|
|
509
|
+
while (!this.signal.aborted) {
|
|
510
|
+
await delay(250);
|
|
511
|
+
if (!this.#handle) {
|
|
512
|
+
// Before the first start: follow app.log from its beginning once it exists.
|
|
513
|
+
await this.#use(await this.#open(this.file), 'start');
|
|
514
|
+
await this.#drain();
|
|
515
|
+
continue;
|
|
516
|
+
}
|
|
517
|
+
// Rotated once or more: finish this file (it is complete), then each newer one in turn.
|
|
518
|
+
let current = await this.#inode(this.file);
|
|
519
|
+
while (current !== undefined && current !== this.#reading && !this.signal.aborted) {
|
|
520
|
+
await this.#drain();
|
|
521
|
+
if (!(await this.#advance()))
|
|
522
|
+
break; // a rotation is under way: next poll
|
|
523
|
+
current = await this.#inode(this.file);
|
|
524
|
+
}
|
|
525
|
+
await this.#drain();
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
/** Prints what the open file has beyond the position; the handle reads a renamed file too. */
|
|
529
|
+
async #drain() {
|
|
530
|
+
const handle = this.#handle;
|
|
531
|
+
if (!handle)
|
|
532
|
+
return;
|
|
533
|
+
const { size } = await handle.stat();
|
|
534
|
+
if (size < this.#position)
|
|
535
|
+
this.#position = 0; // truncated: read it again from the start
|
|
536
|
+
while (this.#position < size) {
|
|
537
|
+
const buffer = Buffer.alloc(Math.min(size - this.#position, MB));
|
|
538
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, this.#position);
|
|
539
|
+
if (!bytesRead)
|
|
540
|
+
break;
|
|
541
|
+
this.write(buffer.subarray(0, bytesRead).toString('utf8'));
|
|
542
|
+
this.#position += bytesRead;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* Moves to the file written after the one being read; false when it cannot be told yet.
|
|
547
|
+
* The writer renames the oldest first (`.k` → `.k+1` before `.k-1` → `.k`), so `.k-1`
|
|
548
|
+
* opened while `.k` holds our inode both before and after the open is the next file;
|
|
549
|
+
* otherwise a rotation ran meanwhile and the place is looked up again. A gap that stays
|
|
550
|
+
* (a rotated file removed by hand) is stepped over to the next newer file there is.
|
|
551
|
+
*/
|
|
552
|
+
async #advance() {
|
|
553
|
+
const attempts = 20;
|
|
554
|
+
for (let attempt = 0; attempt < attempts && !this.signal.aborted; attempt++) {
|
|
555
|
+
if (attempt)
|
|
556
|
+
await delay(5);
|
|
557
|
+
const rotated = await this.#rotated();
|
|
558
|
+
const index = await this.#indexOf(rotated, this.#reading);
|
|
559
|
+
if (index === undefined) {
|
|
560
|
+
if (await this.#removed())
|
|
561
|
+
return this.#skipToOldest(rotated);
|
|
562
|
+
continue; // moved up while we looked: look again
|
|
563
|
+
}
|
|
564
|
+
// A gap at `.k-1` lasts microseconds during a rotation; one seen for ~100 ms is a hole.
|
|
565
|
+
const target = attempt < attempts - 1 ? index - 1 : (rotated.filter((each) => each < index).at(-1) ?? 0);
|
|
566
|
+
const next = await this.#open(this.#named(target));
|
|
567
|
+
if (!next)
|
|
568
|
+
continue; // `.k-1` is between its rename and the next one
|
|
569
|
+
if ((await this.#inode(this.#named(index))) === this.#reading) {
|
|
570
|
+
if (debugEnabled())
|
|
571
|
+
debug('App log rotated; following the next file', { file: this.#named(target) });
|
|
572
|
+
await this.#use(next, 'start');
|
|
573
|
+
return true;
|
|
574
|
+
}
|
|
575
|
+
await next.handle.close();
|
|
576
|
+
}
|
|
577
|
+
return false;
|
|
578
|
+
}
|
|
579
|
+
/** The followed file was removed beyond --log-files: lines between are gone from disk too. */
|
|
580
|
+
async #skipToOldest(rotated) {
|
|
581
|
+
const oldest = rotated.at(-1) ?? 0;
|
|
582
|
+
const opened = await this.#open(this.#named(oldest));
|
|
583
|
+
if (!opened)
|
|
584
|
+
return false;
|
|
585
|
+
debug('Followed log file was removed by rotation; continuing with the oldest kept file', {
|
|
586
|
+
file: this.#named(oldest),
|
|
587
|
+
});
|
|
588
|
+
this.write('--- dokebi: logs -f fell behind; rotated lines removed before they were read ---\n');
|
|
589
|
+
await this.#use(opened, 'start');
|
|
590
|
+
return true;
|
|
591
|
+
}
|
|
592
|
+
/** The index of the rotated file with `inode` (`app.log.<index>`), when one has it. */
|
|
593
|
+
async #indexOf(rotated, inode) {
|
|
594
|
+
for (const index of rotated)
|
|
595
|
+
if ((await this.#inode(this.#named(index))) === inode)
|
|
596
|
+
return index;
|
|
597
|
+
return undefined;
|
|
598
|
+
}
|
|
599
|
+
/** Indexes of the rotated files there are, ascending (newest first). */
|
|
600
|
+
async #rotated() {
|
|
601
|
+
const name = basename(this.file);
|
|
602
|
+
const names = await readdir(dirname(this.file)).catch((error) => {
|
|
603
|
+
if (!isMissing(error))
|
|
604
|
+
reportError('LOG_READ_FAILED', 'Unable to list the rotated app logs', error, 5000);
|
|
605
|
+
return [];
|
|
606
|
+
});
|
|
607
|
+
return names
|
|
608
|
+
.filter((each) => each.startsWith(`${name}.`) && /^\d+$/.test(each.slice(name.length + 1)))
|
|
609
|
+
.map((each) => Number(each.slice(name.length + 1)))
|
|
610
|
+
.sort((a, b) => a - b);
|
|
611
|
+
}
|
|
612
|
+
/** True when the open file has no name left (removed beyond --log-files). */
|
|
613
|
+
async #removed() {
|
|
614
|
+
return (await this.#handle?.stat())?.nlink === 0;
|
|
615
|
+
}
|
|
616
|
+
/** `app.log.<index>` (`app.log` for 0). */
|
|
617
|
+
#named(index) {
|
|
618
|
+
return index ? `${this.file}.${index}` : this.file;
|
|
619
|
+
}
|
|
620
|
+
/** The inode at `name`, or undefined while it does not exist. */
|
|
621
|
+
async #inode(name) {
|
|
622
|
+
return stat(name).then((stats) => stats.ino, (error) => {
|
|
623
|
+
// Between the rename and the new file (rotation) or before the first start: absent.
|
|
624
|
+
if (!isMissing(error))
|
|
625
|
+
reportError('LOG_READ_FAILED', 'Unable to read the app log', error, 5000);
|
|
626
|
+
return undefined;
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
async #open(name) {
|
|
630
|
+
let handle;
|
|
296
631
|
try {
|
|
297
|
-
|
|
298
|
-
await handle.
|
|
299
|
-
|
|
300
|
-
position = size;
|
|
632
|
+
handle = await open(name, 'r');
|
|
633
|
+
const { ino, size } = await handle.stat();
|
|
634
|
+
return { handle, ino, size };
|
|
301
635
|
}
|
|
302
|
-
|
|
303
|
-
await handle
|
|
636
|
+
catch (error) {
|
|
637
|
+
await handle?.close();
|
|
638
|
+
if (!isMissing(error))
|
|
639
|
+
reportError('LOG_READ_FAILED', 'Unable to open the app log', error, 5000);
|
|
640
|
+
return undefined;
|
|
304
641
|
}
|
|
305
642
|
}
|
|
643
|
+
/** Reads `opened` from its start or end from now on, closing the file read so far. */
|
|
644
|
+
async #use(opened, from) {
|
|
645
|
+
if (!opened)
|
|
646
|
+
return;
|
|
647
|
+
await this.#handle?.close();
|
|
648
|
+
this.#handle = opened.handle;
|
|
649
|
+
this.#reading = opened.ino;
|
|
650
|
+
this.#position = from === 'end' ? opened.size : 0;
|
|
651
|
+
}
|
|
306
652
|
}
|
|
307
653
|
/** Runs the app in the foreground and forwards SIGINT/SIGTERM; resolves with its exit code. */
|
|
308
654
|
export async function foreground(root) {
|
|
@@ -333,7 +679,11 @@ function installedVersion(root, name) {
|
|
|
333
679
|
const parts = /^(\d+)\.(\d+)\.(\d+)/.exec(String(version));
|
|
334
680
|
return parts ? [Number(parts[1]), Number(parts[2]), Number(parts[3])] : undefined;
|
|
335
681
|
}
|
|
336
|
-
catch {
|
|
682
|
+
catch (error) {
|
|
683
|
+
// Not installed here: keep walking up like Node's resolution does.
|
|
684
|
+
if (!isMissing(error))
|
|
685
|
+
reportError('PACKAGE_READ_FAILED', `Unable to read the installed ${name} package.json`, error);
|
|
686
|
+
}
|
|
337
687
|
if (dirname(directory) === directory)
|
|
338
688
|
return undefined;
|
|
339
689
|
}
|
|
@@ -367,7 +717,12 @@ export async function dev(root, say) {
|
|
|
367
717
|
const child = (args) => spawn(process.execPath, ['--import', guard, ...args], {
|
|
368
718
|
cwd: root,
|
|
369
719
|
stdio: ['inherit', 'inherit', 'inherit', 'ipc'],
|
|
370
|
-
|
|
720
|
+
// DOKEBI_DEV: a cluster app runs one worker (@dokebi/cluster, DOKEBI_DEV_WORKERS for more).
|
|
721
|
+
env: {
|
|
722
|
+
...environment('development'),
|
|
723
|
+
DOKEBI_DEV: '1',
|
|
724
|
+
...(hot ? { DOKEBI_VITE_DEV: '1' } : {}),
|
|
725
|
+
},
|
|
371
726
|
});
|
|
372
727
|
const run = () => {
|
|
373
728
|
app = child([entry]);
|
|
@@ -446,7 +801,8 @@ export async function dev(root, say) {
|
|
|
446
801
|
return;
|
|
447
802
|
clearTimeout(timer);
|
|
448
803
|
timer = setTimeout(() => {
|
|
449
|
-
restarting = restarting
|
|
804
|
+
restarting = restarting
|
|
805
|
+
.then(async () => {
|
|
450
806
|
if (exiting)
|
|
451
807
|
return;
|
|
452
808
|
say(`${what} changed; restarting`);
|
|
@@ -455,6 +811,10 @@ export async function dev(root, say) {
|
|
|
455
811
|
await halt(previous);
|
|
456
812
|
if (!exiting)
|
|
457
813
|
run();
|
|
814
|
+
})
|
|
815
|
+
.catch((error) => {
|
|
816
|
+
// Keep the loop alive: the next change tries again.
|
|
817
|
+
reportError('DEV_RESTART_FAILED', 'Restarting the app failed', error);
|
|
458
818
|
});
|
|
459
819
|
}, 300);
|
|
460
820
|
};
|