@jmcombs/pi-steward 0.0.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/server/log-tailer.ts +71 -21
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jmcombs/pi-steward",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"description": "Steward — the llama.cpp control panel for Pi. A local browser dashboard for service control, resident models, host health, and streamed logs.",
|
|
5
5
|
"homepage": "https://github.com/jmcombs/pi-extensions/tree/main/packages/steward",
|
|
6
6
|
"repository": {
|
package/server/log-tailer.ts
CHANGED
|
@@ -144,6 +144,22 @@ export function createFileTailer(options: FileTailerOptions): FileTailer {
|
|
|
144
144
|
let seq = 0;
|
|
145
145
|
/** Byte offset already consumed from the current file. */
|
|
146
146
|
let position = 0;
|
|
147
|
+
/**
|
|
148
|
+
* An open descriptor on the file being followed, held for the whole time we
|
|
149
|
+
* follow it.
|
|
150
|
+
*
|
|
151
|
+
* It is what makes the inode comparison below mean anything. Linux frees an
|
|
152
|
+
* inode number the moment its last link goes and hands the very next file the
|
|
153
|
+
* same number: measured in a `node:22` container, delete-then-recreate
|
|
154
|
+
* returned an identical `ino` — and an identical `birthtimeMs`, so creation
|
|
155
|
+
* time cannot break the tie either. An open descriptor holds the old inode
|
|
156
|
+
* alive, so its number cannot be reissued and a replacement is guaranteed to
|
|
157
|
+
* stat differently. macOS happened not to recycle and hid this.
|
|
158
|
+
*
|
|
159
|
+
* It also removes the stat-then-open race: reads come from the handle whose
|
|
160
|
+
* identity we checked, never from whatever the path resolves to now.
|
|
161
|
+
*/
|
|
162
|
+
let heldFd: number | null = null;
|
|
147
163
|
/** Identity of the file we are following, so a replacement is detectable. */
|
|
148
164
|
let inode: number | null = null;
|
|
149
165
|
let device: number | null = null;
|
|
@@ -222,27 +238,63 @@ export function createFileTailer(options: FileTailerOptions): FileTailer {
|
|
|
222
238
|
resetStream();
|
|
223
239
|
}
|
|
224
240
|
|
|
225
|
-
/**
|
|
226
|
-
function
|
|
227
|
-
if (
|
|
228
|
-
|
|
241
|
+
/** Lets go of the file we were following, if any. */
|
|
242
|
+
function release(): void {
|
|
243
|
+
if (heldFd === null) return;
|
|
244
|
+
try {
|
|
245
|
+
closeSync(heldFd);
|
|
246
|
+
} catch {
|
|
247
|
+
// Already gone, or never really open. Nothing here can be recovered from
|
|
248
|
+
// and nothing depends on it.
|
|
249
|
+
}
|
|
250
|
+
heldFd = null;
|
|
251
|
+
}
|
|
229
252
|
|
|
253
|
+
/**
|
|
254
|
+
* Starts following whatever `path` resolves to right now, from its tail.
|
|
255
|
+
*
|
|
256
|
+
* Identity and size are taken from the handle rather than from the caller's
|
|
257
|
+
* `stat`, so the file that gets read is the file that got measured even if the
|
|
258
|
+
* path is replaced in between. Returns false if it could not be opened, in
|
|
259
|
+
* which case the state has already been reported.
|
|
260
|
+
*/
|
|
261
|
+
function follow(): boolean {
|
|
262
|
+
release();
|
|
230
263
|
let fd: number;
|
|
231
264
|
try {
|
|
232
265
|
fd = openSync(path, "r");
|
|
233
266
|
} catch (error) {
|
|
234
267
|
markUnavailable(error);
|
|
235
|
-
|
|
268
|
+
inode = null;
|
|
269
|
+
device = null;
|
|
270
|
+
return false;
|
|
236
271
|
}
|
|
237
272
|
try {
|
|
238
|
-
// The file can be replaced between the stat above and this open; reading
|
|
239
|
-
// a different inode at our offset would emit garbage. Check identity
|
|
240
|
-
// against the handle we actually hold and let the next poll re-anchor.
|
|
241
273
|
const open = fstatSync(fd);
|
|
242
|
-
|
|
274
|
+
heldFd = fd;
|
|
275
|
+
inode = open.ino;
|
|
276
|
+
device = open.dev;
|
|
277
|
+
anchor(open.size);
|
|
278
|
+
return true;
|
|
279
|
+
} catch (error) {
|
|
280
|
+
closeSync(fd);
|
|
281
|
+
markUnavailable(error);
|
|
282
|
+
return false;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Reads at most one chunk of new bytes and feeds them to the splitter. */
|
|
287
|
+
function drain(): void {
|
|
288
|
+
if (heldFd === null) return;
|
|
289
|
+
try {
|
|
290
|
+
// The held handle's own size, not the path's: they differ exactly when the
|
|
291
|
+
// file was replaced mid-poll, and this is the one that matches `position`.
|
|
292
|
+
const size = fstatSync(heldFd).size;
|
|
293
|
+
if (position >= size) return;
|
|
294
|
+
const length = Math.min(size - position, MAX_READ_PER_POLL);
|
|
243
295
|
|
|
244
296
|
const buffer = Buffer.allocUnsafe(length);
|
|
245
|
-
const read = readSync(
|
|
297
|
+
const read = readSync(heldFd, buffer, 0, length, position);
|
|
246
298
|
if (read <= 0) return;
|
|
247
299
|
position += read;
|
|
248
300
|
|
|
@@ -257,8 +309,6 @@ export function createFileTailer(options: FileTailerOptions): FileTailer {
|
|
|
257
309
|
splitter.push(text);
|
|
258
310
|
} catch (error) {
|
|
259
311
|
markUnavailable(error);
|
|
260
|
-
} finally {
|
|
261
|
-
closeSync(fd);
|
|
262
312
|
}
|
|
263
313
|
}
|
|
264
314
|
|
|
@@ -290,9 +340,7 @@ export function createFileTailer(options: FileTailerOptions): FileTailer {
|
|
|
290
340
|
if (!anchored) {
|
|
291
341
|
// First sight: start at the tail, so a log that has grown for months
|
|
292
342
|
// costs one backlog window rather than a whole-file read.
|
|
293
|
-
|
|
294
|
-
device = stats.dev;
|
|
295
|
-
anchor(stats.size);
|
|
343
|
+
if (!follow()) return;
|
|
296
344
|
anchored = true;
|
|
297
345
|
} else if (replaced) {
|
|
298
346
|
// Unlinked and recreated (the `tmp_cleaner` case, once the router writes
|
|
@@ -302,11 +350,10 @@ export function createFileTailer(options: FileTailerOptions): FileTailer {
|
|
|
302
350
|
// console with a whole log.
|
|
303
351
|
//
|
|
304
352
|
// Recovery from `missing` lands here, because a recreated file always
|
|
305
|
-
//
|
|
306
|
-
// not, and so cannot make us re-read
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
anchor(stats.size);
|
|
353
|
+
// stats differently while we hold the old one open — whereas a transient
|
|
354
|
+
// stat failure on the SAME file does not, and so cannot make us re-read
|
|
355
|
+
// and re-emit what we already sent.
|
|
356
|
+
if (!follow()) return;
|
|
310
357
|
} else if (stats.size < position) {
|
|
311
358
|
// Truncated in place (`copytruncate`): same file, fresh content, and the
|
|
312
359
|
// same cap again — a truncate followed by a large write before the next
|
|
@@ -316,7 +363,7 @@ export function createFileTailer(options: FileTailerOptions): FileTailer {
|
|
|
316
363
|
|
|
317
364
|
source = "ok";
|
|
318
365
|
detail = null;
|
|
319
|
-
drain(
|
|
366
|
+
drain();
|
|
320
367
|
} catch (error) {
|
|
321
368
|
// Belt and braces: a tail that throws would take the dashboard's poll
|
|
322
369
|
// loop with it.
|
|
@@ -376,6 +423,9 @@ export function createFileTailer(options: FileTailerOptions): FileTailer {
|
|
|
376
423
|
closed = true;
|
|
377
424
|
if (timer !== null) clearInterval(timer);
|
|
378
425
|
listeners.clear();
|
|
426
|
+
// The held descriptor pins its inode; leaking it would keep a rotated log
|
|
427
|
+
// occupying disk for as long as the process lives.
|
|
428
|
+
release();
|
|
379
429
|
},
|
|
380
430
|
};
|
|
381
431
|
}
|