@adrrr/tarmac 0.8.1 → 0.10.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 +27 -9
- package/dist/args.js +26 -8
- package/dist/cli.js +65 -11
- package/dist/collect.js +2 -1
- package/dist/demo-history.js +244 -0
- package/dist/demo.js +392 -0
- package/dist/fleet.js +14 -4
- package/dist/history-range.js +56 -9
- package/dist/history-store.js +12 -2
- package/dist/history-view.js +317 -63
- package/dist/map.js +6 -22
- package/dist/render.js +633 -114
- package/dist/schema.js +12 -3
- package/dist/server.js +83 -22
- package/dist/sessions.js +33 -2
- package/dist/snapshots.js +27 -11
- package/dist/wrapper.js +27 -10
- package/package.json +1 -1
package/dist/schema.js
CHANGED
|
@@ -63,10 +63,19 @@ export function guardVersions(seen) {
|
|
|
63
63
|
const state = noVersion > 0 ? 'no-version' : unchecked.length > 0 ? 'unchecked' : 'ok';
|
|
64
64
|
return { state, versions, noVersion, unchecked };
|
|
65
65
|
}
|
|
66
|
-
/** What a human should be told,
|
|
66
|
+
/** What a human should be told, in the halves a page shows it in. `null` when there is nothing. */
|
|
67
|
+
export function schemaNoteParts(guard) {
|
|
68
|
+
const said = schemaSaid(guard);
|
|
69
|
+
return said.length === 0 ? null : { key: 'schema', lead: said[0], rest: said.slice(1).join(' ') };
|
|
70
|
+
}
|
|
71
|
+
/** The same notice, whole. What every caller outside the page still asks for. */
|
|
67
72
|
export function schemaNotice(guard) {
|
|
73
|
+
const parts = schemaNoteParts(guard);
|
|
74
|
+
return parts === null ? null : `${parts.lead} ${parts.rest}`.trim();
|
|
75
|
+
}
|
|
76
|
+
function schemaSaid(guard) {
|
|
68
77
|
if (guard.state === 'ok' || guard.state === 'nothing')
|
|
69
|
-
return
|
|
78
|
+
return [];
|
|
70
79
|
const said = [];
|
|
71
80
|
if (guard.noVersion > 0) {
|
|
72
81
|
const total = guard.noVersion + guard.versions.length;
|
|
@@ -83,5 +92,5 @@ export function schemaNotice(guard) {
|
|
|
83
92
|
// and has no scripts/ directory. Capturing a fixture is a maintainer's move, documented
|
|
84
93
|
// in the README, not something to send a user looking for.
|
|
85
94
|
said.push(`Nothing is blocked and no reading is hidden; if a column starts coming up empty, update tarmac or report it at ${ISSUES_URL}.`);
|
|
86
|
-
return said
|
|
95
|
+
return said;
|
|
87
96
|
}
|
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
|
@@ -36,6 +36,31 @@ const KNOWN_STATUS = new Map([
|
|
|
36
36
|
const WAITING = 'waiting';
|
|
37
37
|
/** Whether this reading is halted on a human. The one status the renderers treat as a state. */
|
|
38
38
|
export const isWaiting = (s) => s.status === WAITING;
|
|
39
|
+
/**
|
|
40
|
+
* The kind a terminal calls itself, and the anchor the two rules below reason from. A
|
|
41
|
+
* background entry has since been seen beside them — `kind: 'background'`, no `pid`, its word
|
|
42
|
+
* under `state` rather than `status` — so the two are no longer a reading of that CLI's help.
|
|
43
|
+
* It is still the anchor and never the list: one observed alternative is not the vocabulary,
|
|
44
|
+
* and the question asked is only whether anything on this machine still calls itself
|
|
45
|
+
* `interactive`.
|
|
46
|
+
*/
|
|
47
|
+
export const INTERACTIVE = 'interactive';
|
|
48
|
+
/**
|
|
49
|
+
* Whether this fleet still speaks the kind we know. If NOTHING calls itself `interactive`, the
|
|
50
|
+
* word moved rather than every terminal on the machine going background at once — and the
|
|
51
|
+
* readers say so by treating them all as what they almost certainly still are. Same tolerance
|
|
52
|
+
* `buildFleet` already applies to telemetry: a signal true of every row is a change in the
|
|
53
|
+
* source.
|
|
54
|
+
*/
|
|
55
|
+
export const anchoredOnKind = (rows) => rows.some((r) => r.kind === INTERACTIVE);
|
|
56
|
+
/**
|
|
57
|
+
* A background entry, under the anchor above. An ABSENT kind is not evidence of one: the same
|
|
58
|
+
* rule the status follows, where unrecognised means unknown, never "the quiet one". The two
|
|
59
|
+
* mistakes are not the same size — an agent read as a terminal is a row expecting a frame that
|
|
60
|
+
* will not come, while a terminal read as an agent is someone's open session dropped out of
|
|
61
|
+
* every count that is about them.
|
|
62
|
+
*/
|
|
63
|
+
export const isBackgroundAgent = (r, anchored) => anchored && r.kind !== null && r.kind !== INTERACTIVE;
|
|
39
64
|
/** @param text raw stdout of `claude agents --json` */
|
|
40
65
|
export function parseAgents(text) {
|
|
41
66
|
let raw;
|
|
@@ -55,8 +80,14 @@ export function parseAgents(text) {
|
|
|
55
80
|
health.noSessionId += 1;
|
|
56
81
|
continue;
|
|
57
82
|
}
|
|
58
|
-
|
|
59
|
-
|
|
83
|
+
// The empty string is not an id, and it is a `string`, so the type check alone let it
|
|
84
|
+
// through: counted as missing here, then carried as `''` to every reader and written to
|
|
85
|
+
// the journal as `sid: ''`. Each reader neutralises that value its own way (#137) —
|
|
86
|
+
// normalising at the source says it once, and stops counting the same nameless entry
|
|
87
|
+
// both here and as unfilable downstream. Absent reads `null` like every field here;
|
|
88
|
+
// sessionId alone also folds `''` into absent, an empty id being no id at all.
|
|
89
|
+
const sessionId = typeof entry.sessionId === 'string' && entry.sessionId !== '' ? entry.sessionId : null;
|
|
90
|
+
if (sessionId === null)
|
|
60
91
|
health.noSessionId += 1;
|
|
61
92
|
// A background agent carries no `status` at all — its word is under `state`. `status`
|
|
62
93
|
// 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.
|
package/dist/wrapper.js
CHANGED
|
@@ -157,15 +157,20 @@ esac
|
|
|
157
157
|
# --- drop the snapshot (best effort, atomic: temp file + rename in the same dir) ---
|
|
158
158
|
if [ -n "$sid" ] && mkdir -p "$TARMAC_DIR" 2>/dev/null; then
|
|
159
159
|
tmp="$TARMAC_DIR/${TEMP_PREFIX}$sid.$$.tmp"
|
|
160
|
-
# \`2>/dev/null\`
|
|
161
|
-
#
|
|
162
|
-
#
|
|
163
|
-
#
|
|
164
|
-
#
|
|
165
|
-
#
|
|
166
|
-
#
|
|
167
|
-
#
|
|
168
|
-
|
|
160
|
+
# The \`2>/dev/null\` is on the GROUP, and that placement is the whole point: a redirection
|
|
161
|
+
# that cannot be performed is reported by the shell itself, not by \`printf\`, so it has to
|
|
162
|
+
# be silenced by something already in force when the failing redirection is attempted. On
|
|
163
|
+
# the simple command it is not — \`> "$tmp" 2>/dev/null\` opens the temp file while stderr is
|
|
164
|
+
# STILL the user's terminal, and even \`2>/dev/null > "$tmp"\`, which reads as the fix and
|
|
165
|
+
# was one under every POSIX sh, leaves zsh printing \`permission denied\` on the terminal.
|
|
166
|
+
# The group is entered first, so its stderr is /dev/null before the command inside it is
|
|
167
|
+
# attempted, and that holds under every shell here. On a snapshot directory that has become
|
|
168
|
+
# read-only the difference is one line of noise per FRAME, on the terminal of a script whose
|
|
169
|
+
# first rule is to be invisible. Exit code and display are untouched either way (\`printf\` is
|
|
170
|
+
# a regular built-in, so a failed redirection only fails the command, and a group carries
|
|
171
|
+
# the status of what it ran), which is exactly why nothing but stderr itself catches this.
|
|
172
|
+
# RULE 1.
|
|
173
|
+
if { printf '%s\\n' "$payload" > "$tmp"; } 2>/dev/null; then
|
|
169
174
|
mv -f "$tmp" "$TARMAC_DIR/$sid.json" 2>/dev/null || rm -f "$tmp" 2>/dev/null
|
|
170
175
|
else
|
|
171
176
|
rm -f "$tmp" 2>/dev/null
|
|
@@ -293,7 +298,19 @@ else
|
|
|
293
298
|
case "$rest" in
|
|
294
299
|
*'"'*)
|
|
295
300
|
rest=\${rest#*'"'}
|
|
296
|
-
|
|
301
|
+
# Cut first, print second, and the two lines are not one on purpose. Spelled
|
|
302
|
+
# \`printf '%s\\n' "\${rest%%'"'*}"\` — a quoted pattern inside a quoted expansion —
|
|
303
|
+
# zsh reads the inner \`'\` as a literal character rather than as quoting, and the
|
|
304
|
+
# script ends on an unmatched \`"\`: a parse error, so nothing runs and the status
|
|
305
|
+
# line is blank. No POSIX sh does that, and no distribution ships zsh as
|
|
306
|
+
# \`/bin/sh\`, but linking it there is one command and this file's promise is that
|
|
307
|
+
# the wrapper runs wherever it is pointed. Dropping the outer quotes is the other
|
|
308
|
+
# way to make it parse, and it is wrong: unquoted, \`Fable 5\` reaches \`printf\` as
|
|
309
|
+
# two arguments and prints on two lines, and a \`*\` in a display name is expanded
|
|
310
|
+
# against the working directory. An assignment splits nothing and globs nothing,
|
|
311
|
+
# so the cut is safe unquoted and the value is quoted where it is used.
|
|
312
|
+
display=\${rest%%'"'*}
|
|
313
|
+
printf '%s\\n' "$display"
|
|
297
314
|
;;
|
|
298
315
|
esac
|
|
299
316
|
;;
|