ccdeck 3.29.11 → 3.30.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 +1 -1
- package/bin/agent-dag.js +67 -118
- package/bin/cli/help.js +67 -0
- package/bin/cli/login-item.js +182 -0
- package/bin/cli/one-shot.js +254 -0
- package/bin/cli/package.js +39 -0
- package/bin/cli/pulse.js +98 -0
- package/bin/cli/restart.js +179 -0
- package/bin/cli/screen.js +166 -0
- package/bin/cli/second-start.js +120 -0
- package/bin/cli/startup.js +459 -0
- package/bin/cli/uninstall.js +169 -0
- package/bin/deck.js +112 -1559
- package/dist/web/assets/BrowserWatchModal-DP3YMtEi.js +1 -0
- package/dist/web/assets/UsageHistoryModal-CcdPVXAw.js +1 -0
- package/dist/web/assets/index-3vS6A0vt.css +1 -0
- package/dist/web/assets/index-BVTWHDta.js +298 -0
- package/dist/web/index.html +2 -2
- package/hook/hook.js +15 -14
- package/package.json +1 -1
- package/release-notes.json +22 -0
- package/src/server/account-projects.mjs +25 -11
- package/src/server/account-routes.mjs +254 -0
- package/src/server/activity.mjs +3 -3
- package/src/server/append-budget.mjs +185 -0
- package/src/server/append-failures.mjs +147 -0
- package/src/server/args.mjs +37 -5
- package/src/server/atomic-write.mjs +300 -0
- package/src/server/block-notify.mjs +0 -5
- package/src/server/boot-lock.mjs +1 -1
- package/src/server/brand.mjs +7 -6
- package/src/server/browser-react.mjs +10 -2
- package/src/server/browser-watch-decks.mjs +146 -0
- package/src/server/browser-watch-feed.mjs +69 -0
- package/src/server/browser-watch-log.mjs +173 -0
- package/src/server/browser-watch-mtime.mjs +18 -0
- package/src/server/browser-watch-relay.mjs +112 -0
- package/src/server/browser-watch-routes.mjs +172 -0
- package/src/server/browser-watch-seen.mjs +127 -0
- package/src/server/browser-watch-store.mjs +14 -143
- package/src/server/browser-watch.mjs +278 -626
- package/src/server/canonical-path.mjs +150 -0
- package/src/server/ccusage-failure.mjs +64 -0
- package/src/server/ccusage-install.mjs +459 -0
- package/src/server/ccusage-runner.mjs +271 -0
- package/src/server/ccusage.mjs +21 -756
- package/src/server/claude-accounts.mjs +220 -464
- package/src/server/claude-collector.mjs +189 -0
- package/src/server/claude-dir.mjs +2 -2
- package/src/server/claude-identity.mjs +5 -4
- package/src/server/claude-reset-credits.mjs +130 -0
- package/src/server/claude-verdicts.mjs +174 -0
- package/src/server/clear-route.mjs +191 -0
- package/src/server/codex-auth.mjs +4 -4
- package/src/server/codex-dir.mjs +52 -8
- package/src/server/codex-enrichment.mjs +310 -0
- package/src/server/codex-quota.mjs +5 -3
- package/src/server/codex-translate.mjs +407 -0
- package/src/server/codex-usage.mjs +8 -5
- package/src/server/codex-watch.mjs +496 -0
- package/src/server/cswap-auto-loop.mjs +342 -0
- package/src/server/cswap-auto-readers.mjs +303 -0
- package/src/server/cswap-auto.mjs +51 -609
- package/src/server/cswap-install.mjs +69 -265
- package/src/server/cswap-layout.mjs +242 -0
- package/src/server/deck-home.mjs +1 -1
- package/src/server/deck-prefs.mjs +51 -184
- package/src/server/deck-scope.mjs +52 -0
- package/src/server/desktop-update-routes.mjs +75 -0
- package/src/server/detach.mjs +78 -0
- package/src/server/discovery.mjs +298 -0
- package/src/server/event-log.mjs +328 -0
- package/src/server/event-pipeline.mjs +235 -0
- package/src/server/event-ring.mjs +214 -0
- package/src/server/event-routes.mjs +352 -0
- package/src/server/event-sink.mjs +26 -0
- package/src/server/exec-children.mjs +128 -0
- package/src/server/exec-not-found.mjs +213 -0
- package/src/server/exec-output.mjs +23 -0
- package/src/server/exec-spec.mjs +341 -0
- package/src/server/exec.mjs +74 -661
- package/src/server/health-route.mjs +42 -0
- package/src/server/hook-challenge.mjs +37 -0
- package/src/server/http-io.mjs +104 -0
- package/src/server/index.mjs +172 -7620
- package/src/server/install-layout.mjs +506 -0
- package/src/server/installer.mjs +79 -608
- package/src/server/jsonl-chunks.mjs +139 -0
- package/src/server/lan-accounts.mjs +27 -0
- package/src/server/lan-beacon.mjs +457 -0
- package/src/server/lan-call.mjs +239 -0
- package/src/server/lan-copies.mjs +270 -0
- package/src/server/lan-deck.mjs +583 -0
- package/src/server/lan-dials.mjs +161 -0
- package/src/server/lan-engine.mjs +475 -1024
- package/src/server/lan-hearing.mjs +49 -0
- package/src/server/lan-inbound.mjs +100 -0
- package/src/server/lan-invite-offer.mjs +77 -0
- package/src/server/lan-invite.mjs +207 -0
- package/src/server/lan-lines.mjs +73 -0
- package/src/server/lan-manifest.mjs +74 -0
- package/src/server/lan-reach.mjs +82 -69
- package/src/server/lan-requests.mjs +249 -0
- package/src/server/lan-round-record.mjs +45 -0
- package/src/server/lan-round-timer.mjs +62 -0
- package/src/server/lan-routes.mjs +143 -0
- package/src/server/lan-serve.mjs +112 -0
- package/src/server/lan-socket.mjs +243 -925
- package/src/server/lan-sync.mjs +58 -1026
- package/src/server/lan-tailnet-poll.mjs +57 -0
- package/src/server/lan-turns.mjs +80 -0
- package/src/server/lan-wire.mjs +620 -0
- package/src/server/lifecycle.mjs +421 -0
- package/src/server/listen.mjs +110 -0
- package/src/server/live-decks.mjs +138 -0
- package/src/server/load-average.mjs +32 -0
- package/src/server/log-election.mjs +235 -0
- package/src/server/log-replay.mjs +374 -0
- package/src/server/log-writer.mjs +72 -557
- package/src/server/login-service.mjs +3 -2
- package/src/server/macmon.mjs +1 -1
- package/src/server/memory-files.mjs +163 -0
- package/src/server/memory-metrics.mjs +178 -0
- package/src/server/metrics-history.mjs +94 -0
- package/src/server/metrics-run.mjs +100 -0
- package/src/server/music-routes.mjs +134 -0
- package/src/server/network-metrics.mjs +5 -3
- package/src/server/network-sampler.mjs +256 -0
- package/src/server/npm-latest.mjs +385 -0
- package/src/server/npm-upgrade.mjs +352 -0
- package/src/server/npx.mjs +41 -7
- package/src/server/output-watch.mjs +5 -4
- package/src/server/pinned-build.mjs +108 -0
- package/src/server/prefs-path.mjs +14 -0
- package/src/server/prefs-refusal.mjs +77 -0
- package/src/server/prefs-routes.mjs +76 -0
- package/src/server/prefs-set-aside.mjs +86 -0
- package/src/server/prefs-state.mjs +51 -0
- package/src/server/process-command.mjs +104 -0
- package/src/server/process-list.mjs +547 -0
- package/src/server/pulse-line.mjs +132 -0
- package/src/server/purge-key.mjs +7 -7
- package/src/server/quota-cli.mjs +174 -0
- package/src/server/quota-oauth.mjs +190 -0
- package/src/server/quota-shape.mjs +229 -0
- package/src/server/quota-store-resets.mjs +206 -0
- package/src/server/quota.mjs +123 -509
- package/src/server/relay-guard.mjs +3 -3
- package/src/server/request-gates.mjs +441 -0
- package/src/server/restart-note.mjs +180 -0
- package/src/server/retire-sound-hook.mjs +3 -3
- package/src/server/ring-bounds.mjs +215 -0
- package/src/server/running-deck.mjs +1 -1
- package/src/server/running-version.mjs +24 -0
- package/src/server/self-update.mjs +39 -1371
- package/src/server/session-enrichment.mjs +622 -0
- package/src/server/session-read-gate.mjs +49 -0
- package/src/server/session-tracking.mjs +242 -0
- package/src/server/sse-clients.mjs +222 -0
- package/src/server/stale-discovery.mjs +30 -0
- package/src/server/static-serve.mjs +81 -0
- package/src/server/supervisor.mjs +73 -12
- package/src/server/system-metrics.mjs +169 -1935
- package/src/server/term.mjs +11 -249
- package/src/server/thermal-metrics.mjs +520 -0
- package/src/server/thermal-sampler.mjs +204 -0
- package/src/server/token-redact.mjs +123 -0
- package/src/server/transcript-gate.mjs +107 -0
- package/src/server/transcript-scan.mjs +563 -0
- package/src/server/usage-routes.mjs +93 -0
- package/src/server/uv-bootstrap.mjs +1 -1
- package/src/server/wordmark.mjs +127 -0
- package/dist/web/assets/BrowserWatchModal-CyAFTvAY.js +0 -1
- package/dist/web/assets/UsageHistoryModal-KJfli0oa.js +0 -1
- package/dist/web/assets/index-Ck2RAla3.css +0 -1
- package/dist/web/assets/index-jmrcEsCj.js +0 -298
|
@@ -1,240 +1,24 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Appending to the events log that several decks may share: one whole line per
|
|
2
|
+
// write(2), in the order pushEvent produced them, behind a queue that is
|
|
3
|
+
// bounded and counts what it refused and what failed to land — and the drain,
|
|
4
|
+
// the flush and the Clear that take their turn on that same queue.
|
|
2
5
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// elects one per file, and marks the request to every other one `?persist=0`.
|
|
6
|
-
// See electWriters in hook/hook.js.
|
|
7
|
-
//
|
|
8
|
-
// The Codex rollout watcher never goes through the hook — it builds its events
|
|
9
|
-
// inside the server by tailing ~/.codex/sessions/**/rollout-*.jsonl — so nothing
|
|
10
|
-
// suppressed the copies on that path: every deck tailing the same rollout
|
|
11
|
-
// appended its own line to the one events.jsonl they all default to, so each
|
|
12
|
-
// Codex tool call, prompt and session start landed there once per running deck.
|
|
13
|
-
// That is the duplication the hook election was added to end, still open on the
|
|
14
|
-
// path that is the only Codex capture there is on Windows, where Codex hooks
|
|
15
|
-
// never fire at all.
|
|
16
|
-
//
|
|
17
|
-
// So the server runs the same election, over the same discovery records, with
|
|
18
|
-
// the same tie-break. The rule is repeated here rather than imported from
|
|
19
|
-
// hook/hook.js because that file is copied out of the package and run standalone
|
|
20
|
-
// by the host CLI, with no path back to the module it came from — the same
|
|
21
|
-
// reason it re-derives the Claude config dir inline. The two copies are pinned
|
|
22
|
-
// equal by a test, as challengeProof's pair already is.
|
|
6
|
+
// Which deck writes a given line is decided before any of this, by the
|
|
7
|
+
// election in log-election.mjs, which used to sit at the top of this file.
|
|
23
8
|
import { open, truncate, unlink } from "node:fs/promises";
|
|
24
|
-
|
|
25
|
-
import {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
* as the same file? The platform is a parameter so both answers can be checked
|
|
31
|
-
* from either kind of machine.
|
|
32
|
-
*
|
|
33
|
-
* Windows always does, and macOS does by default (APFS and HFS+ are formatted
|
|
34
|
-
* case-insensitive unless the user deliberately chose otherwise). Linux does
|
|
35
|
-
* not, and folding case there would be a bug of its own: /srv/a/events.jsonl and
|
|
36
|
-
* /srv/A/events.jsonl are two real files, each of which needs a writer.
|
|
37
|
-
*/
|
|
38
|
-
/**
|
|
39
|
-
* The one spelling of an events log, so two decks pointed at one file land in
|
|
40
|
-
* one group (#793).
|
|
41
|
-
*
|
|
42
|
-
* `resolve` alone was what shipped, and it settles relative-vs-absolute and
|
|
43
|
-
* nothing else. The election below then only case-folds — so two spellings of
|
|
44
|
-
* one file read as two files, which is the exact thing `bin/deck.js`'s comment
|
|
45
|
-
* over this value says must not happen. On Windows it needs no odd user action:
|
|
46
|
-
* `claudeConfigDir()` derives from `homedir()`, and a shell whose `USERPROFILE`
|
|
47
|
-
* is 8.3-shortened yields a different default string than one with the long
|
|
48
|
-
* form. `subst` and mapped drives and junctions do it too, and on macOS so does
|
|
49
|
-
* `/tmp` against `/private/tmp`.
|
|
50
|
-
*
|
|
51
|
-
* What it cost was not merely a duplicate group. BOTH decks were then elected,
|
|
52
|
-
* so every hook event was appended twice — the duplicate-tools-after-restart
|
|
53
|
-
* symptom the election exists to end — and `logSharing()` compares the same
|
|
54
|
-
* string, so both answered `mine: true` and `POST /api/clear` truncated a file
|
|
55
|
-
* this deck does not own. That is the #698 history loss the ownership gate was
|
|
56
|
-
* added to prevent.
|
|
57
|
-
*
|
|
58
|
-
* THE DIRECTORY IS CANONICALISED EVEN WHEN THE FILE IS NOT THERE, which is the
|
|
59
|
-
* half a plain realpath misses. On a first run, or against a `--history` naming
|
|
60
|
-
* a file the deck will create, `realpath` throws ENOENT — and falling back to
|
|
61
|
-
* the resolved string would leave the two spellings different for exactly the
|
|
62
|
-
* run that creates the file. The parent exists (or is about to be created under
|
|
63
|
-
* one canonical name), so it is canonicalised and the basename rejoined.
|
|
64
|
-
*
|
|
65
|
-
* `canonicalWorkspace` in index.mjs is the same rule for the other path this
|
|
66
|
-
* deck publishes, with three comments naming 8.3 expansion as its reason. This
|
|
67
|
-
* is that rule reaching the value two lines away from it.
|
|
68
|
-
*/
|
|
69
|
-
export function canonicalLogPath(raw) {
|
|
70
|
-
if (typeof raw !== "string" || raw.trim() === "") return "";
|
|
71
|
-
const abs = resolve(raw);
|
|
72
|
-
try { return realpathSync.native(abs); } catch { /* not created yet */ }
|
|
73
|
-
try { return join(realpathSync.native(dirname(abs)), basename(abs)); } catch { return abs; }
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
export const foldsCase = (platform = process.platform) =>
|
|
77
|
-
platform === "win32" || platform === "darwin";
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Of these decks, which ones write to disk? Returns the subset that should;
|
|
81
|
-
* every other one is expected to draw the event and keep no record of it.
|
|
82
|
-
*
|
|
83
|
-
* Decks are grouped by the log file each one names in its discovery record and
|
|
84
|
-
* one deck per group is elected. Grouping by the file rather than counting decks
|
|
85
|
-
* is what keeps the overrides honest: a deck run with `--history` sits alone in
|
|
86
|
-
* its own group and always writes, a deck run with `--no-persist` reports no
|
|
87
|
-
* file and can never be elected to write for one that does, and a deck too old
|
|
88
|
-
* to report either keeps the behaviour it had before this rule existed. Within a
|
|
89
|
-
* group the lowest port wins — a fixed rule, so the same deck holds the file for
|
|
90
|
-
* as long as it is up and the next one inherits it as soon as that deck is gone.
|
|
91
|
-
*
|
|
92
|
-
* Kept byte-for-byte equivalent to electWriters in hook/hook.js: the two decide
|
|
93
|
-
* for the same decks over the same records, and a disagreement between them
|
|
94
|
-
* means one log line written twice or none at all.
|
|
95
|
-
*/
|
|
96
|
-
export function electWriters(decks, platform = process.platform) {
|
|
97
|
-
const byLog = new Map();
|
|
98
|
-
for (const d of decks) {
|
|
99
|
-
const log = typeof d.persist === "string" ? d.persist : "";
|
|
100
|
-
// Two namespaces, so a deck with no log to share — and a deck too old to
|
|
101
|
-
// report one — is alone in its group and cannot collide with a real path.
|
|
102
|
-
const key = log
|
|
103
|
-
? `log:${foldsCase(platform) ? log.toLowerCase() : log}`
|
|
104
|
-
: `deck:${d.pid}:${d.port}`;
|
|
105
|
-
const held = byLog.get(key);
|
|
106
|
-
// Ports are unique among live decks; pid only breaks a tie a stale
|
|
107
|
-
// discovery file could invent, so the answer stays deterministic.
|
|
108
|
-
if (!held || d.port < held.port || (d.port === held.port && d.pid < held.pid)) {
|
|
109
|
-
byLog.set(key, d);
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
return new Set(byLog.values());
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
/**
|
|
116
|
-
* Would a deck scoped to `workspace` capture a rollout running in `cwd`? An
|
|
117
|
-
* empty workspace is unscoped and captures every session; a rollout that never
|
|
118
|
-
* said where it runs is inside no workspace, so only an unscoped deck draws it.
|
|
119
|
-
*
|
|
120
|
-
* This answers two questions with one function — whether THIS deck tails a
|
|
121
|
-
* rollout, and whether another deck tails it too — and that is only sound while
|
|
122
|
-
* the rule below is the rule every deck actually runs. Model another deck's
|
|
123
|
-
* capture with anything else and the election covers the wrong set: a deck that
|
|
124
|
-
* writes without being elected, or an elected deck that never opened the file.
|
|
125
|
-
*
|
|
126
|
-
* It is also the rule hook/hook.js runs for the sessions it delivers, under the
|
|
127
|
-
* name capturesSession — that script is copied out of the package and run
|
|
128
|
-
* standalone, so the two are written twice and pinned equal by a test walking
|
|
129
|
-
* one table of paths through both. They were not equal: case was folded here on
|
|
130
|
-
* every platform, so on Linux a deck scoped to /srv/proj captured Codex sessions
|
|
131
|
-
* from /srv/Proj and Claude sessions from neither. Those are two real
|
|
132
|
-
* directories there, and the hook's own comment says what folding them together
|
|
133
|
-
* costs — a deck handed the events of a tree it was not scoped to. So the fold
|
|
134
|
-
* is per-platform on both sides now, and `--workspace` means one thing.
|
|
135
|
-
*
|
|
136
|
-
* (The narrow window that opens: two decks on Linux whose workspaces differ only
|
|
137
|
-
* in case, one of them old enough to still fold, both containing one rollout's
|
|
138
|
-
* cwd. Each models the other as tailing the file; one of them is wrong, and the
|
|
139
|
-
* cost is a single log line written twice.)
|
|
140
|
-
*
|
|
141
|
-
* The platform is a parameter, following the hook's cwdInWorkspace and
|
|
142
|
-
* spawnSpec in src/server/exec.mjs, so the Windows separator is testable from a
|
|
143
|
-
* POSIX machine.
|
|
144
|
-
*/
|
|
145
|
-
export function codexCwdInWorkspace(cwd, workspace, platform = process.platform) {
|
|
146
|
-
if (!workspace || typeof workspace !== "string") return true;
|
|
147
|
-
if (!cwd || typeof cwd !== "string") return false;
|
|
148
|
-
const p = platform === "win32" ? win32 : posix;
|
|
149
|
-
const fold = s => (foldsCase(platform) ? s.toLowerCase() : s);
|
|
150
|
-
const a = fold(p.resolve(cwd));
|
|
151
|
-
const b = fold(p.resolve(workspace));
|
|
152
|
-
if (a === b) return true;
|
|
153
|
-
// A root ("C:\", "/") already ends in the separator; appending a second one
|
|
154
|
-
// would match nothing.
|
|
155
|
-
return a.startsWith(b.endsWith(p.sep) ? b : b + p.sep);
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/**
|
|
159
|
-
* Do two discovery records name the same Codex tree? Each argument is a
|
|
160
|
-
* record's `codexHome`: the canonical path of the CODEX_HOME that deck tails.
|
|
161
|
-
*
|
|
162
|
-
* Anything that is not a non-empty string answers yes. That is a record written
|
|
163
|
-
* before the field existed — a machine halfway through an upgrade is the
|
|
164
|
-
* ordinary way to meet one — and guessing "a different tree" there would elect
|
|
165
|
-
* a second writer for one log on every machine with an older deck still up.
|
|
166
|
-
* Guessing "the same tree" is what every deck did before the field existed,
|
|
167
|
-
* which is the fail-safe writesCodexLog below already takes for a record too old
|
|
168
|
-
* to carry `codex` at all.
|
|
169
|
-
*
|
|
170
|
-
* Case is folded where the filesystem folds it, exactly as electWriters folds
|
|
171
|
-
* the log path: on Windows and macOS two spellings that differ only in case are
|
|
172
|
-
* one directory with one reader, and on Linux they are two directories, each
|
|
173
|
-
* read by its own deck. The other ways to spell one directory — a symlinked
|
|
174
|
-
* ~/.codex, an 8.3-shortened USERPROFILE, /tmp against /private/tmp — are
|
|
175
|
-
* settled before the value is published (writeDiscovery in installer.mjs runs
|
|
176
|
-
* it through canonicalLogPath), so two records compared here already agree on
|
|
177
|
-
* everything but case.
|
|
178
|
-
*/
|
|
179
|
-
export function sameCodexTree(a, b, platform = process.platform) {
|
|
180
|
-
if (typeof a !== "string" || a === "" || typeof b !== "string" || b === "") return true;
|
|
181
|
-
return foldsCase(platform) ? a.toLowerCase() === b.toLowerCase() : a === b;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
/**
|
|
185
|
-
* Does this deck append a rollout's events to its log, or is another deck doing
|
|
186
|
-
* it? `decks` is every deck registered right now, `pid` identifies this one
|
|
187
|
-
* among them, and `cwd` is the workspace the rollout is running in.
|
|
188
|
-
*
|
|
189
|
-
* The group is every deck that tails this same rollout: each deck decides for
|
|
190
|
-
* itself, so all of them whose workspace contains the cwd read the file and all
|
|
191
|
-
* of them would write it. The hook builds the same group the same way for the
|
|
192
|
-
* events it delivers — it used to narrow them to the longest workspace match
|
|
193
|
-
* first, which is the asymmetry the predicate above describes the end of. A deck
|
|
194
|
-
* started with `--no-codex` tails nothing and is left out; electing it would
|
|
195
|
-
* mean the rollout's events reach no log at all. A deck too old to say either
|
|
196
|
-
* way is assumed to be tailing, which is what it was doing before this field
|
|
197
|
-
* existed.
|
|
198
|
-
*
|
|
199
|
-
* A deck reading a different Codex tree is left out for the same reason (#982).
|
|
200
|
-
* CODEX_HOME moves the whole tree, so two decks on one machine can share one
|
|
201
|
-
* events.jsonl while reading two different sets of rollouts — and for an
|
|
202
|
-
* unscoped deck the workspace test above answers yes to every cwd, so it tells
|
|
203
|
-
* them apart not at all. The record's `codex: true` said a deck tails rollouts
|
|
204
|
-
* and never said whose. So the lower-port deck won the line for a rollout its
|
|
205
|
-
* own tree does not hold and appended nothing, the deck that was reading it
|
|
206
|
-
* stood down, and the shared log recorded none of the session while the canvas
|
|
207
|
-
* drew all of it: #695's symptom, from a deck that is alive, answers its
|
|
208
|
-
* challenge, and is simply looking somewhere else. The same deck launched with
|
|
209
|
-
* a stale CODEX_HOME is the duller version of it.
|
|
210
|
-
*/
|
|
211
|
-
export function writesCodexLog({ decks, pid, cwd, platform = process.platform }) {
|
|
212
|
-
const live = Array.isArray(decks) ? decks : [];
|
|
213
|
-
const self = live.find(d => d && d.pid === pid) ?? null;
|
|
214
|
-
// No record of our own on disk — the window before the first heartbeat writes
|
|
215
|
-
// it, or a deck that cannot write one at all. Nobody can elect us and we
|
|
216
|
-
// cannot see who else is here, so keep what this deck did before the election
|
|
217
|
-
// existed and write. A line written twice is recoverable; a deck that quietly
|
|
218
|
-
// stops recording anything is not.
|
|
219
|
-
if (!self || typeof self.persist !== "string" || self.persist === "") return true;
|
|
220
|
-
|
|
221
|
-
const group = [self];
|
|
222
|
-
for (const d of live) {
|
|
223
|
-
if (!d || d.pid === self.pid) continue;
|
|
224
|
-
if (d.codex === false) continue;
|
|
225
|
-
if (!sameCodexTree(self.codexHome, d.codexHome, platform)) continue;
|
|
226
|
-
if (!codexCwdInWorkspace(cwd, d.workspace ?? "", platform)) continue;
|
|
227
|
-
group.push(d);
|
|
228
|
-
}
|
|
229
|
-
return electWriters(group, platform).has(self);
|
|
230
|
-
}
|
|
9
|
+
// How much the queue may hold, and what it refused: see append-budget.mjs.
|
|
10
|
+
import {
|
|
11
|
+
MAX_PENDING_APPEND_CHARS, appendQueueStats, noteRefused, queueIsFull, releaseCharge, takeCharge,
|
|
12
|
+
} from "./append-budget.mjs";
|
|
13
|
+
// What failed to land, and how much has: see append-failures.mjs.
|
|
14
|
+
import { appendFailureStats, appendsLanded, noteAppendFailed, noteAppendLanded } from "./append-failures.mjs";
|
|
231
15
|
|
|
232
16
|
// ─── Appending one whole line ─────────────────────────────────────────────
|
|
233
17
|
//
|
|
234
|
-
// The election
|
|
235
|
-
// the two are answers to the same question: the log is one
|
|
236
|
-
// processes append to, so a line that arrives in pieces is a
|
|
237
|
-
// writer can land inside.
|
|
18
|
+
// The election in log-election.mjs decides WHICH deck writes a line. This
|
|
19
|
+
// decides HOW, and the two are answers to the same question: the log is one
|
|
20
|
+
// file that several processes append to, so a line that arrives in pieces is a
|
|
21
|
+
// line another writer can land inside.
|
|
238
22
|
//
|
|
239
23
|
// What was wrong. events.jsonl was appended with `fsPromises.appendFile`, which
|
|
240
24
|
// is not one write(2). Node's writeFileHandle loops over the payload in chunks
|
|
@@ -300,256 +84,34 @@ export function writesCodexLog({ decks, pid, cwd, platform = process.platform })
|
|
|
300
84
|
// already paid — open, write, close — minus the extra writes.
|
|
301
85
|
const appendTails = new Map();
|
|
302
86
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
// on how many of those exist at once is how fast write(2) returns — while
|
|
308
|
-
// pushEvent hands them over fire-and-forget and answers `{ok:true, seq}` on the
|
|
309
|
-
// next statement, so `POST /api/event` admits lines as fast as a socket can
|
|
310
|
-
// deliver them.
|
|
311
|
-
//
|
|
312
|
-
// MEASURED (Linux 7.0 / Node 24.21 / ext4 on NVMe), eight sockets posting 1 MB
|
|
313
|
-
// `tool_response` bodies to a sandboxed deck for four seconds, sampled twice a
|
|
314
|
-
// second:
|
|
315
|
-
//
|
|
316
|
-
// t=1.0s log_MB=19 rss_MB=881
|
|
317
|
-
// t=2.5s log_MB=51 rss_MB=1741
|
|
318
|
-
// t=4.1s log_MB=85 rss_MB=2260 <- ingest stops; 1540 MB acknowledged
|
|
319
|
-
// t=5.5s log_MB=1135 rss_MB=935
|
|
320
|
-
// t=6.0s log_MB=1540 rss_MB=546 <- queue finally drained
|
|
321
|
-
//
|
|
322
|
-
// 85 MB on disk against 1540 MB the deck had said it had: 1455 MB of serialized
|
|
323
|
-
// lines held in the heap, and an RSS peak of 2260 MB — 17x MAX_BUFFER_CHARS,
|
|
324
|
-
// the budget the ring beside this keeps exactly. The disk is not the amplifier;
|
|
325
|
-
// the event loop is. Ingest and the writer share it, so the log grew at 21 MB/s
|
|
326
|
-
// while the posting continued and at 800 MB/s the instant it stopped.
|
|
327
|
-
//
|
|
328
|
-
// The same burst under `--max-old-space-size=1024`:
|
|
329
|
-
//
|
|
330
|
-
// FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory
|
|
331
|
-
// [deck exited code=null sig=SIGABRT] 3.2s in, 50 MB written
|
|
332
|
-
//
|
|
333
|
-
// That abort is not catchable, so the SSE stream, the hook ingest and the log
|
|
334
|
-
// stop together — and `/api/event` is a deliberate OPEN_MUTATION, so this is a
|
|
335
|
-
// few hundred unauthenticated posts from any local process ending the deck.
|
|
336
|
-
// It is the failure MAX_BUFFER_CHARS was added to close (#625), reached through
|
|
337
|
-
// the door beside the one it guards (#1030).
|
|
338
|
-
//
|
|
339
|
-
// Who gets there without trying: `--history` accepts any path. The 40 events/s
|
|
340
|
-
// of 1 MB that is trivial on NVMe is roughly 30 MB/s of pure accumulation on a
|
|
341
|
-
// 5-10 MB/s network mount or a slow external drive, where the queue's only
|
|
342
|
-
// brake is that much slower than the traffic filling it.
|
|
343
|
-
|
|
344
|
-
/**
|
|
345
|
-
* The ceiling on pending append bytes, and the ring's sibling on purpose.
|
|
346
|
-
*
|
|
347
|
-
* 128 MiB, the same number and the same three readings that pick
|
|
348
|
-
* MAX_BUFFER_CHARS in index.mjs:
|
|
349
|
-
*
|
|
350
|
-
* - It is 26 times the largest single line ingest can produce (5,000,000
|
|
351
|
-
* characters plus the deck's envelope), so a burst of maximum-size tool
|
|
352
|
-
* responses — eight subagents each returning a big Read — is queued whole
|
|
353
|
-
* rather than shed.
|
|
354
|
-
* - It is thirteen times a completely full 2000-event ring of ordinary
|
|
355
|
-
* traffic. The mean serialized event is about 5 KB, so the ~26,000 lines
|
|
356
|
-
* this holds are far more than any honest burst produces: ordinary traffic
|
|
357
|
-
* never meets this bound at all.
|
|
358
|
-
* - Its worst case is a bounded fraction of the heap, and a bounded ADDITION
|
|
359
|
-
* to the ring's. 128 MiB of charged characters is at most 256 MiB retained
|
|
360
|
-
* — a two-byte string costs twice what it is charged, the same 2x
|
|
361
|
-
* MAX_CLIENT_BUFFER_BYTES' note describes — so the ring's 320 MiB and this
|
|
362
|
-
* together are about 576 MiB of the 2 GB heap an 8 GB laptop picks, against
|
|
363
|
-
* the 2260 MB one four-second burst reached with no bound here at all.
|
|
364
|
-
*
|
|
365
|
-
* CHARGED IN CHARACTERS, not in UTF-8 bytes, and the unit is deliberate. These
|
|
366
|
-
* lines are already serialized, so `line.length` is the size of the thing being
|
|
367
|
-
* retained and costs nothing to read; `Buffer.byteLength` would be a second
|
|
368
|
-
* full scan of a five-megabyte string on the hottest path in the process for a
|
|
369
|
-
* number that is FURTHER from the retained heap, not closer — JSON.stringify
|
|
370
|
-
* leaves non-ASCII unescaped, so a CJK line is two bytes of heap per character
|
|
371
|
-
* and three bytes of UTF-8.
|
|
372
|
-
*
|
|
373
|
-
* Deliberately NOT summed into MAX_BUFFER_CHARS, which #1030 raises as an
|
|
374
|
-
* option. The two hold different things — the ring holds parsed payloads
|
|
375
|
-
* charged by payloadChars' walk, this holds their serialization — so one
|
|
376
|
-
* counter would be adding two units; and eviction cannot free a queued line
|
|
377
|
-
* anyway, so a shared budget would answer a slow disk by collapsing the replay
|
|
378
|
-
* depth of the ring, which is the one part of this that was never broken.
|
|
379
|
-
*/
|
|
380
|
-
export const MAX_PENDING_APPEND_CHARS = 128 * 1024 * 1024;
|
|
381
|
-
|
|
382
|
-
// What has been accepted and not yet written, what has been refused because of
|
|
383
|
-
// it, and whether we are inside an episode of refusing. These move with the
|
|
384
|
-
// chain in appendLogLine and nowhere else — the same rule `bufferedChars` and
|
|
385
|
-
// `events` keep in index.mjs, and for the same reason: a total that names lines
|
|
386
|
-
// the queue no longer holds is a permanent debt against the budget.
|
|
387
|
-
let pendingLines = 0;
|
|
388
|
-
let pendingChars = 0;
|
|
389
|
-
let droppedLines = 0;
|
|
390
|
-
let droppedChars = 0;
|
|
391
|
-
let dropEpisodes = 0;
|
|
392
|
-
// Reset at the start of each episode, so the line printed when the queue
|
|
393
|
-
// drains describes THAT burst rather than the life of the process.
|
|
394
|
-
let episodeLines = 0;
|
|
395
|
-
let episodeChars = 0;
|
|
396
|
-
let shedding = false;
|
|
397
|
-
|
|
398
|
-
/**
|
|
399
|
-
* What the append queue holds right now, and what it has refused.
|
|
400
|
-
*
|
|
401
|
-
* Exported for exactly the reason eventBufferStats and MAX_BUFFER_CHARS are —
|
|
402
|
-
* "a bound whose only observable failure is the process running out of memory
|
|
403
|
-
* is a bound no test can assert". With this, the bound is observable without
|
|
404
|
-
* watching a process die, and so is the loss it trades for.
|
|
405
|
-
*
|
|
406
|
-
* `droppedLines` / `droppedChars` are cumulative for the life of the process.
|
|
407
|
-
* They count lines that were never ATTEMPTED; an append that was attempted and
|
|
408
|
-
* failed is a different number, which #991 is open about and which belongs
|
|
409
|
-
* beside these rather than tangled into them.
|
|
410
|
-
*/
|
|
411
|
-
export function appendQueueStats() {
|
|
412
|
-
return { pendingLines, pendingChars, droppedLines, droppedChars, dropEpisodes };
|
|
87
|
+
/** The step the next one queued for this log starts behind: the last one
|
|
88
|
+
* queued, or nothing when its queue is empty. */
|
|
89
|
+
function tailOf(filePath) {
|
|
90
|
+
return appendTails.get(filePath) ?? Promise.resolve();
|
|
413
91
|
}
|
|
414
92
|
|
|
415
|
-
const mb = chars => `${(chars / 1024 / 1024).toFixed(0)}MB`;
|
|
416
|
-
// ─── What the queue failed to write ───────────────────────────────────────
|
|
417
|
-
//
|
|
418
|
-
// `.catch(() => {})` was the only handler anywhere on this path, and a failed
|
|
419
|
-
// `open(filePath, "a")` was indistinguishable from a successful append from
|
|
420
|
-
// every vantage point in the process. MEASURED, on a sandboxed deck whose
|
|
421
|
-
// `--history` names a file inside a directory the user cannot write:
|
|
422
|
-
//
|
|
423
|
-
// POST /api/event acknowledged: 10 of 10
|
|
424
|
-
// log file on disk? false
|
|
425
|
-
// /api/health mentions the log? false
|
|
426
|
-
// /api/version canRestart: true
|
|
427
|
-
//
|
|
428
|
-
// Ten events the deck told the hook it had recorded, nothing on disk, nothing
|
|
429
|
-
// said on any console, and the Restart button still offering itself. That last
|
|
430
|
-
// line is the one that costs: the button exists precisely BECAUSE a restart
|
|
431
|
-
// without a log wipes the canvas irrecoverably, and it was gating on whether a
|
|
432
|
-
// log had been CONFIGURED rather than on whether one was being written. The
|
|
433
|
-
// press then lands on `replayLog`'s `if (!existsSync(filePath)) return 0` and
|
|
434
|
-
// the whole session history is gone.
|
|
435
|
-
//
|
|
436
|
-
// Who gets there without trying: `--history` accepts any path — a read-only or
|
|
437
|
-
// full volume, a removable drive unmounted while the deck runs, a directory
|
|
438
|
-
// whose permissions changed under it. `startServer` even creates the parent
|
|
439
|
-
// inside a bare `try {} catch {}`, so the failure is already swallowed once
|
|
440
|
-
// before the first line is ever written.
|
|
441
|
-
//
|
|
442
|
-
// So the chain counts what it could not write, and says so once. The numbers
|
|
443
|
-
// are what make the loss observable without watching a canvas come back empty,
|
|
444
|
-
// for the same reason `eventBufferStats` exists one file over.
|
|
445
|
-
let failedLines = 0;
|
|
446
|
-
let failedChars = 0;
|
|
447
|
-
/**
|
|
448
|
-
* The paths currently inside an EPISODE of failing, and how many lines each
|
|
449
|
-
* episode has lost so far: path -> { lines }.
|
|
450
|
-
*
|
|
451
|
-
* An episode rather than a one-shot flag, because the interesting failures are
|
|
452
|
-
* transient — a volume that fills and is emptied, a drive that is unplugged and
|
|
453
|
-
* returns — and a path complained about once and never again would report the
|
|
454
|
-
* first outage of a long-lived deck and stay silent through every later one.
|
|
455
|
-
* The episode closes on the next append to that path that LANDS, which is the
|
|
456
|
-
* only evidence available that the condition is over.
|
|
457
|
-
*/
|
|
458
|
-
const failingPaths = new Map();
|
|
459
|
-
|
|
460
93
|
/**
|
|
461
|
-
*
|
|
462
|
-
*
|
|
463
|
-
*
|
|
464
|
-
*
|
|
465
|
-
*
|
|
466
|
-
*
|
|
467
|
-
*
|
|
468
|
-
*
|
|
469
|
-
* compared with itself. index.mjs reads it once, when its boot probe answers,
|
|
470
|
-
* and again whenever it asks; any difference is a line that reached the disk
|
|
471
|
-
* after the probe spoke.
|
|
472
|
-
*
|
|
473
|
-
* One number per path this process has ever appended to, which on a deck is
|
|
474
|
-
* one. Never deleted, unlike the entries in appendTails, because a count that
|
|
475
|
-
* went back to zero would read as "nothing has landed" to whoever took a
|
|
476
|
-
* reading before it did.
|
|
94
|
+
* Make `tail` the step the next one queued for this log starts behind, and
|
|
95
|
+
* drop it from the map once it settles.
|
|
96
|
+
*
|
|
97
|
+
* Dropped so a process that writes to several logs over its life does not hold
|
|
98
|
+
* a promise per path it has finished with. Only the tail installed here is
|
|
99
|
+
* cleared: if another step chained on in the meantime the map already points
|
|
100
|
+
* at that one, and deleting it would let the next line race the one still in
|
|
101
|
+
* flight.
|
|
477
102
|
*/
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
* What the appender has failed to write, and whether it is failing now.
|
|
482
|
-
*
|
|
483
|
-
* Exported for the reason `eventBufferStats` and `MAX_BUFFER_CHARS` are: a loss
|
|
484
|
-
* whose only observable effect is a canvas that comes back empty after a
|
|
485
|
-
* restart is a loss no test can assert. `failing` is the number of paths inside
|
|
486
|
-
* an open failure episode — this process appends to exactly one log, so on a
|
|
487
|
-
* deck it is 0 or 1, and 1 means the log being drawn is not the log being kept.
|
|
488
|
-
*
|
|
489
|
-
* `failedLines` / `failedChars` are cumulative for the life of the process and
|
|
490
|
-
* count lines that were ATTEMPTED and did not land. A line the queue refused
|
|
491
|
-
* to accept in the first place is a different number and belongs beside this
|
|
492
|
-
* one rather than tangled into it.
|
|
493
|
-
*
|
|
494
|
-
* `filePath` NARROWS `failing` to one log, and a caller deciding what to do
|
|
495
|
-
* about its own log has to pass it. A process can outlive the log it was
|
|
496
|
-
* started with — `startServer` may be called again with a different `--history`
|
|
497
|
-
* on a restart, and the suite does exactly that — so an unscoped count would
|
|
498
|
-
* let a path that failed and was abandoned veto a deck whose current log is
|
|
499
|
-
* perfectly writable.
|
|
500
|
-
*
|
|
501
|
-
* @param {string|null} [filePath] the log to ask about; omit for every path
|
|
502
|
-
*/
|
|
503
|
-
export function appendFailureStats(filePath = null) {
|
|
504
|
-
const failing = filePath == null ? failingPaths.size : (failingPaths.has(filePath) ? 1 : 0);
|
|
505
|
-
return { failedLines, failedChars, failing };
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
/**
|
|
509
|
-
* How many lines have landed on one log in this process. See landedLines.
|
|
510
|
-
*
|
|
511
|
-
* A separate export rather than a fourth field of appendFailureStats, because
|
|
512
|
-
* that object is spread whole into `/api/health`, an open route that keeps to
|
|
513
|
-
* the smallest set of facts answering its question, and a running count of
|
|
514
|
-
* successful writes is not one of them.
|
|
515
|
-
*
|
|
516
|
-
* @param {string} filePath the log to ask about
|
|
517
|
-
* @returns {number}
|
|
518
|
-
*/
|
|
519
|
-
export function appendsLanded(filePath) {
|
|
520
|
-
return landedLines.get(filePath) ?? 0;
|
|
103
|
+
function installTail(filePath, tail) {
|
|
104
|
+
appendTails.set(filePath, tail);
|
|
105
|
+
tail.then(() => { if (appendTails.get(filePath) === tail) appendTails.delete(filePath); });
|
|
521
106
|
}
|
|
522
107
|
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
* hole it left is worth one line.
|
|
527
|
-
*/
|
|
528
|
-
function noteAppendLanded(filePath) {
|
|
529
|
-
landedLines.set(filePath, (landedLines.get(filePath) ?? 0) + 1);
|
|
530
|
-
const episode = failingPaths.get(filePath);
|
|
531
|
-
if (!episode) return;
|
|
532
|
-
failingPaths.delete(filePath);
|
|
533
|
-
console.error(`${PRODUCT}: writing ${filePath} works again — ${episode.lines} event(s) were dropped while it did not, and are not in the log`);
|
|
534
|
-
}
|
|
108
|
+
// What the queue is allowed to weigh (#1030) — the ceiling, the charge a line
|
|
109
|
+
// carries until its write settles, and the refusals past it: append-budget.mjs.
|
|
110
|
+
export { MAX_PENDING_APPEND_CHARS, appendQueueStats };
|
|
535
111
|
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
* ONE LINE PER EPISODE, not one per failure. A read-only log fails on every
|
|
540
|
-
* event the deck draws, so per-failure this would be thousands of lines onto
|
|
541
|
-
* the terminal the deck paints its own banner over — which is its own version
|
|
542
|
-
* of the problem being fixed.
|
|
543
|
-
*/
|
|
544
|
-
function noteAppendFailed(filePath, line, err) {
|
|
545
|
-
failedLines++;
|
|
546
|
-
failedChars += typeof line === "string" ? line.length : 0;
|
|
547
|
-
const episode = failingPaths.get(filePath);
|
|
548
|
-
if (episode) { episode.lines++; return; }
|
|
549
|
-
failingPaths.set(filePath, { lines: 1 });
|
|
550
|
-
const why = err && err.message ? err.message : String(err);
|
|
551
|
-
console.error(`${PRODUCT}: cannot write the event log ${filePath} (${why}) — events are being drawn but not recorded, and a restart will not bring them back`);
|
|
552
|
-
}
|
|
112
|
+
// What the queue failed to write (#991), and how many lines have landed on
|
|
113
|
+
// each log (#1130): append-failures.mjs.
|
|
114
|
+
export { appendFailureStats, appendsLanded };
|
|
553
115
|
|
|
554
116
|
/**
|
|
555
117
|
* Append one already-serialized line to the shared log, whole.
|
|
@@ -566,8 +128,8 @@ function noteAppendFailed(filePath, line, err) {
|
|
|
566
128
|
* about anyway. The chain deliberately continues past a failure — one ENOSPC
|
|
567
129
|
* must not stop every later event from being recorded once space is back.
|
|
568
130
|
*
|
|
569
|
-
* BOUNDED, by MAX_PENDING_APPEND_CHARS
|
|
570
|
-
* the door, counted, and reported once per episode.
|
|
131
|
+
* BOUNDED, by MAX_PENDING_APPEND_CHARS in append-budget.mjs. Past it the line
|
|
132
|
+
* is refused at the door, counted, and reported once per episode.
|
|
571
133
|
*
|
|
572
134
|
* Refused at the door, and not evicted from the middle of the queue, which is
|
|
573
135
|
* the other shape #1030 offers. A line that has been accepted has been charged,
|
|
@@ -588,34 +150,14 @@ function noteAppendFailed(filePath, line, err) {
|
|
|
588
150
|
*/
|
|
589
151
|
export function appendLogLine(filePath, line) {
|
|
590
152
|
const charged = typeof line === "string" ? line.length : 0;
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
// off disk has no length bound at all, so refusing an oversized line outright
|
|
594
|
-
// would mean a deck that silently never records its largest events — and the
|
|
595
|
-
// ring one file over makes the same exception for the same reason ("a single
|
|
596
|
-
// event is allowed to be larger than the entire budget"). So the true ceiling
|
|
597
|
-
// is MAX_PENDING_APPEND_CHARS plus one line, stated here rather than
|
|
598
|
-
// pretended away.
|
|
599
|
-
if (pendingLines > 0 && pendingChars + charged > MAX_PENDING_APPEND_CHARS) {
|
|
600
|
-
droppedLines++;
|
|
601
|
-
droppedChars += charged;
|
|
602
|
-
episodeLines++;
|
|
603
|
-
episodeChars += charged;
|
|
604
|
-
if (!shedding) {
|
|
605
|
-
shedding = true;
|
|
606
|
-
dropEpisodes++;
|
|
607
|
-
// One line, at the start of the episode. Per refusal it would be
|
|
608
|
-
// thousands of lines onto the terminal the deck paints over, which is its
|
|
609
|
-
// own version of the problem being fixed.
|
|
610
|
-
console.error(`${PRODUCT}: the log append queue is full (${mb(pendingChars)} waiting for ${filePath}) — dropping events until it drains`);
|
|
611
|
-
}
|
|
153
|
+
if (queueIsFull(charged)) {
|
|
154
|
+
noteRefused(filePath, charged);
|
|
612
155
|
// Resolved rather than rejected, and resolved rather than the tail: the
|
|
613
156
|
// contract every caller has is "never rejects, never makes you wait".
|
|
614
157
|
return Promise.resolve();
|
|
615
158
|
}
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
const tail = (appendTails.get(filePath) ?? Promise.resolve())
|
|
159
|
+
takeCharge(charged);
|
|
160
|
+
const tail = tailOf(filePath)
|
|
619
161
|
.then(() => writeWholeLine(filePath, line))
|
|
620
162
|
// Counted, and reported once per episode — see noteAppendFailed. The order
|
|
621
163
|
// of these four steps is the whole of the union between the append-queue
|
|
@@ -624,9 +166,10 @@ export function appendLogLine(filePath, line) {
|
|
|
624
166
|
//
|
|
625
167
|
// * the failure handler comes BEFORE the `catch`, or the rejection has
|
|
626
168
|
// already been swallowed by the time anything could count it;
|
|
627
|
-
// * the `catch` stays, because the map cleanup
|
|
628
|
-
// `.then` on this tail and "never rejects" is the contract every
|
|
629
|
-
// of this function has — neither handler above may be what
|
|
169
|
+
// * the `catch` stays, because the map cleanup in installTail chains a
|
|
170
|
+
// bare `.then` on this tail and "never rejects" is the contract every
|
|
171
|
+
// caller of this function has — neither handler above may be what
|
|
172
|
+
// breaks it;
|
|
630
173
|
// * `finally` comes LAST, so the charge is given back whether the write
|
|
631
174
|
// landed, failed, or a handler here threw on its way past. It would run
|
|
632
175
|
// on a rejection anyway; what the ordering buys is that it cannot be
|
|
@@ -637,16 +180,25 @@ export function appendLogLine(filePath, line) {
|
|
|
637
180
|
// which is the moment the closure holding it becomes collectable, and so
|
|
638
181
|
// the moment the heap it was standing for is actually back.
|
|
639
182
|
.finally(() => releaseCharge(charged));
|
|
640
|
-
|
|
641
|
-
// Drop the chain once it drains, so a process that writes to several logs
|
|
642
|
-
// over its life does not hold a promise per path it has finished with. Only
|
|
643
|
-
// the tail we just installed is cleared: if another append chained on in the
|
|
644
|
-
// meantime the map already points at that one, and deleting it would let the
|
|
645
|
-
// next line race the one still in flight.
|
|
646
|
-
tail.then(() => { if (appendTails.get(filePath) === tail) appendTails.delete(filePath); });
|
|
183
|
+
installTail(filePath, tail);
|
|
647
184
|
return tail;
|
|
648
185
|
}
|
|
649
186
|
|
|
187
|
+
/**
|
|
188
|
+
* `drained` — a promise that answers true once what it waits for has landed —
|
|
189
|
+
* or false at `ms`, whichever comes first: the bound drainAppends and
|
|
190
|
+
* flushAppends below both keep. The bell is unref'd, so a wait still running
|
|
191
|
+
* never holds the process open by itself, and it is cleared either way.
|
|
192
|
+
*/
|
|
193
|
+
function withinDeadline(drained, ms) {
|
|
194
|
+
let bell;
|
|
195
|
+
const deadline = new Promise(resolve => {
|
|
196
|
+
bell = setTimeout(() => resolve(false), ms);
|
|
197
|
+
bell.unref?.();
|
|
198
|
+
});
|
|
199
|
+
return Promise.race([drained, deadline]).finally(() => clearTimeout(bell));
|
|
200
|
+
}
|
|
201
|
+
|
|
650
202
|
/**
|
|
651
203
|
* Wait for every queued append to land, bounded.
|
|
652
204
|
*
|
|
@@ -676,17 +228,12 @@ export function appendLogLine(filePath, line) {
|
|
|
676
228
|
export function drainAppends(ms = 3000) {
|
|
677
229
|
const pending = [...appendTails.values()];
|
|
678
230
|
if (!pending.length) return Promise.resolve(true);
|
|
679
|
-
let bell;
|
|
680
|
-
const deadline = new Promise(resolve => {
|
|
681
|
-
bell = setTimeout(() => resolve(false), ms);
|
|
682
|
-
bell.unref?.();
|
|
683
|
-
});
|
|
684
231
|
// `catch` on each: a failed append has already been swallowed by the chain,
|
|
685
232
|
// and a rejection here would skip the rest of the drain.
|
|
686
|
-
return
|
|
233
|
+
return withinDeadline(
|
|
687
234
|
Promise.all(pending.map(p => Promise.resolve(p).catch(() => {}))).then(() => true),
|
|
688
|
-
|
|
689
|
-
|
|
235
|
+
ms,
|
|
236
|
+
);
|
|
690
237
|
}
|
|
691
238
|
|
|
692
239
|
/**
|
|
@@ -740,18 +287,10 @@ export function drainAppends(ms = 3000) {
|
|
|
740
287
|
export function flushAppends(filePath, ms = 3000) {
|
|
741
288
|
const tail = appendTails.get(filePath);
|
|
742
289
|
if (!tail) return Promise.resolve(true);
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
});
|
|
748
|
-
return Promise.race([
|
|
749
|
-
// The tail never rejects — appendLogLine's own catch sees to that — but a
|
|
750
|
-
// rejection here would skip the clearTimeout and leave the caller hanging,
|
|
751
|
-
// so it is handled rather than assumed away.
|
|
752
|
-
Promise.resolve(tail).then(() => true, () => true),
|
|
753
|
-
deadline,
|
|
754
|
-
]).finally(() => clearTimeout(bell));
|
|
290
|
+
// The tail never rejects — appendLogLine's own catch sees to that — but a
|
|
291
|
+
// rejection here would reach a caller that was promised a boolean, so it is
|
|
292
|
+
// handled rather than assumed away.
|
|
293
|
+
return withinDeadline(Promise.resolve(tail).then(() => true, () => true), ms);
|
|
755
294
|
}
|
|
756
295
|
|
|
757
296
|
/**
|
|
@@ -822,7 +361,7 @@ export function flushAppends(filePath, ms = 3000) {
|
|
|
822
361
|
* @returns {Promise<boolean>} whether it was over before the deadline
|
|
823
362
|
*/
|
|
824
363
|
export function emptyLog(filePath, archives = [], ms = 3000, outcome = {}) {
|
|
825
|
-
const turn = (
|
|
364
|
+
const turn = tailOf(filePath)
|
|
826
365
|
.then(() => truncate(filePath, 0))
|
|
827
366
|
.then(
|
|
828
367
|
() => { outcome.error = null; },
|
|
@@ -837,34 +376,10 @@ export function emptyLog(filePath, archives = [], ms = 3000, outcome = {}) {
|
|
|
837
376
|
outcome.archiveError = first;
|
|
838
377
|
})
|
|
839
378
|
.catch(() => {});
|
|
840
|
-
|
|
841
|
-
// The same cleanup appendLogLine does, for the same reason: only the tail
|
|
842
|
-
// installed here, never one that has chained on since.
|
|
843
|
-
turn.then(() => { if (appendTails.get(filePath) === turn) appendTails.delete(filePath); });
|
|
379
|
+
installTail(filePath, turn);
|
|
844
380
|
return flushAppends(filePath, ms);
|
|
845
381
|
}
|
|
846
382
|
|
|
847
|
-
/**
|
|
848
|
-
* One line has left the queue. Give its charge back, and close the episode if
|
|
849
|
-
* that was the last of them.
|
|
850
|
-
*
|
|
851
|
-
* The episode ends when the queue is EMPTY rather than the moment it dips back
|
|
852
|
-
* under the bound, because it dips under the bound once per completed write:
|
|
853
|
-
* keyed on the bound this would print a pair of lines per event for the length
|
|
854
|
-
* of the burst. Empty is also the honest boundary for the number being
|
|
855
|
-
* reported — while anything is still queued the next line can still be refused,
|
|
856
|
-
* and the total would have to be retracted.
|
|
857
|
-
*/
|
|
858
|
-
function releaseCharge(charged) {
|
|
859
|
-
pendingLines--;
|
|
860
|
-
pendingChars -= charged;
|
|
861
|
-
if (pendingLines > 0 || !shedding) return;
|
|
862
|
-
console.error(`${PRODUCT}: the log append queue drained — ${episodeLines} event(s) (${mb(episodeChars)}) were dropped and are not in the log`);
|
|
863
|
-
shedding = false;
|
|
864
|
-
episodeLines = 0;
|
|
865
|
-
episodeChars = 0;
|
|
866
|
-
}
|
|
867
|
-
|
|
868
383
|
/**
|
|
869
384
|
* One line, one write(2). The loop exists only for the short-write case
|
|
870
385
|
* described above; on every filesystem that does not do that it runs once.
|