@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/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`; its stdout and
3
- // stderr go to `.dokebi/run/app.log`. A pid is only acted on when `ps` (/proc on Linux) shows it runs this
4
- // project's entry and started when the pid file says, so a reused pid is never signalled.
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
- const LOG_WARN_BYTES = 100 * 1024 * 1024;
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
- return undefined; // no such process
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
- return undefined; // ps exits 1 when the pid does not exist
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 { running: false, stale: true, pid: record.pid, startedAt: record.startedAt, log };
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
- async function tail(file, lines, maxBytes = 1024 * 1024) {
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
- handle = await open(file, 'r');
144
- const { size } = await handle.stat();
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 handle.read(buffer, 0, length, size - length);
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).join('\n') + (text.length ? '\n' : '');
222
+ return text.slice(-lines);
152
223
  }
153
- catch {
154
- return '';
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
- export async function start(root, waitMs = 10_000,
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
- const age = Date.now() - (await stat(files.lock).catch(() => ({ mtimeMs: 0 }))).mtimeMs;
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
- const before = await stat(files.log).catch(() => undefined);
187
- if (before && before.size > LOG_WARN_BYTES)
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
- await log.write(`\n--- dokebi start ${new Date().toISOString()} ---\n`);
194
- child = spawn(process.execPath, [entry], {
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', log.fd, log.fd],
311
+ stdio: ['ignore', 'ignore', writerLog.fd, 'ipc'],
198
312
  env: environment('production'),
199
313
  });
200
314
  }
201
315
  finally {
202
- await log.close();
316
+ await writerLog.close();
203
317
  }
204
- const pid = child.pid;
205
- if (pid === undefined)
206
- throw new CliError('START_FAILED', 'The process could not be spawned');
207
- let exited;
208
- child.once('exit', (code, signal) => {
209
- exited = code ?? (signal ? 128 : 1);
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
- child.unref();
212
- const record = {
213
- pid,
214
- startedAt: new Date().toISOString(),
215
- entry,
216
- node: process.version,
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 ready = false;
351
+ let recorded;
222
352
  while (performance.now() < deadline) {
223
- await delay(100);
224
- if (exited !== undefined) {
225
- await rm(files.pid, { force: true });
226
- throw new CliError('START_FAILED', `The application exited with code ${exited}`, {
227
- details: { log: files.log, tail: await tail(files.log, 30) },
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
- const text = await readFile(files.log).catch(() => Buffer.alloc(0));
231
- if (text.subarray(offset).includes(STARTED)) {
232
- ready = true;
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 { pid, log: files.log, ready, ms: Math.round(performance.now() - began), warnings };
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 live(record)))
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
- return { stopped: true, pid: record.pid, ms: Math.round(performance.now() - began), forced };
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
- /** Prints the last `lines` lines; with `follow`, keeps printing what is appended until SIGINT. */
278
- export async function logs(root, lines = 100, follow = false, write = (text) => process.stdout.write(text)) {
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 && !existsSync(file))
281
- throw new CliError('NO_LOG', `${RUN}/app.log does not exist; the app was never started`);
282
- write(await tail(file, lines));
283
- if (!follow)
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
- let position = (await stat(file).catch(() => ({ size: 0 }))).size;
466
+ }
286
467
  const stopped = new AbortController();
287
- process.once('SIGINT', () => stopped.abort());
288
- while (!stopped.signal.aborted) {
289
- await delay(250);
290
- const size = (await stat(file).catch(() => ({ size: 0 }))).size;
291
- if (size < position)
292
- position = 0; // removed or truncated: follow the new file
293
- if (size === position)
294
- continue;
295
- const handle = await open(file, 'r');
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
- const buffer = Buffer.alloc(size - position);
298
- await handle.read(buffer, 0, buffer.length, position);
299
- write(buffer.toString('utf8'));
300
- position = size;
632
+ handle = await open(name, 'r');
633
+ const { ino, size } = await handle.stat();
634
+ return { handle, ino, size };
301
635
  }
302
- finally {
303
- await handle.close();
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
- env: { ...environment('development'), ...(hot ? { DOKEBI_VITE_DEV: '1' } : {}) },
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.then(async () => {
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
  };