@adrrr/tarmac 0.8.1 → 0.9.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/README.md +14 -0
- package/dist/args.js +26 -8
- package/dist/cli.js +65 -11
- package/dist/collect.js +2 -1
- package/dist/demo-history.js +184 -0
- package/dist/demo.js +392 -0
- package/dist/history-range.js +54 -9
- package/dist/history-store.js +12 -2
- package/dist/history-view.js +80 -7
- package/dist/render.js +25 -4
- package/dist/server.js +83 -22
- package/dist/sessions.js +8 -2
- package/dist/snapshots.js +27 -11
- package/package.json +1 -1
package/dist/render.js
CHANGED
|
@@ -192,6 +192,10 @@ export function renderTable({ rows, health }) {
|
|
|
192
192
|
warns.push(`! ${health.snapshotsUnreadable} snapshot file(s) present but unreadable — schema may have moved, check for a newer tarmac`);
|
|
193
193
|
if ((health.snapshotsDuplicates ?? 0) > 0)
|
|
194
194
|
warns.push(`! ${health.snapshotsDuplicates} snapshot file(s) claim a session id another file already claims — the freshest was kept`);
|
|
195
|
+
// Not folded into the line above: a name that is not a file is a directory somebody put
|
|
196
|
+
// something in, and sending them to look for a newer tarmac would be advice for the schema.
|
|
197
|
+
if ((health.snapshotsNotFiles ?? 0) > 0)
|
|
198
|
+
warns.push(`! ${health.snapshotsNotFiles} name(s) in the snapshot directory are not regular files — stepped over unread, never opened`);
|
|
195
199
|
// Covers both "not allowed to look" and "there is nothing there to look at", so the words
|
|
196
200
|
// have to fit an errno as well as a path that points nowhere.
|
|
197
201
|
if (health.snapshotsError)
|
|
@@ -365,6 +369,9 @@ export function renderLive(fleet) {
|
|
|
365
369
|
if ((health.snapshotsDuplicates ?? 0) > 0) {
|
|
366
370
|
warnings.push(`${health.snapshotsDuplicates} snapshot file(s) claim a session id another file already claims — the freshest reading was kept and the other ignored. Two wrappers may be writing into the same directory.`);
|
|
367
371
|
}
|
|
372
|
+
if ((health.snapshotsNotFiles ?? 0) > 0) {
|
|
373
|
+
warnings.push(`${health.snapshotsNotFiles} name(s) in the snapshot directory are not regular files — a directory, a link or a named pipe wearing a snapshot's name is stepped over rather than opened, because reading one can wait for ever. Whatever else is there was read as usual.`);
|
|
374
|
+
}
|
|
368
375
|
if (health.snapshotsError) {
|
|
369
376
|
// A permission error is ours to report, not the user's to be blamed for.
|
|
370
377
|
warnings.push(`The snapshot directory could not be used — ${health.snapshotsError}. Context readings are unavailable, and this is not an install problem.`);
|
|
@@ -576,7 +583,7 @@ function ago(ms) {
|
|
|
576
583
|
const m = Math.round(s / 60);
|
|
577
584
|
return m < 60 ? `${m}m` : `${Math.round(m / 60)}h`;
|
|
578
585
|
}
|
|
579
|
-
export function renderPage(fleet, view = 'table', { historyEnabled = false } = {}) {
|
|
586
|
+
export function renderPage(fleet, view = 'table', { historyEnabled = false, demo = false } = {}) {
|
|
580
587
|
// The header's copy. `renderLive` below renders its own, out of this same fleet and through
|
|
581
588
|
// this same function — two calls of one pure renderer over one reading, which is what keeps
|
|
582
589
|
// the pair the reader sees and the pair the script will copy up from being two accounts.
|
|
@@ -598,7 +605,15 @@ export function renderPage(fleet, view = 'table', { historyEnabled = false } = {
|
|
|
598
605
|
font:14px/1.5 ui-sans-serif,-apple-system,"Segoe UI",sans-serif; }
|
|
599
606
|
header { display:flex; align-items:baseline; gap:1rem; flex-wrap:wrap; margin-bottom:1rem; }
|
|
600
607
|
h1 { font-size:1.1rem; margin:0; letter-spacing:.02em; }
|
|
601
|
-
.meta { color:var(--dim); font-size:.85rem; }
|
|
608
|
+
.meta { color:var(--dim); font-size:.85rem; }${demo
|
|
609
|
+
? `
|
|
610
|
+
/* The demo marker. It borrows the warning's hues rather than the dim chrome the tabs use:
|
|
611
|
+
what it says is not chrome, and a badge a reader's eye files with the furniture is a badge
|
|
612
|
+
that is not in the screenshot as far as anybody looking at the screenshot is concerned.
|
|
613
|
+
Shipped only on a demo, so a plain serve is byte-for-byte the page it always was. */
|
|
614
|
+
.demo-tag { background:var(--warnbg); color:var(--warn); border:1px solid currentColor; border-radius:99px;
|
|
615
|
+
padding:.05rem .55rem; font-size:.75rem; font-weight:600; letter-spacing:.02em; white-space:nowrap; }`
|
|
616
|
+
: ''}
|
|
602
617
|
/* Honest, and out of the way of the fleet: three of these stacked at full padding pushed
|
|
603
618
|
the table below the fold on a laptop, which is its own kind of hidden. */
|
|
604
619
|
.warn { background:var(--warnbg); color:var(--warn); border:1px solid currentColor; border-radius:6px;
|
|
@@ -1077,7 +1092,13 @@ ${HISTORY_PHONE_CSS} }
|
|
|
1077
1092
|
</style>
|
|
1078
1093
|
</head><body data-view="${view}">
|
|
1079
1094
|
<header>
|
|
1080
|
-
<h1>tarmac</h1
|
|
1095
|
+
<h1>tarmac</h1>${demo
|
|
1096
|
+
? `
|
|
1097
|
+
<!-- Beside the title, in the shell, and never quiet. The one thing a screenshot of this page
|
|
1098
|
+
must not be able to do is pass for a real fleet, and the reader who can be misled is not
|
|
1099
|
+
the one running the serve — it is whoever is sent the picture afterwards. -->
|
|
1100
|
+
<span class="demo-tag" role="status">demo data — an invented fleet</span>`
|
|
1101
|
+
: ''}
|
|
1081
1102
|
<!-- Links, not buttons: the view survives a reload, a bookmark and a browser with
|
|
1082
1103
|
JavaScript off — the state of a page whose own noscript banner promises it is still
|
|
1083
1104
|
readable. Both views are in the fragment below either way, so switching costs the
|
|
@@ -1137,7 +1158,7 @@ ${HISTORY_PHONE_CSS} }
|
|
|
1137
1158
|
<input type="range" id="scrub" min="0" max="0" step="1" value="0" disabled aria-label="Replay position">
|
|
1138
1159
|
<div class="covers" id="covers"></div>
|
|
1139
1160
|
</div>
|
|
1140
|
-
${view === 'history' ? renderHistoryView({ historyEnabled }) : ''}
|
|
1161
|
+
${view === 'history' ? renderHistoryView({ historyEnabled, demo }) : ''}
|
|
1141
1162
|
<script>${pageScript(view)}</script>${view === 'history' ? `\n<script>${historyScript()}</script>` : ''}
|
|
1142
1163
|
</body></html>
|
|
1143
1164
|
`;
|
package/dist/server.js
CHANGED
|
@@ -6,7 +6,7 @@ import http from 'node:http';
|
|
|
6
6
|
import { reason, renderLive, renderPage } from './render.js';
|
|
7
7
|
import { hostName, SOURCE_PHRASE } from './config.js';
|
|
8
8
|
import { createHistory, HISTORY_CADENCE_MS } from './history.js';
|
|
9
|
-
import { HISTORY_RANGES
|
|
9
|
+
import { HISTORY_RANGES } from './history-range.js';
|
|
10
10
|
/**
|
|
11
11
|
* On every answer, including the refusals and the 500s. The page swaps what this port returns
|
|
12
12
|
* into `innerHTML`, and loopback proves where an answer came from, never who wrote it: a
|
|
@@ -16,13 +16,21 @@ import { HISTORY_RANGES, readRange } from './history-range.js';
|
|
|
16
16
|
* failures carry it too: their text is what it quotes as the reason.
|
|
17
17
|
*/
|
|
18
18
|
const IDENTITY = { 'x-tarmac': '1' };
|
|
19
|
+
/**
|
|
20
|
+
* A range read that never came back, kept apart from a range read that failed: one is a journal
|
|
21
|
+
* that answered with a broken file, the other a directory nothing came out of at all. The
|
|
22
|
+
* handler answers 500 for the first and 504 for the second, and a reader is sent to two
|
|
23
|
+
* different places by them.
|
|
24
|
+
*/
|
|
25
|
+
class JournalTimeout extends Error {
|
|
26
|
+
}
|
|
19
27
|
/** The addresses that serve the shell, and which view each one opens on. */
|
|
20
28
|
const PAGES = new Map([
|
|
21
29
|
['/', 'table'],
|
|
22
30
|
['/map', 'map'],
|
|
23
31
|
['/history', 'history'],
|
|
24
32
|
]);
|
|
25
|
-
export function createFleetServer({ collect, sampleEveryMs = HISTORY_CADENCE_MS, trustedHosts = [], store = null, rangeCacheMs = 60_000, report = (line) => console.error(line), }) {
|
|
33
|
+
export function createFleetServer({ collect, sampleEveryMs = HISTORY_CADENCE_MS, trustedHosts = [], store = null, rangeCacheMs = 60_000, readJournal = (s, range, now) => s.read(range, now), rangeDeadlineMs = 30_000, report = (line) => console.error(line), history = createHistory({ since: Date.now(), cadence: sampleEveryMs }), demo = false, }) {
|
|
26
34
|
// Normalised HERE rather than trusted to arrive that way. This is the last thing between a
|
|
27
35
|
// foreign origin and the fleet, so it owns both sides of its own comparison — the config
|
|
28
36
|
// parser cuts a name the same way, and neither leans on the other having done it.
|
|
@@ -33,17 +41,23 @@ export function createFleetServer({ collect, sampleEveryMs = HISTORY_CADENCE_MS,
|
|
|
33
41
|
// reachable from a flag, a variable or a file gets here empty; that is the parser's promise,
|
|
34
42
|
// and this is the guard not resting on it.
|
|
35
43
|
const trusted = new Set(trustedHosts.map((h) => hostName(h.trim()).toLowerCase()).filter((h) => h !== ''));
|
|
44
|
+
// The page wears a badge; this is the same fact for machines. The HTML shell is one of four
|
|
45
|
+
// surfaces this port answers, and the other three are JSON and a fragment a consumer may
|
|
46
|
+
// archive or forward — where an invented fleet with nothing on it saying so becomes a real
|
|
47
|
+
// one the moment the response body travels alone. On every answer for the same reason
|
|
48
|
+
// IDENTITY is: the refusals and the 500s of a demo serve are the demo's too.
|
|
49
|
+
const identity = demo ? { ...IDENTITY, 'x-tarmac-demo': '1' } : IDENTITY;
|
|
36
50
|
// Which rule refused, decided once. With hosts named, "loopback hosts only" would read as a
|
|
37
51
|
// flag that never took; with none, this is the sentence it has always been, to the byte. The
|
|
38
52
|
// Host itself is never quoted back: it is the one string on the request the caller wrote.
|
|
39
53
|
const refusal = trusted.size === 0
|
|
40
54
|
? 'tarmac serves loopback hosts only\n'
|
|
41
55
|
: 'tarmac serves loopback and trusted hosts only\n';
|
|
42
|
-
// What this serve has already read, kept for a day and never written down
|
|
43
|
-
// moment
|
|
44
|
-
// long the process has been up
|
|
45
|
-
// showing rather than an empty record pretending to be a young one.
|
|
46
|
-
|
|
56
|
+
// What this serve has already read, kept for a day and never written down, is the ring above
|
|
57
|
+
// — its `since` is the moment it was made, not the first sample that landed, so the span it
|
|
58
|
+
// covers is how long the process has been up and an hour of it with nothing in it is a fact
|
|
59
|
+
// worth showing rather than an empty record pretending to be a young one.
|
|
60
|
+
//
|
|
47
61
|
// One at a time. `claude agents --json` has a 15s deadline of its own, and a fleet slower
|
|
48
62
|
// than a slot would otherwise be answered with a queue of processes instead of one missed
|
|
49
63
|
// minute — the tick that finds a read still running counts the slot and stands down.
|
|
@@ -89,10 +103,15 @@ export function createFleetServer({ collect, sampleEveryMs = HISTORY_CADENCE_MS,
|
|
|
89
103
|
if (held !== undefined && (held.at === null || now - held.at < rangeCacheMs))
|
|
90
104
|
return held.reading;
|
|
91
105
|
const entry = { at: null, reading: undefined };
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
|
|
106
|
+
// Asked of the STORE, which owns both ends of its own journal: this route never learns where
|
|
107
|
+
// the days come from, so the invented week `serve --demo` carries arrives here as an ordinary
|
|
108
|
+
// range read and the demo gets no rendering path of its own (#156). Seeded past the store
|
|
109
|
+
// only by the suite, whose stuck read no real directory can produce (#136).
|
|
110
|
+
//
|
|
111
|
+
// Read once per range per minute rather than per request: the store walks its directory to
|
|
112
|
+
// answer, and a journal that stopped at its cap stays stopped for hours, so a minute-old
|
|
113
|
+
// answer to that question is the same answer.
|
|
114
|
+
entry.reading = readJournal(store, range, now).then((answer) => {
|
|
96
115
|
entry.at = Date.now();
|
|
97
116
|
return answer;
|
|
98
117
|
}, (e) => {
|
|
@@ -106,6 +125,36 @@ export function createFleetServer({ collect, sampleEveryMs = HISTORY_CADENCE_MS,
|
|
|
106
125
|
ranges.set(range, entry);
|
|
107
126
|
return entry.reading;
|
|
108
127
|
};
|
|
128
|
+
/**
|
|
129
|
+
* The same read, with a deadline on the REQUEST rather than on the read.
|
|
130
|
+
*
|
|
131
|
+
* A read that does not come back is not a read that can be taken back: nothing cancels a
|
|
132
|
+
* blocked `open`, and the thread it holds is held until the kernel hands it over. So the entry
|
|
133
|
+
* stays in the cache, and every request that arrives while it is out gives up on its own clock
|
|
134
|
+
* instead of starting a second read of a directory that is already not answering. If it ever
|
|
135
|
+
* does land, it is served — to whoever is asking then. The price is written down rather than
|
|
136
|
+
* hidden: an entry that never settles is never dated, so that range answers 504 for the life
|
|
137
|
+
* of the process, and a restart is what reads it again.
|
|
138
|
+
*
|
|
139
|
+
* What this bounds is the WAIT. `rangeOf` measures the directory synchronously before the read
|
|
140
|
+
* begins (`readdirSync`, then a `statSync` a file), so a volume that has stopped answering
|
|
141
|
+
* stops the event loop this timer would have to fire on. A deadline cannot save a thread that
|
|
142
|
+
* is not running; only asynchronous work is bounded here, and that is the whole of the claim.
|
|
143
|
+
*/
|
|
144
|
+
const answerRange = (store, range) => new Promise((resolve, reject) => {
|
|
145
|
+
const timer = setTimeout(() => reject(new JournalTimeout(`nothing came back from ${store.dir} in ${rangeDeadlineMs}ms`)), rangeDeadlineMs);
|
|
146
|
+
// The nominal path pays one timer and no latency: the answer clears it on the way past.
|
|
147
|
+
// The unref is the belt, and a narrow one worth naming rather than overselling: while the
|
|
148
|
+
// request is out its own socket holds the loop, so the only window this covers is a
|
|
149
|
+
// deadline still counting after the socket has gone. `bounded.ts` carries the same line
|
|
150
|
+
// for a case that IS reachable in a test, and has one; this one has no test of its own.
|
|
151
|
+
timer.unref();
|
|
152
|
+
// Attached, rather than raced with `Promise.race`: this is what marks the shared read's own
|
|
153
|
+
// failure handled when it arrives after the request that started it has already given up.
|
|
154
|
+
rangeOf(store, range)
|
|
155
|
+
.then(resolve, reject)
|
|
156
|
+
.finally(() => clearTimeout(timer));
|
|
157
|
+
});
|
|
109
158
|
const sample = async () => {
|
|
110
159
|
// First, and outside the try: the journal's lock says this process is alive, which is a
|
|
111
160
|
// fact about the tick and not about the reading. A collector that has been throwing for
|
|
@@ -135,7 +184,7 @@ export function createFleetServer({ collect, sampleEveryMs = HISTORY_CADENCE_MS,
|
|
|
135
184
|
// reader trusted on top of that is a name, exactly: matched whole, never as a prefix, a
|
|
136
185
|
// suffix or a pattern, so trusting one host can never be trusting a family of them.
|
|
137
186
|
if (!isLoopbackHost(req.headers.host) && !isTrustedHost(req.headers.host, trusted)) {
|
|
138
|
-
res.writeHead(403, { ...
|
|
187
|
+
res.writeHead(403, { ...identity, 'content-type': 'text/plain; charset=utf-8' });
|
|
139
188
|
res.end(refusal);
|
|
140
189
|
return;
|
|
141
190
|
}
|
|
@@ -146,7 +195,7 @@ export function createFleetServer({ collect, sampleEveryMs = HISTORY_CADENCE_MS,
|
|
|
146
195
|
// spawned; a client that sends no label (curl, a script) is left alone.
|
|
147
196
|
const site = req.headers['sec-fetch-site'];
|
|
148
197
|
if (typeof site === 'string' && site !== 'same-origin' && site !== 'none') {
|
|
149
|
-
res.writeHead(403, { ...
|
|
198
|
+
res.writeHead(403, { ...identity, 'content-type': 'text/plain; charset=utf-8' });
|
|
150
199
|
res.end('tarmac serves same-origin requests only\n');
|
|
151
200
|
return;
|
|
152
201
|
}
|
|
@@ -163,7 +212,7 @@ export function createFleetServer({ collect, sampleEveryMs = HISTORY_CADENCE_MS,
|
|
|
163
212
|
url.pathname !== '/live' &&
|
|
164
213
|
url.pathname !== '/api/fleet' &&
|
|
165
214
|
url.pathname !== '/api/history') {
|
|
166
|
-
res.writeHead(404, { ...
|
|
215
|
+
res.writeHead(404, { ...identity, 'content-type': 'text/plain; charset=utf-8' });
|
|
167
216
|
res.end('not found\n');
|
|
168
217
|
return;
|
|
169
218
|
}
|
|
@@ -179,7 +228,7 @@ export function createFleetServer({ collect, sampleEveryMs = HISTORY_CADENCE_MS,
|
|
|
179
228
|
if (!isRange(asked)) {
|
|
180
229
|
// The value is not quoted back, for the reason the refused Host is not: it is a string
|
|
181
230
|
// the caller wrote, and the page swaps a refusal's text into `innerHTML` as its reason.
|
|
182
|
-
res.writeHead(400, { ...
|
|
231
|
+
res.writeHead(400, { ...identity, 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' });
|
|
183
232
|
res.end(`tarmac serves /api/history for 24h, ${HISTORY_RANGES.join(' and ')}; no range is the last 24h\n`);
|
|
184
233
|
return;
|
|
185
234
|
}
|
|
@@ -187,17 +236,22 @@ export function createFleetServer({ collect, sampleEveryMs = HISTORY_CADENCE_MS,
|
|
|
187
236
|
// would draw a flat line over a month the fleet was busy for.
|
|
188
237
|
let body;
|
|
189
238
|
try {
|
|
190
|
-
body = store === null ? { enabled: false, range: asked } : { enabled: true, ...(await
|
|
239
|
+
body = store === null ? { enabled: false, range: asked } : { enabled: true, ...(await answerRange(store, asked)) };
|
|
191
240
|
}
|
|
192
241
|
catch (e) {
|
|
193
242
|
// Nothing in `readRange` is allowed to throw, and this is the seam that keeps a day
|
|
194
243
|
// when something does from being a request that hangs instead of an answer.
|
|
195
|
-
|
|
244
|
+
//
|
|
245
|
+
// A read that has not come back is the other failure, and it is not the same news: 500
|
|
246
|
+
// is a read that came back with a failure, 504 is a read that did not come back at all
|
|
247
|
+
// — the first sends a reader to the file, the second to the directory. The page prints
|
|
248
|
+
// whichever sentence arrives as the reason the charts are empty.
|
|
249
|
+
res.writeHead(e instanceof JournalTimeout ? 504 : 500, { ...identity, 'content-type': 'text/plain; charset=utf-8' });
|
|
196
250
|
res.end(`tarmac could not read the fleet journal:\n${reason(e)}\n`);
|
|
197
251
|
return;
|
|
198
252
|
}
|
|
199
253
|
res.writeHead(200, {
|
|
200
|
-
...
|
|
254
|
+
...identity,
|
|
201
255
|
'content-type': 'application/json; charset=utf-8',
|
|
202
256
|
'cache-control': 'no-store',
|
|
203
257
|
});
|
|
@@ -207,7 +261,7 @@ export function createFleetServer({ collect, sampleEveryMs = HISTORY_CADENCE_MS,
|
|
|
207
261
|
}
|
|
208
262
|
if (url.pathname === '/api/history') {
|
|
209
263
|
res.writeHead(200, {
|
|
210
|
-
...
|
|
264
|
+
...identity,
|
|
211
265
|
'content-type': 'application/json; charset=utf-8',
|
|
212
266
|
'cache-control': 'no-store',
|
|
213
267
|
});
|
|
@@ -239,18 +293,18 @@ export function createFleetServer({ collect, sampleEveryMs = HISTORY_CADENCE_MS,
|
|
|
239
293
|
body =
|
|
240
294
|
url.pathname === '/live'
|
|
241
295
|
? renderLive(fleet)
|
|
242
|
-
: renderPage(fleet, PAGES.get(url.pathname), { historyEnabled: store !== null });
|
|
296
|
+
: renderPage(fleet, PAGES.get(url.pathname), { historyEnabled: store !== null, demo });
|
|
243
297
|
}
|
|
244
298
|
}
|
|
245
299
|
catch (e) {
|
|
246
300
|
// Say why. A dashboard that goes blank when its source breaks teaches nothing.
|
|
247
|
-
res.writeHead(500, { ...
|
|
301
|
+
res.writeHead(500, { ...identity, 'content-type': 'text/plain; charset=utf-8' });
|
|
248
302
|
res.end(`tarmac could not read the fleet:\n${reason(e)}\n`);
|
|
249
303
|
return;
|
|
250
304
|
}
|
|
251
305
|
// A page whose entire claim is freshness must not be served from a cache: a restored tab
|
|
252
306
|
// re-running the script over stale HTML would re-stamp it "updated just now".
|
|
253
|
-
res.writeHead(200, { ...
|
|
307
|
+
res.writeHead(200, { ...identity, 'content-type': type, 'cache-control': 'no-store' });
|
|
254
308
|
res.end(body);
|
|
255
309
|
});
|
|
256
310
|
// The sampler lives exactly as long as the serving does. Started at construction it kept
|
|
@@ -266,6 +320,13 @@ export function createFleetServer({ collect, sampleEveryMs = HISTORY_CADENCE_MS,
|
|
|
266
320
|
server.on('listening', () => {
|
|
267
321
|
if (sampler !== null)
|
|
268
322
|
return;
|
|
323
|
+
// A demo does not sample. Its collector answers one frozen minute, so every tick would
|
|
324
|
+
// record that same minute into the ring and push a minute of the invented day off the far
|
|
325
|
+
// end: one slot a minute, until a serve left up overnight is showing 1440 copies of one
|
|
326
|
+
// reading. That is the flat chart this whole feature exists to replace. The record it was
|
|
327
|
+
// handed is the record it keeps, and the live view stays honestly dated either way.
|
|
328
|
+
if (demo)
|
|
329
|
+
return;
|
|
269
330
|
// The journal's retention, applied before the first line of this run is written and once a
|
|
270
331
|
// local day after that (the store keeps that half itself). Here rather than in the CLI so
|
|
271
332
|
// that the store `serve` prunes with is, provably, the store `serve` writes with: a `serve`
|
package/dist/sessions.js
CHANGED
|
@@ -55,8 +55,14 @@ export function parseAgents(text) {
|
|
|
55
55
|
health.noSessionId += 1;
|
|
56
56
|
continue;
|
|
57
57
|
}
|
|
58
|
-
|
|
59
|
-
|
|
58
|
+
// The empty string is not an id, and it is a `string`, so the type check alone let it
|
|
59
|
+
// through: counted as missing here, then carried as `''` to every reader and written to
|
|
60
|
+
// the journal as `sid: ''`. Each reader neutralises that value its own way (#137) —
|
|
61
|
+
// normalising at the source says it once, and stops counting the same nameless entry
|
|
62
|
+
// both here and as unfilable downstream. Absent reads `null` like every field here;
|
|
63
|
+
// sessionId alone also folds `''` into absent, an empty id being no id at all.
|
|
64
|
+
const sessionId = typeof entry.sessionId === 'string' && entry.sessionId !== '' ? entry.sessionId : null;
|
|
65
|
+
if (sessionId === null)
|
|
60
66
|
health.noSessionId += 1;
|
|
61
67
|
// A background agent carries no `status` at all — its word is under `state`. `status`
|
|
62
68
|
// still wins where both are present: it comes from the agent's own process, and `state`
|
package/dist/snapshots.js
CHANGED
|
@@ -86,11 +86,13 @@ export function readSnapshots(dir, { now = Date.now() } = {}) {
|
|
|
86
86
|
snapshots,
|
|
87
87
|
dirError: code === 'ENOENT' ? null : `${code}: ${dir}`,
|
|
88
88
|
unreadable: 0,
|
|
89
|
+
notFiles: 0,
|
|
89
90
|
duplicates: 0,
|
|
90
91
|
dirMissing: code === 'ENOENT',
|
|
91
92
|
};
|
|
92
93
|
}
|
|
93
94
|
let unreadable = 0;
|
|
95
|
+
let notFiles = 0;
|
|
94
96
|
let duplicates = 0;
|
|
95
97
|
for (const name of entries) {
|
|
96
98
|
if (!name.endsWith('.json') || name.startsWith('.'))
|
|
@@ -99,7 +101,27 @@ export function readSnapshots(dir, { now = Date.now() } = {}) {
|
|
|
99
101
|
let payload;
|
|
100
102
|
let mtimeMs;
|
|
101
103
|
try {
|
|
102
|
-
|
|
104
|
+
// The kind is asked BEFORE the open, and only a regular file is opened. What wears a
|
|
105
|
+
// snapshot's name is not this reader's to assume: the directory belongs to whoever owns
|
|
106
|
+
// the machine and a wrapper can be pointed at any of them, while `readFileSync` on a FIFO
|
|
107
|
+
// waits for someone to write to the other end — a wait with nothing to end it, on the
|
|
108
|
+
// loop under every surface tarmac has (#160).
|
|
109
|
+
//
|
|
110
|
+
// `lstat`, not `stat`, and not for the pipe: `stat` refuses a link to one exactly as this
|
|
111
|
+
// does. The one shape the two disagree about is a link to an ordinary file, and refusing
|
|
112
|
+
// that is the choice — a snapshot is what the wrapper wrote, and a link is a name someone
|
|
113
|
+
// else put there, aimed at something nobody told this reader about. The journal reader
|
|
114
|
+
// decides the same way for the same reason (#159); this is the hotter path.
|
|
115
|
+
//
|
|
116
|
+
// A check before an open is a race, and it stays one: nothing stops the name being
|
|
117
|
+
// replaced between the two. What that costs is bounded — one blocked read, on a directory
|
|
118
|
+
// somebody is racing — where reading the kind off the open would cost every read.
|
|
119
|
+
const stat = fs.lstatSync(file);
|
|
120
|
+
if (!stat.isFile()) {
|
|
121
|
+
notFiles += 1;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
mtimeMs = stat.mtimeMs;
|
|
103
125
|
payload = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
104
126
|
}
|
|
105
127
|
catch (e) {
|
|
@@ -108,15 +130,9 @@ export function readSnapshots(dir, { now = Date.now() } = {}) {
|
|
|
108
130
|
// and a race with our own housekeeping rather than a payload we failed to parse.
|
|
109
131
|
// Counting it made tarmac drive its own format-drift warning (up to 2675 phantom
|
|
110
132
|
// unreadable on one read of a 20k directory, and `list --watch` and `serve` redraw
|
|
111
|
-
// often enough to be inside that window).
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
// snapshot is ENOENT too, and it goes silent forever — a permanent state skipped as if
|
|
115
|
-
// it were a passing race. Deliberate. There is no payload behind a dead link either,
|
|
116
|
-
// and telling the two apart (an `lstat` first) buys a warning about a file `ls` already
|
|
117
|
-
// shows. Note it is the opposite call from `reap.ts:75`, which lstats PRECISELY so a
|
|
118
|
-
// dead link is not ENOENT: it deletes, and `unlink` takes a link away just fine. Reader
|
|
119
|
-
// and reaper ask different questions of the same shape.
|
|
133
|
+
// often enough to be inside that window). A dead link named like a snapshot used to land
|
|
134
|
+
// here too, `statSync` having followed it — a permanent state skipped as a passing race;
|
|
135
|
+
// the kind check above refuses it by name now, and counts it.
|
|
120
136
|
//
|
|
121
137
|
// ENOENT only. A file we were not ALLOWED to open still counts, and must.
|
|
122
138
|
if (e.code !== 'ENOENT')
|
|
@@ -134,7 +150,7 @@ export function readSnapshots(dir, { now = Date.now() } = {}) {
|
|
|
134
150
|
duplicates += 1;
|
|
135
151
|
snapshots.set(t.sessionId, already ? preferred(already, snapshot) : snapshot);
|
|
136
152
|
}
|
|
137
|
-
return { snapshots, dirError: null, unreadable, duplicates, dirMissing: false };
|
|
153
|
+
return { snapshots, dirError: null, unreadable, notFiles, duplicates, dirMissing: false };
|
|
138
154
|
}
|
|
139
155
|
/**
|
|
140
156
|
* Which of two snapshots claiming one session a reader is shown.
|