@awebai/oats 0.31.0 → 0.33.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/oats.mjs +162 -42
- package/docs/capabilities.md +5 -0
- package/docs/configuration.md +65 -8
- package/docs/design/2026-09-23-workspace-module-contracts.md +3 -2
- package/docs/desktop-cli-api.md +167 -18
- package/docs/desktop.md +16 -0
- package/docs/first-team.md +20 -2
- package/docs/implementation.md +121 -1
- package/docs/integrations.md +1 -1
- package/docs/oats-local.schema.json +2 -1
- package/docs/official-catalog.md +2 -2
- package/docs/packages.md +2 -2
- package/docs/plans/0.30-close-out.md +7 -4
- package/docs/release-notes/v0.32.0.md +137 -0
- package/docs/release-notes/v0.33.0.md +174 -0
- package/docs/souls-and-instances.md +64 -0
- package/docs/workspaces.md +1 -1
- package/lib/automations.mjs +5 -1
- package/lib/core.mjs +164 -46
- package/lib/harness-trust.mjs +139 -0
- package/lib/instance-inspect.mjs +18 -3
- package/lib/instance-resolution.mjs +3 -3
- package/lib/local-inputs.mjs +46 -0
- package/lib/materialize.mjs +3 -2
- package/lib/packages.mjs +26 -6
- package/lib/process-group.mjs +54 -0
- package/lib/remote.mjs +1363 -115
- package/lib/resolve.mjs +23 -7
- package/lib/servers.mjs +7 -0
- package/lib/workspace.mjs +163 -56
- package/package-catalog.json +2 -2
- package/package.json +1 -1
package/lib/remote.mjs
CHANGED
|
@@ -6,30 +6,41 @@
|
|
|
6
6
|
* 1. `observeRemote` resolves `at` with `git ls-remote --symref <url> …` —
|
|
7
7
|
* never a fetch when the caller already gave a full OID.
|
|
8
8
|
* 2. Every read (`readRemoteFile`, `listRemoteTree`, `fetchRemoteTree`) needs the
|
|
9
|
-
* commit
|
|
10
|
-
* `git fetch --depth 1 --no-tags
|
|
11
|
-
* cache repo under `<cacheRoot>/<sha256(key)>/` and then
|
|
12
|
-
* `refs/oats/commits/<oid>` so `gc` cannot prune it
|
|
13
|
-
*
|
|
9
|
+
* commit locally. `ensureCommit` does a shallow, partial
|
|
10
|
+
* `git fetch --depth 1 --no-tags --filter=blob:limit=64k origin <oid>` into a
|
|
11
|
+
* content-addressed BARE cache repo under `<cacheRoot>/<sha256(key)>/` and then
|
|
12
|
+
* pins the commit with `refs/oats/commits/<oid>` so `gc` cannot prune it (the
|
|
13
|
+
* pins are also what a later fetch advertises as `have`, so it is incremental).
|
|
14
|
+
* The commit arrives with ALL its trees (every listing is complete) and the blobs
|
|
15
|
+
* up to SMALL_BLOB_LIMIT: every file discovery reads, in one round trip.
|
|
14
16
|
* 3. Reads are then local plumbing: `git ls-tree -r -t -l -z` for listing and
|
|
15
|
-
* `git cat-file blob` for bytes.
|
|
16
|
-
*
|
|
17
|
-
*
|
|
17
|
+
* `git cat-file blob` for bytes. A read that needs a larger blob fetches it first
|
|
18
|
+
* (`ensureBlobs`: one fetch by blob id per request, a whole subtree for
|
|
19
|
+
* `fetchRemoteTree`); git never fetches one on its own (GIT_NO_LAZY_FETCH=1, and
|
|
20
|
+
* the cache stores no url to fetch from). `git archive --remote` is NOT used:
|
|
21
|
+
* GitHub and most https hosts refuse it, and per-entry plumbing lets us inspect
|
|
22
|
+
* every mode (symlink / submodule / oversize) BEFORE anything touches the disk.
|
|
18
23
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
24
|
+
* PARTIAL CACHES (awebai/oats#384): the cache is a partial clone of the remote
|
|
25
|
+
* "origin" whose url is never written (it may carry credentials): every fetch passes
|
|
26
|
+
* it as `-c remote.origin.url=<url>`. `ls-tree -l` prints "BAD" for the size of a blob
|
|
27
|
+
* the cache lacks, so an unknown size is null until ensureBlobs fetches the blob, and
|
|
28
|
+
* every budget is applied to real sizes. A server that cannot serve partial fetches
|
|
29
|
+
* (no filter support; or blob wants refused, so the commit is fetched again with
|
|
30
|
+
* `--refetch`) gets whole trees from then on: the cache records `oats.fetch = full`,
|
|
31
|
+
* and the session's `notices` say so once. So does a git older than PARTIAL_FETCH_GIT
|
|
32
|
+
* (2.45, which brought GIT_NO_LAZY_FETCH): an older git would fetch a missing blob on
|
|
33
|
+
* its own or die, so every cache it touches fetches whole trees, and a partial cache it
|
|
34
|
+
* finds is deleted and fetched again whole (rebuildWhole). A lost `.lock` race on the
|
|
35
|
+
* cache repo's config or objects (another process starting the same cache) is retried.
|
|
26
36
|
*
|
|
27
37
|
* The cache is invisible plumbing: it may be wiped at any time (a wiped cache
|
|
28
38
|
* simply re-fetches), and nothing outside this module references it.
|
|
29
39
|
*
|
|
30
40
|
* Nothing here ever prompts: GIT_TERMINAL_PROMPT=0, GIT_ASKPASS=/usr/bin/false,
|
|
31
41
|
* ssh ALWAYS in BatchMode — `-o BatchMode=yes` is appended to the operator's own
|
|
32
|
-
* GIT_SSH_COMMAND / core.sshCommand (or to plain `ssh`). Timeout 30 s per call
|
|
42
|
+
* GIT_SSH_COMMAND / core.sshCommand (or to plain `ssh`). Timeout 30 s per call;
|
|
43
|
+
* 10 minutes for the fetch of a commit (GIT_FETCH_TIMEOUT_MS).
|
|
33
44
|
*
|
|
34
45
|
* KEY vs URL (post-0.25.0 fix M2): the canonical KEY (`<host>/<path>`, lowercase
|
|
35
46
|
* host, no scheme, no `.git`) is the identity everywhere — the same repo written
|
|
@@ -59,9 +70,17 @@
|
|
|
59
70
|
* collide on a case-/normalization-insensitive filesystem (README.md vs
|
|
60
71
|
* readme.md, NFC vs NFD) are E_REMOTE_TREE_UNSAFE { why: "collision" }.
|
|
61
72
|
*
|
|
62
|
-
* CONCURRENCY: per-key operations on one cache repo are serialized
|
|
63
|
-
* (
|
|
64
|
-
*
|
|
73
|
+
* CONCURRENCY (awebai/oats#386): per-key operations on one cache repo are serialized
|
|
74
|
+
* in-process (withCacheLock), and every WRITE to a cache repo (init, config, fetch, pin)
|
|
75
|
+
* holds its cross-process write lock `<cacheRoot>/.locks/<repo>.lock` (withCacheWriteLock):
|
|
76
|
+
* a live holder is waited for (bounded by a whole fetch) and never stolen from, a dead
|
|
77
|
+
* one is reclaimed; reads take no lock. A cache repo is created whole (init into a private
|
|
78
|
+
* directory, then rename). A git `.lock` met under the write lock belongs to an older
|
|
79
|
+
* kernel's live write (retried briefly) or to a git that was killed: one older than the
|
|
80
|
+
* longest fetch, inside the cache and a regular file, is removed (a session notice says
|
|
81
|
+
* so) and the write made once more; any other is E_REMOTE_UNREADABLE { reason: "cache" }
|
|
82
|
+
* naming it. git is ended with SIGTERM first (it removes its own locks), SIGKILL after a
|
|
83
|
+
* grace unless its group is seen empty first (process-group.mjs terminateGroup, watchGroup).
|
|
65
84
|
*
|
|
66
85
|
* CACHE PIN vs OBJECTS: the pin ref is the fast-path marker, but a wiped or
|
|
67
86
|
* pruned object store is detected (`rev-parse <oid>^{commit}`) and refetched;
|
|
@@ -76,20 +95,77 @@
|
|
|
76
95
|
* is set, else "644" (Git stores only 100644/100755, so a local checkout under
|
|
77
96
|
* any umask digests identically to the fetched tree). Empty directories do not
|
|
78
97
|
* enter identity. Result: "sha256-<hex>". Empty tree → sha256 of the empty input.
|
|
98
|
+
*
|
|
99
|
+
* READ SESSION (one per CLI command; docs/implementation.md "The remote read path"):
|
|
100
|
+
* every in-process memo this module keeps lives in a session object carried as
|
|
101
|
+
* `options.session` (createReadSession). Without one, every call behaves exactly as
|
|
102
|
+
* it did before sessions existed: no memo, no batch process, no file under the cache
|
|
103
|
+
* root beyond the cache repos. A session holds
|
|
104
|
+
* - one `ls-remote` per (cache root, url, ref args): a host that is also a member is
|
|
105
|
+
* observed once, and every reader in the command sees the same commit; at most
|
|
106
|
+
* OBSERVE_LIMIT observations run at once, each holding its slot for ALL its git work
|
|
107
|
+
* (the ls-remote, and the fetch of the commit it names or of a reused record's);
|
|
108
|
+
* - the member prefetch (prefetchObservation; lib/workspace.mjs discoverWorkspace): a
|
|
109
|
+
* workspace discovery starts its members' observations with the host's, from the
|
|
110
|
+
* member list at the host's last observed commit (lastObservedCommit + peekAtCommit,
|
|
111
|
+
* no git process); a prefetched failure is adopted, never retried in the command;
|
|
112
|
+
* - positive peels per (cache repo, oid) — a negative peel is never kept;
|
|
113
|
+
* - one recursive tree index per (cache repo, commit): `git ls-tree -r -t -l -z` once,
|
|
114
|
+
* answering entryAt / listRemoteTree / remoteTreeOids exactly as per-path listings
|
|
115
|
+
* would; any index failure (the output budget, a timeout) falls back to per-path;
|
|
116
|
+
* - one long-lived `git cat-file --batch` per cache repo (at most BATCH_LIMIT alive),
|
|
117
|
+
* unref'd while idle and ended by session.close();
|
|
118
|
+
* - `maxAge` (seconds): > 0 lets observeRemote reuse a recorded head observation
|
|
119
|
+
* (the observation store below) no older than that;
|
|
120
|
+
* - the head observations used, for the `observation` output block.
|
|
121
|
+
*
|
|
122
|
+
* PARSED CACHE (`memoAtCommit`, session only): a value derived only from the bytes at
|
|
123
|
+
* (repo key, commit) by a given kernel never changes, so it is kept on disk under
|
|
124
|
+
* `<cacheRoot>/.parsed/<kernel fingerprint>/<sha256(item key)>.json`, written
|
|
125
|
+
* atomically (temp file + rename), read back only when every recorded field matches,
|
|
126
|
+
* bounded (LRU by mtime, pruned at most once per session), and never holding a
|
|
127
|
+
* transient failure (any E_REMOTE_UNREADABLE) or a value whose JSON round trip would
|
|
128
|
+
* differ. A corrupt entry is a miss, never an error.
|
|
129
|
+
*
|
|
130
|
+
* OBSERVATION STORE (session only): `<cacheRoot>/.observed/<sha256(key NUL at-args NUL urlDigest)>.json`
|
|
131
|
+
* records each successful live head observation { v, key, args, urlDigest, commit, ref,
|
|
132
|
+
* observedAt } — never the url itself (it may carry userinfo); two spellings of one repo
|
|
133
|
+
* keep a record each. A record is reused only
|
|
134
|
+
* under `maxAge`, only when key, ref args and urlDigest all match, and never in place of
|
|
135
|
+
* a live observation that failed.
|
|
79
136
|
*/
|
|
80
|
-
import {
|
|
81
|
-
import { createHash } from "node:crypto";
|
|
137
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
138
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
139
|
+
import { setMaxListeners } from "node:events";
|
|
82
140
|
import {
|
|
83
|
-
closeSync, existsSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, rmSync,
|
|
84
|
-
writeFileSync, writeSync, symlinkSync, readlinkSync } from "node:fs";
|
|
141
|
+
closeSync, existsSync, linkSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync,
|
|
142
|
+
statSync, unlinkSync, utimesSync, writeFileSync, writeSync, symlinkSync, readlinkSync } from "node:fs";
|
|
85
143
|
import { homedir } from "node:os";
|
|
86
|
-
import { dirname, isAbsolute, join, resolve, sep } from "node:path";
|
|
144
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
87
145
|
import { fileURLToPath } from "node:url";
|
|
88
146
|
import { oatsError } from "./errors.mjs";
|
|
147
|
+
import { killGroup, signalGroup, terminateGroup, watchGroup } from "./process-group.mjs";
|
|
89
148
|
|
|
90
149
|
export const FILE_BUDGET = 4 * 1024 * 1024; // readRemoteFile: 4 MiB per file
|
|
91
150
|
export const TREE_BUDGET = 64 * 1024 * 1024; // fetchRemoteTree: 64 MiB per subtree
|
|
151
|
+
/** A commit is fetched with its trees and the blobs up to this size; a read fetches any larger blob it needs. */
|
|
152
|
+
export const SMALL_BLOB_LIMIT = 64 * 1024;
|
|
153
|
+
/** The oldest git that keeps a partial cache honest: GIT_NO_LAZY_FETCH arrived in 2.45; an older git fetches a
|
|
154
|
+
* missing blob on its own, or dies trying. With an older git every cache fetches whole trees. */
|
|
155
|
+
export const PARTIAL_FETCH_GIT = [2, 45];
|
|
92
156
|
export const GIT_TIMEOUT_MS = 30_000;
|
|
157
|
+
/** The fetch of a commit into the cache: the first one transfers the commit's whole tree, which for a
|
|
158
|
+
* large workspace host takes far longer than GIT_TIMEOUT_MS (awebai/oats#362). */
|
|
159
|
+
export const GIT_FETCH_TIMEOUT_MS = 600_000;
|
|
160
|
+
/** A session's tree index: the output budget of one `ls-tree -r -t -l -z <commit>`. */
|
|
161
|
+
export const TREE_INDEX_BUDGET = 64 * 1024 * 1024;
|
|
162
|
+
/** `--max-age` bounds (seconds). */
|
|
163
|
+
export const MAX_AGE_LIMIT = 86_400;
|
|
164
|
+
/** At most this many head observations running at once per session (real and prefetched), each for all its
|
|
165
|
+
* git work: ls-remote and the fetch of its commit, or a reused record's fetch. */
|
|
166
|
+
export const OBSERVE_LIMIT = 8;
|
|
167
|
+
/** At most this many `cat-file --batch` children alive per session (idle ones are closed first). */
|
|
168
|
+
const BATCH_LIMIT = 12;
|
|
93
169
|
const OID_RE = /^[0-9a-f]{40}$/;
|
|
94
170
|
const SEGMENT_RE = /^[A-Za-z0-9_.-]+$/;
|
|
95
171
|
/** A ref name we are willing to hand to `git ls-remote` as a pattern: never a leading
|
|
@@ -205,41 +281,118 @@ function gitEnv() {
|
|
|
205
281
|
GIT_ASKPASS: "/usr/bin/false",
|
|
206
282
|
GIT_SSH_COMMAND: sshCommand(),
|
|
207
283
|
GIT_LITERAL_PATHSPECS: "1",
|
|
284
|
+
// A cache holds a commit's trees and its small blobs (SMALL_BLOB_LIMIT); a read fetches what else it needs
|
|
285
|
+
// first (ensureBlobs). git must never fetch a missing blob on its own, one round trip at a time.
|
|
286
|
+
GIT_NO_LAZY_FETCH: "1",
|
|
208
287
|
};
|
|
209
288
|
}
|
|
210
289
|
|
|
290
|
+
/** Every git child this module started that has not exited yet (runGit's and the batch readers'): what
|
|
291
|
+
* reapOnExit still has to end when the process exits. */
|
|
292
|
+
const liveChildren = new Set();
|
|
293
|
+
/** The exit path (`process.on("exit")`, ReadSession.closeNow) cannot wait for a timer, so the graceful kill
|
|
294
|
+
* is done synchronously and bounded: every live git group gets SIGTERM, the process blocks for at most
|
|
295
|
+
* EXIT_GRACE_MS (git removes its lock files on SIGTERM in far less), then SIGKILL ends what is left. A
|
|
296
|
+
* child's pid cannot be reused meanwhile: Node has not reaped it. */
|
|
297
|
+
const EXIT_GRACE_MS = 200;
|
|
298
|
+
function reapOnExit() {
|
|
299
|
+
// A child stays tracked until its stdio closes: a leader that exited while a descendant still holds its pipes
|
|
300
|
+
// is still here, and its group still gets both signals.
|
|
301
|
+
const children = [...liveChildren];
|
|
302
|
+
if (!children.length) return;
|
|
303
|
+
for (const child of children) signalGroup(child, "SIGTERM");
|
|
304
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, EXIT_GRACE_MS);
|
|
305
|
+
for (const child of children) signalGroup(child, "SIGKILL");
|
|
306
|
+
liveChildren.clear();
|
|
307
|
+
}
|
|
308
|
+
|
|
211
309
|
/** Default exec dependency: runs `git <args>`; resolves { stdout, stderr } (Buffers);
|
|
212
310
|
* rejects with { code, signal, killed, stderr, stdout, timedOut, overflowed }.
|
|
213
|
-
* timedOut — the `timeout` kill (
|
|
214
|
-
*
|
|
311
|
+
* timedOut — the `timeout` kill (killed=true, signal SIGTERM, no error.code); set by
|
|
312
|
+
* our own timer only: a git that some other process killed (an OOM kill)
|
|
313
|
+
* exits with its signal and timedOut false;
|
|
314
|
+
* overflowed — stdout or stderr exceeded `maxBuffer` (the child is killed too, and
|
|
215
315
|
* error.code = ERR_CHILD_PROCESS_STDIO_MAXBUFFER): NOT a timeout.
|
|
216
|
-
*
|
|
217
|
-
|
|
316
|
+
* An abort of `signal` rejects with an AbortError (code ABORT_ERR). git runs detached,
|
|
317
|
+
* as its own process group: a timeout, an overflow or an abort kills the group, so
|
|
318
|
+
* git's ssh or remote helper dies with it (a kill of git alone left them running until
|
|
319
|
+
* their connection ended). `input` is written to git's stdin, which is then closed. Injectable via options.exec. */
|
|
320
|
+
export function runGit(args, { cwd, maxBuffer = 16 * 1024 * 1024, timeout = GIT_TIMEOUT_MS, input, signal } = {}) {
|
|
218
321
|
return new Promise((resolvePromise, reject) => {
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
322
|
+
if (signal?.aborted) { reject(abortError(signal)); return; }
|
|
323
|
+
// stdin is a pipe: written with `input` and closed, else never written, as execFile gave it.
|
|
324
|
+
const child = watchGroup(spawn("git", args, { cwd, env: gitEnv(), detached: true, stdio: ["pipe", "pipe", "pipe"] }));
|
|
325
|
+
liveChildren.add(child);
|
|
326
|
+
child.once("close", () => liveChildren.delete(child));
|
|
327
|
+
if (input !== undefined) { child.stdin.on("error", () => { /* EPIPE after the child died: 'close' reports it */ }); child.stdin.end(input); }
|
|
328
|
+
const out = [], err = [], size = { out: 0, err: 0 };
|
|
329
|
+
let done = false;
|
|
330
|
+
const output = () => ({ stdout: Buffer.concat(out), stderr: Buffer.concat(err) });
|
|
331
|
+
const settle = (error, value) => {
|
|
332
|
+
if (done) return;
|
|
333
|
+
done = true;
|
|
334
|
+
clearTimeout(timer);
|
|
335
|
+
signal?.removeEventListener("abort", onAbort);
|
|
336
|
+
if (error) reject(error); else resolvePromise(value);
|
|
337
|
+
};
|
|
338
|
+
const stop = (error) => {
|
|
339
|
+
if (done) return;
|
|
340
|
+
// SIGTERM first (git removes its lock files), SIGKILL after the grace. The error says which kill this
|
|
341
|
+
// is (`timedOut` for our own timer), never the signal that finally ended git.
|
|
342
|
+
terminateGroup(child);
|
|
343
|
+
// stdout and stderr are drained, never destroyed: 'close' must still wait for a descendant holding them, so
|
|
344
|
+
// terminateGroup's SIGKILL reaches it (and only it). Unref'd, they never keep the command alive.
|
|
345
|
+
child.stdin.destroy();
|
|
346
|
+
for (const s of [child.stdout, child.stderr]) { s.removeAllListeners("data"); s.resume(); s.unref?.(); }
|
|
347
|
+
settle(Object.assign(error, output()));
|
|
348
|
+
};
|
|
349
|
+
const onAbort = () => stop(abortError(signal));
|
|
350
|
+
const timer = timeout > 0 ? setTimeout(() => stop(Object.assign(new Error(`Command failed: git ${args.join(" ")} (timed out after ${timeout} ms)`),
|
|
351
|
+
{ code: null, killed: true, signal: "SIGTERM", timedOut: true, overflowed: false })), timeout) : null;
|
|
352
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
353
|
+
const collect = (chunks, key) => (chunk) => {
|
|
354
|
+
const room = maxBuffer - size[key];
|
|
355
|
+
if (chunk.length > room) {
|
|
356
|
+
chunks.push(chunk.subarray(0, Math.max(0, room))); size[key] = maxBuffer;
|
|
357
|
+
stop(Object.assign(new RangeError(`${key === "out" ? "stdout" : "stderr"} maxBuffer length exceeded`),
|
|
358
|
+
{ code: "ERR_CHILD_PROCESS_STDIO_MAXBUFFER", killed: true, signal: "SIGTERM", overflowed: true, timedOut: false }));
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
chunks.push(chunk); size[key] += chunk.length;
|
|
362
|
+
};
|
|
363
|
+
child.stdout.on("data", collect(out, "out"));
|
|
364
|
+
child.stderr.on("data", collect(err, "err"));
|
|
365
|
+
child.on("error", (error) => { killGroup(child); settle(Object.assign(error, output(), { overflowed: false, timedOut: false })); });
|
|
366
|
+
child.on("close", (code, exitSignal) => {
|
|
367
|
+
if (code === 0) { settle(null, output()); return; }
|
|
368
|
+
const { stdout, stderr } = output();
|
|
369
|
+
settle(Object.assign(new Error(`Command failed: git ${args.join(" ")}\n${stderr.toString("utf8")}`),
|
|
370
|
+
{ code, signal: exitSignal, killed: false, stdout, stderr, overflowed: false, timedOut: false }));
|
|
371
|
+
});
|
|
229
372
|
});
|
|
230
373
|
}
|
|
231
374
|
|
|
375
|
+
function abortError(signal) {
|
|
376
|
+
return Object.assign(new Error("The operation was aborted", { cause: signal?.reason }), { name: "AbortError", code: "ABORT_ERR", overflowed: false, timedOut: false });
|
|
377
|
+
}
|
|
378
|
+
|
|
232
379
|
function stderrText(error) {
|
|
233
380
|
const s = error?.stderr;
|
|
234
381
|
return Buffer.isBuffer(s) ? s.toString("utf8") : typeof s === "string" ? s : String(error?.message ?? "");
|
|
235
382
|
}
|
|
236
383
|
|
|
237
|
-
/** Classify a failed network git call into the contract's
|
|
384
|
+
/** Classify a failed network git call into the contract's reasons. A `maxBuffer`
|
|
238
385
|
* overflow is never a timeout (the child is killed in both cases; only the timeout kill
|
|
239
|
-
* counts) — it falls through to the stderr text, else "network".
|
|
386
|
+
* counts) — it falls through to the stderr text, else "network". A timeout is our own
|
|
387
|
+
* timer's kill (`timedOut`), whatever signal it sent. A git lock still held by another
|
|
388
|
+
* process (a lost race that outlived retryLockRace, or a dead process's stale lock) is
|
|
389
|
+
* "cache": a local cache write failed, nothing about the remote. Any other signal exit is
|
|
390
|
+
* "killed" (the system killed git, e.g. out of memory). */
|
|
240
391
|
export function classifyRemoteFailure(error) {
|
|
241
392
|
if (error?.overflowed === true || error?.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") return classifyText(error) ?? "network";
|
|
242
|
-
if (error?.timedOut
|
|
393
|
+
if (error?.timedOut) return "timeout";
|
|
394
|
+
if (isLockRace(error) || isLocalWriteFailure(error)) return "cache";
|
|
395
|
+
if (typeof error?.signal === "string" && error.signal) return "killed";
|
|
243
396
|
return classifyText(error) ?? "network";
|
|
244
397
|
}
|
|
245
398
|
|
|
@@ -253,8 +406,44 @@ function classifyText(error) {
|
|
|
253
406
|
}
|
|
254
407
|
|
|
255
408
|
function unreadable(ref, error, extra = {}) {
|
|
256
|
-
const reason = classifyRemoteFailure(error);
|
|
257
|
-
|
|
409
|
+
const reason = classifyRemoteFailure(error), killed = reason === "killed";
|
|
410
|
+
if (reason === "cache") return cacheFailure(ref, error, extra);
|
|
411
|
+
return fail("E_REMOTE_UNREADABLE", `cannot read remote ${ref.url} (${reason})${killed ? `: git was killed (signal ${error.signal})` : ""}`,
|
|
412
|
+
{ url: ref.url, key: ref.key, reason, ...(killed ? { signal: error.signal } : {}), ...extra });
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** The lock file a git lock error names (`Unable to create '<path>.lock'`, `could not lock config file <path>`),
|
|
416
|
+
* else null. */
|
|
417
|
+
function lockFileOf(error, cwd = null) {
|
|
418
|
+
const text = stderrText(error);
|
|
419
|
+
const m = /Unable to create '([^']+\.lock)'/.exec(text) ?? /could not lock config file ([^:\s]+)/.exec(text);
|
|
420
|
+
if (!m) return null;
|
|
421
|
+
const file = m[1].endsWith(".lock") ? m[1] : `${m[1]}.lock`;
|
|
422
|
+
// git names a lock relative to its cwd (`could not lock config file config`), or as `<dir>/./refs/…`.
|
|
423
|
+
return isAbsolute(file) ? resolve(file) : cwd ? resolve(cwd, file) : file;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** git could not write a file of the local cache repo (a FETCH_HEAD, a pack, an object it cannot open or
|
|
427
|
+
* create: permissions, a directory in the way, a read-only or full disk): a fact about this machine, never
|
|
428
|
+
* about the remote. Only local file wording counts: "Permission denied (publickey)" stays auth. */
|
|
429
|
+
const isLocalWriteFailure = (error) => /cannot open '[^']+': |unable to create temporary file|insufficient permission for adding an object|no space left on device|read-only file system|unable to write (?:file|new|loose|sha1|index)|could not write (?:to|file|index)/i.test(stderrText(error));
|
|
430
|
+
|
|
431
|
+
/** A write to the local cache repo failed — reason "cache" (a timeout stays "timeout"), never "network" — with a
|
|
432
|
+
* message that says what to do: a lock still held names its file, safe to remove once no oats process runs;
|
|
433
|
+
* any other failure carries git's own words. `extra.stage` says which write (init, pin, config, fetch). */
|
|
434
|
+
function cacheFailure(ref, error, extra = {}) {
|
|
435
|
+
const url = redactUrl(ref.url);
|
|
436
|
+
if (error?.timedOut) return fail("E_REMOTE_UNREADABLE", `cannot write the cache of ${url} (timeout)`, { url: ref.url, key: ref.key, reason: "timeout", ...extra });
|
|
437
|
+
const where = extra.cacheDir ? ` at ${extra.cacheDir}` : "";
|
|
438
|
+
const lock = isLockRace(error) ? lockFileOf(error, extra.cacheDir) : null;
|
|
439
|
+
if (isLockRace(error)) {
|
|
440
|
+
const held = lock ? `${lock} is still held` : "a git lock in it is still held";
|
|
441
|
+
return fail("E_REMOTE_UNREADABLE", `cannot write the cache of ${url}${where} (cache${extra.stage ? `, ${extra.stage}` : ""}): ${held} by another git process, or left by one that died; it is safe to remove once no oats or git process is running`,
|
|
442
|
+
{ url: ref.url, key: ref.key, reason: "cache", ...extra, ...(lock ? { lock } : {}) });
|
|
443
|
+
}
|
|
444
|
+
const why = stderrText(error).trim().split("\n").filter(Boolean).join(" ") || error?.message || error?.code || "git failed";
|
|
445
|
+
const check = isLocalWriteFailure(error) ? `; check that ${extra.cacheDir ?? "the cache"} is writable by this user and its disk has room` : "";
|
|
446
|
+
return fail("E_REMOTE_UNREADABLE", `cannot write the cache of ${url}${where} (cache${extra.stage ? `, ${extra.stage}` : ""}): ${why}${check}`, { url: ref.url, key: ref.key, reason: "cache", ...extra });
|
|
258
447
|
}
|
|
259
448
|
|
|
260
449
|
// ---------------------------------------------------------------------------
|
|
@@ -278,27 +467,611 @@ async function withCacheLock(dir, fn) {
|
|
|
278
467
|
}
|
|
279
468
|
|
|
280
469
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
281
|
-
|
|
470
|
+
/** A write another process won (a git lock it holds, or the cache's `shallow` file it rewrote mid-fetch): a race
|
|
471
|
+
* on the local cache, retried, and never a fact about the remote. */
|
|
472
|
+
const isLockRace = (error) => /\.lock': File exists|Unable to create .*\.lock|another git process seems to be running|could not lock config file|shallow file has changed since we read it/i.test(stderrText(error));
|
|
473
|
+
/** How long a git lock is waited for before it is judged. Every oats writer of this kernel holds the cache's
|
|
474
|
+
* write lock (withCacheWriteLock), so a git lock met under it belongs to an older kernel's live write, or to a
|
|
475
|
+
* git that was killed: the wait covers the first, short writes; a fetch's lock is judged by its age. */
|
|
476
|
+
const LOCK_WAIT_MS = 3_000;
|
|
477
|
+
/** Run `fn` (one git call that writes the cache repo), again after a lost on-disk `.lock` race, until
|
|
478
|
+
* LOCK_WAIT_MS has passed (backoff up to 500 ms, jittered); a timeout or any other failure is never retried. */
|
|
479
|
+
async function retryLockRace(fn) {
|
|
480
|
+
const deadline = Date.now() + LOCK_WAIT_MS;
|
|
481
|
+
for (let attempt = 0; ; attempt++) {
|
|
482
|
+
try { return await fn(); }
|
|
483
|
+
catch (error) {
|
|
484
|
+
if (!isLockRace(error) || error.timedOut) throw error;
|
|
485
|
+
if (Date.now() >= deadline) throw error;
|
|
486
|
+
await sleep(Math.min(500, 50 * 2 ** attempt) * (0.5 + Math.random()));
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// ---------------------------------------------------------------------------
|
|
492
|
+
// the cache write lock (cross-process)
|
|
493
|
+
// ---------------------------------------------------------------------------
|
|
494
|
+
|
|
495
|
+
/** How long a write waits for another live oats process writing the same cache: a whole fetch, and a margin. */
|
|
496
|
+
const CACHE_WRITE_WAIT_MS = GIT_FETCH_TIMEOUT_MS + 60_000;
|
|
497
|
+
/** A write lock with no readable owner (made by hand, or by a filesystem fault: ours is linked into place
|
|
498
|
+
* whole) is judged by its age instead. */
|
|
499
|
+
const UNREADABLE_LOCK_STALE_MS = 30_000;
|
|
500
|
+
/** How old a git `*.lock` must be before a write holding the cache write lock may remove it: older than the
|
|
501
|
+
* longest fetch any oats allows, so an older kernel's live fetch (it takes no write lock) is never broken. */
|
|
502
|
+
const GIT_LOCK_STALE_MS = GIT_FETCH_TIMEOUT_MS + 60_000;
|
|
503
|
+
|
|
504
|
+
function pidAlive(pid) { try { process.kill(pid, 0); return true; } catch (e) { return e.code === "EPERM"; } }
|
|
505
|
+
|
|
506
|
+
/** The write lock of the cache repo `dir`: `<cacheRoot>/.locks/<repo>.lock`, outside git's own namespace. */
|
|
507
|
+
const cacheWriteLockOf = (dir) => join(dirname(dir), ".locks", `${basename(dir)}.lock`);
|
|
508
|
+
|
|
509
|
+
/** The write lock as { owner, ino, mtimeMs } (owner null when it holds no readable { pid, token }), or null
|
|
510
|
+
* when it is gone. A symlink or anything but a regular file is an unreadable lock; a lock that cannot be read
|
|
511
|
+
* at all (a directory in its place, EACCES) is thrown: retrying cannot clear it. */
|
|
512
|
+
function readCacheWriteLock(path) {
|
|
513
|
+
let st;
|
|
514
|
+
try { st = lstatSync(path); } catch (error) { if (error.code === "ENOENT") return null; throw error; }
|
|
515
|
+
let owner = null;
|
|
516
|
+
if (st.isFile()) {
|
|
517
|
+
try { const o = JSON.parse(readFileSync(path, "utf8")); if (Number.isSafeInteger(o?.pid) && typeof o?.token === "string") owner = o; }
|
|
518
|
+
catch (error) { if (error.code === "ENOENT") return null; if (error.code && error.code !== "ENOENT" && !(error instanceof SyntaxError)) throw error; }
|
|
519
|
+
}
|
|
520
|
+
return { owner, ino: st.ino, mtimeMs: st.mtimeMs };
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Run `fn` holding the cache repo's cross-process write lock (docs: oats-kernel-expert decision
|
|
525
|
+
* record-lock-liveness-tradeoff): every oats write to a cache repo — init, config, fetch, pin — happens under
|
|
526
|
+
* it, so two processes never write one cache at once; reads take no lock. The lock is an exclusive-create file
|
|
527
|
+
* holding { pid, token, startedAt }, linked into place whole. A live holder is waited for, never stolen from,
|
|
528
|
+
* whatever its age; a dead holder's lock (or an unreadable one past UNREADABLE_LOCK_STALE_MS) is removed after
|
|
529
|
+
* checking it is still the same file. Every pass that does not acquire falls through one deadline check and
|
|
530
|
+
* one sleep. At the deadline: E_REMOTE_UNREADABLE { reason: "cache", stage, cacheDir, lock, holderPid }; at once
|
|
531
|
+
* when a reclaimer died holding the reclaim guard (reclaimCacheWriteLock), with details.guard.
|
|
532
|
+
* `fn({ waited })`: whether another process held the lock first (what it wrote is worth checking again).
|
|
533
|
+
*/
|
|
534
|
+
async function withCacheWriteLock(ref, dir, stage, session, fn) {
|
|
535
|
+
const signal = session?.signal;
|
|
536
|
+
const lock = cacheWriteLockOf(dir);
|
|
537
|
+
const owner = { pid: process.pid, token: randomBytes(12).toString("hex"), startedAt: new Date().toISOString() };
|
|
538
|
+
const url = redactUrl(ref.url);
|
|
539
|
+
const refuse = (message, extra = {}) => fail("E_REMOTE_UNREADABLE", `cannot write the cache of ${url} at ${dir} (cache, ${stage}): ${message}`,
|
|
540
|
+
{ url: ref.url, key: ref.key, reason: "cache", stage, cacheDir: dir, lock, ...extra });
|
|
541
|
+
try { mkdirSync(dirname(lock), { recursive: true, mode: 0o700 }); } catch (error) { throw refuse(`cannot create ${dirname(lock)} (${error.code ?? error.message})`); }
|
|
542
|
+
const deadline = Date.now() + (session?.cacheWriteWaitMs ?? CACHE_WRITE_WAIT_MS);
|
|
543
|
+
let held = null, waited = false;
|
|
544
|
+
for (let attempt = 0; ; attempt++) {
|
|
545
|
+
if (signal?.aborted) throw unreadable(ref, abortError(signal), { cacheDir: dir, stage });
|
|
546
|
+
const tmp = `${lock}.${owner.pid}.${owner.token}`;
|
|
547
|
+
try {
|
|
548
|
+
writeFileSync(tmp, JSON.stringify(owner) + "\n", { mode: 0o600 });
|
|
549
|
+
try { linkSync(tmp, lock); break; } catch (error) { if (error.code !== "EEXIST") throw error; }
|
|
550
|
+
} catch (error) { throw refuse(`cannot take its write lock ${lock} (${error.code ?? error.message})`); }
|
|
551
|
+
finally { try { unlinkSync(tmp); } catch { /* never written */ } }
|
|
552
|
+
try { held = readCacheWriteLock(lock); } catch (error) { throw refuse(`cannot read its write lock ${lock} (${error.code ?? error.message}); remove it if no oats process is running`); }
|
|
553
|
+
if (held && isStaleLock(held)) {
|
|
554
|
+
const abandoned = reclaimCacheWriteLock(lock, held, owner);
|
|
555
|
+
if (abandoned) {
|
|
556
|
+
throw refuse(`${abandoned.guard} was left by ${abandoned.pid ? `oats process ${abandoned.pid}, which died` : "an oats process that died"} while reclaiming the write lock ${lock}; it is safe to remove once no oats process is running`,
|
|
557
|
+
{ guard: abandoned.guard, ...(held.owner ? { holderPid: held.owner.pid } : {}) });
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
if (Date.now() >= deadline) {
|
|
561
|
+
throw refuse(held?.owner ? `oats process ${held.owner.pid} has been writing it since ${held.owner.startedAt} (lock ${lock}); try again once it finishes`
|
|
562
|
+
: `its write lock ${lock} is held; it is safe to remove once no oats process is running`, held?.owner ? { holderPid: held.owner.pid } : {});
|
|
563
|
+
}
|
|
564
|
+
waited = true;
|
|
565
|
+
await sleep(Math.min(250, 25 * 2 ** attempt) * (0.5 + Math.random()));
|
|
566
|
+
}
|
|
567
|
+
try { return await fn({ waited }); }
|
|
568
|
+
finally {
|
|
569
|
+
// Release only our own lock: one reclaimed from us meanwhile belongs to its new holder.
|
|
570
|
+
try { if (readCacheWriteLock(lock)?.owner?.token === owner.token) unlinkSync(lock); } catch { /* gone */ }
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/** Whether a lock record (readCacheWriteLock) is provably abandoned: its owner's pid is gone, or it has no
|
|
575
|
+
* readable owner and is older than UNREADABLE_LOCK_STALE_MS. A live owner is never stale, whatever its age. */
|
|
576
|
+
const isStaleLock = (held) => (held.owner ? !pidAlive(held.owner.pid) : Date.now() - held.mtimeMs > UNREADABLE_LOCK_STALE_MS);
|
|
577
|
+
/** The same record still at `path`: the same owner token, or (no readable owner) the same file. */
|
|
578
|
+
const sameLock = (now, held) => (held.owner ? now?.owner?.token === held.owner.token : now && !now.owner && now.ino === held.ino && now.mtimeMs === held.mtimeMs);
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* Remove the write lock `held`, proven stale — serialized among reclaimers by the guard `<lock>.reclaim`
|
|
582
|
+
* (exclusive create, holding { pid, token }). Under the guard the lock is read again and removed only if it is
|
|
583
|
+
* still that stale record. Only a reclaimer ever removes another process's lock, and reclaimers hold the guard,
|
|
584
|
+
* so the lock cannot change between that check and the unlink: a reclaimer that paused cannot delete the lock a
|
|
585
|
+
* live process took meanwhile. A live guard is waited for (the caller's next pass). A guard whose holder died
|
|
586
|
+
* (or unreadable and old) is never removed: removing it would race exactly as removing the lock does, with no
|
|
587
|
+
* guard left to serialize that, so two reclaimers could each delete the other's live guard, then lock. It is
|
|
588
|
+
* returned instead, { guard, pid }, and the caller refuses naming it: a reclaimer dying inside its guard is a
|
|
589
|
+
* microseconds window, and a human removing the file once no oats process runs is the safe recovery. Any other
|
|
590
|
+
* failure leaves the lock to the caller's next pass and its deadline. → null, or the abandoned guard.
|
|
591
|
+
*/
|
|
592
|
+
function reclaimCacheWriteLock(lock, held, me) {
|
|
593
|
+
const guard = `${lock}.reclaim`;
|
|
594
|
+
try { writeFileSync(guard, JSON.stringify({ pid: me.pid, token: me.token }) + "\n", { flag: "wx", mode: 0o600 }); }
|
|
595
|
+
catch (error) {
|
|
596
|
+
if (error?.code !== "EEXIST") return null;
|
|
597
|
+
try { const g = readCacheWriteLock(guard); if (g && isStaleLock(g)) return { guard, pid: g.owner?.pid ?? null }; } catch { /* next pass */ }
|
|
598
|
+
return null;
|
|
599
|
+
}
|
|
600
|
+
try {
|
|
601
|
+
const now = readCacheWriteLock(lock);
|
|
602
|
+
if (now && sameLock(now, held) && isStaleLock(now)) unlinkSync(lock);
|
|
603
|
+
} catch { /* next pass */ }
|
|
604
|
+
finally { try { if (readCacheWriteLock(guard)?.owner?.token === me.token) unlinkSync(guard); } catch { /* gone */ } }
|
|
605
|
+
return null;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/** Remove the git lock file `lockFile` that a cache write met, if — and only if — it provably belongs to no live
|
|
609
|
+
* writer: it is inside this cache repo, ends in `.lock`, is a regular file (never a symlink), and is older than
|
|
610
|
+
* GIT_LOCK_STALE_MS. Called only while holding the cache write lock (no oats writer of this kernel can hold it).
|
|
611
|
+
* → whether it was removed. */
|
|
612
|
+
function reclaimGitLock(repo, lockFile) {
|
|
613
|
+
if (typeof lockFile !== "string" || !lockFile.endsWith(".lock") || !isAbsolute(lockFile)) return false;
|
|
614
|
+
let root, parent;
|
|
615
|
+
try { root = realpathSync(repo.dir); parent = realpathSync(dirname(lockFile)); } catch { return false; }
|
|
616
|
+
if (parent !== root && !parent.startsWith(root + sep)) return false;
|
|
617
|
+
const file = join(parent, basename(lockFile));
|
|
618
|
+
let st;
|
|
619
|
+
try { st = lstatSync(file); } catch { return false; }
|
|
620
|
+
if (!st.isFile() || Date.now() - st.mtimeMs <= GIT_LOCK_STALE_MS) return false;
|
|
621
|
+
try { unlinkSync(file); return true; } catch { return false; }
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/** One git call that writes the cache repo (config, fetch, update-ref), made while holding its write lock:
|
|
625
|
+
* a lost lock race (an older kernel's write) is retried briefly; a git lock that outlives that and is
|
|
626
|
+
* provably stale (reclaimGitLock: a git killed mid-write) is removed — said once as a session notice — and
|
|
627
|
+
* the call made once more. Anything else is thrown as git raised it. */
|
|
628
|
+
async function cacheGit(repo, args, opts = {}) {
|
|
629
|
+
const run = () => repo.local(args, opts);
|
|
630
|
+
try { return await retryLockRace(run); }
|
|
631
|
+
catch (error) {
|
|
632
|
+
const lockFile = isLockRace(error) ? lockFileOf(error, repo.dir) : null;
|
|
633
|
+
if (!lockFile || !reclaimGitLock(repo, lockFile)) throw error;
|
|
634
|
+
repo.session?.notices.push(`removed a stale git lock ${lockFile} (left by a git process that was killed mid-write)`);
|
|
635
|
+
return await run();
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
const sha256 = (text) => createHash("sha256").update(text).digest("hex");
|
|
640
|
+
const cacheRootOf = (options) => options.cacheDir ?? defaultCacheRoot();
|
|
641
|
+
const cacheDirOf = (root, ref) => join(root, sha256(ref.key));
|
|
642
|
+
|
|
643
|
+
/** A url without its userinfo (`ssh://user:secret@host/…` → `ssh://host/…`): what may be written to disk. */
|
|
644
|
+
function redactUrl(url) {
|
|
645
|
+
return typeof url === "string" ? url.replace(/^([a-z][a-z0-9+.-]*:\/\/)[^@/]*@/i, "$1") : url;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function repoHandle(dir, exec, session) {
|
|
649
|
+
const local = (args, opts = {}) => exec(["-C", dir, "-c", "gc.auto=0", ...args], { cwd: dir, ...(session ? { signal: session.signal } : {}), ...opts });
|
|
650
|
+
return { dir, exec, local, session };
|
|
651
|
+
}
|
|
282
652
|
|
|
283
653
|
async function cacheRepo(ref, options) {
|
|
284
654
|
const exec = options.exec ?? runGit;
|
|
285
|
-
const
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
655
|
+
const dir = cacheDirOf(cacheRootOf(options), ref);
|
|
656
|
+
if (!existsSync(join(dir, "HEAD"))) await initCacheRepo(ref, dir, exec);
|
|
657
|
+
return repoHandle(dir, exec, sessionOf(options));
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/** How long a cache directory without HEAD is waited for (an older kernel initialising it in place) before it
|
|
661
|
+
* is taken for a crash's leftover and replaced. */
|
|
662
|
+
const HALF_INIT_WAIT_MS = 2_000;
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* Create the cache repo at `dir` in one atomic step: `git init --bare` into a private sibling directory, then
|
|
666
|
+
* rename it into place, so `dir` never exists half-initialised. Processes racing to create one cache all
|
|
667
|
+
* succeed: a loser's rename finds the winner's complete repo and drops its own copy. A directory at `dir`
|
|
668
|
+
* without HEAD (a crash's leftover, or an older kernel initialising in place) is waited for (bounded), then
|
|
669
|
+
* moved aside and replaced; the cache is disposable. Any other failure is E_REMOTE_UNREADABLE
|
|
670
|
+
* { reason: "cache", stage: "init", cacheDir }.
|
|
671
|
+
*/
|
|
672
|
+
async function initCacheRepo(ref, dir, exec) {
|
|
673
|
+
const failInit = (error) => cacheFailure(ref, error, { cacheDir: dir, stage: "init" });
|
|
674
|
+
const tmp = `${dir}.init-${process.pid}-${randomBytes(4).toString("hex")}`;
|
|
675
|
+
try {
|
|
676
|
+
try { mkdirSync(tmp, { recursive: true, mode: 0o700 }); } catch (error) { throw failInit(error); }
|
|
677
|
+
try { await exec(["init", "-q", "--bare", tmp]); } catch (error) { throw failInit(error); }
|
|
678
|
+
try { writeFileSync(join(tmp, "oats-remote.json"), JSON.stringify({ key: ref.key, url: redactUrl(ref.url) }, null, 2) + "\n"); } catch {}
|
|
679
|
+
const deadline = Date.now() + HALF_INIT_WAIT_MS;
|
|
680
|
+
for (;;) {
|
|
681
|
+
try { renameSync(tmp, dir); return; } // replaces nothing, or an EMPTY directory
|
|
682
|
+
catch (error) {
|
|
683
|
+
if (error?.code !== "ENOTEMPTY" && error?.code !== "EEXIST") throw failInit(error);
|
|
684
|
+
}
|
|
685
|
+
if (existsSync(join(dir, "HEAD"))) return; // another process created it first
|
|
686
|
+
if (Date.now() < deadline) { await sleep(50); continue; }
|
|
687
|
+
// No HEAD after the wait: a leftover. Move it aside (atomic; a process racing to do the same loses
|
|
688
|
+
// with ENOENT, which is fine) and take its place.
|
|
689
|
+
const aside = `${dir}.stale-${process.pid}-${randomBytes(4).toString("hex")}`;
|
|
690
|
+
try { renameSync(dir, aside); } catch (error) { if (error?.code !== "ENOENT") throw failInit(error); }
|
|
691
|
+
// A repo another process renamed in between the check and the move is complete: put it back.
|
|
692
|
+
if (existsSync(join(aside, "HEAD"))) { try { renameSync(aside, dir); continue; } catch { /* taken again meanwhile */ } }
|
|
693
|
+
rmSync(aside, { recursive: true, force: true });
|
|
293
694
|
}
|
|
294
|
-
|
|
695
|
+
} finally {
|
|
696
|
+
rmSync(tmp, { recursive: true, force: true }); // gone already after a successful rename
|
|
295
697
|
}
|
|
296
|
-
const local = (args, opts = {}) => exec(["-C", dir, "-c", "gc.auto=0", ...args], { cwd: dir, ...opts });
|
|
297
|
-
return { dir, exec, local };
|
|
298
698
|
}
|
|
299
699
|
|
|
300
700
|
function pinRef(oid) { return `refs/oats/commits/${oid}`; }
|
|
301
701
|
|
|
702
|
+
// ---------------------------------------------------------------------------
|
|
703
|
+
// read session
|
|
704
|
+
// ---------------------------------------------------------------------------
|
|
705
|
+
|
|
706
|
+
class ReadSession {
|
|
707
|
+
constructor({ maxAge = 0, now = Date.now, fingerprint = null, treeIndexBudget = TREE_INDEX_BUDGET, parsedLimits = null, batchTimeoutMs = GIT_TIMEOUT_MS,
|
|
708
|
+
cacheWriteWaitMs = CACHE_WRITE_WAIT_MS, fetchTimeoutMs = GIT_FETCH_TIMEOUT_MS } = {}) {
|
|
709
|
+
if (!Number.isInteger(maxAge) || maxAge < 0 || maxAge > MAX_AGE_LIMIT) throw new TypeError(`maxAge must be an integer from 0 to ${MAX_AGE_LIMIT}`);
|
|
710
|
+
this.maxAge = maxAge;
|
|
711
|
+
this.now = now;
|
|
712
|
+
this.startedAt = new Date(now()).toISOString();
|
|
713
|
+
this.fingerprint = fingerprint; // tests inject one; else the kernel's own (kernelFingerprint)
|
|
714
|
+
this.treeIndexBudget = treeIndexBudget;
|
|
715
|
+
this.batchTimeoutMs = batchTimeoutMs; // tests shorten it; a batch answer is otherwise waited for as long as any git call
|
|
716
|
+
this.cacheWriteWaitMs = cacheWriteWaitMs; // tests shorten it: how long a write waits for another live writer of its cache
|
|
717
|
+
this.fetchTimeoutMs = fetchTimeoutMs; // tests shorten it: a fetch's own timeout
|
|
718
|
+
this.parsedLimits = parsedLimits; // tests inject small prune bounds
|
|
719
|
+
this.observations = new Map(); // memo key → Promise<head observation>
|
|
720
|
+
this.used = new Map(); // memo key → { observedAt, reused }: the heads this command used
|
|
721
|
+
this.peels = new Map(); // `${cacheRepo}\0${oid}` → peeled commit (positive answers only)
|
|
722
|
+
this.trees = new Map(); // `${cacheRepo}\0${commit}` → Promise<tree index | null>
|
|
723
|
+
this.batches = new Map(); // cacheRepo → batch reader, in least-recently-used order
|
|
724
|
+
this.retiring = new Set(); // evicted batch readers still ending
|
|
725
|
+
this.parsedDirs = new Set(); // fingerprint dirs touched (mtime bumped) this session
|
|
726
|
+
this.observeActive = 0; // observations running now (at most OBSERVE_LIMIT) …
|
|
727
|
+
this.observeQueue = []; // … and the ones waiting for a slot, in order: { resolve, reject }
|
|
728
|
+
this.prefetched = new Map(); // memo key → prefetch entry { promise, abandoned } no caller has adopted yet
|
|
729
|
+
this.aborter = new AbortController(); // close()/closeNow() kill every git child still running for this session
|
|
730
|
+
this.signal = this.aborter.signal;
|
|
731
|
+
setMaxListeners(0, this.signal); // every running git child listens (up to 16 at once): no leak warning
|
|
732
|
+
this.notices = []; // what the command should tell the operator once it ends (the CLI prints them)
|
|
733
|
+
this.pruned = false;
|
|
734
|
+
this.closed = false;
|
|
735
|
+
}
|
|
736
|
+
/** One of the session's OBSERVE_LIMIT observation slots, shared by real and prefetched observations and
|
|
737
|
+
* held for all of an observation's git work (no unbounded fan-out over a large workspace). A released
|
|
738
|
+
* slot passes straight to the next waiter. Nothing holding a slot ever waits for another one. */
|
|
739
|
+
observeSlot() {
|
|
740
|
+
if (this.closed) return Promise.reject(abandonedError());
|
|
741
|
+
if (this.observeActive < OBSERVE_LIMIT) { this.observeActive++; return Promise.resolve(); }
|
|
742
|
+
return new Promise((resolve, reject) => this.observeQueue.push({ resolve, reject }));
|
|
743
|
+
}
|
|
744
|
+
observeDone() {
|
|
745
|
+
const next = this.observeQueue.shift();
|
|
746
|
+
if (next) next.resolve(); else this.observeActive--;
|
|
747
|
+
}
|
|
748
|
+
/** Give up every prefetch no caller has adopted: a queued one never runs its git, and its memo entry is
|
|
749
|
+
* dropped so a later caller observes afresh. One already running finishes, or is killed by close(). */
|
|
750
|
+
abandonPrefetches() {
|
|
751
|
+
for (const [key, entry] of this.prefetched) {
|
|
752
|
+
entry.abandoned = true;
|
|
753
|
+
if (this.observations.get(key) === entry.promise) this.observations.delete(key);
|
|
754
|
+
}
|
|
755
|
+
this.prefetched.clear();
|
|
756
|
+
}
|
|
757
|
+
/** The command is over: nothing queued runs, and every git child still running is killed. */
|
|
758
|
+
stop() {
|
|
759
|
+
this.closed = true;
|
|
760
|
+
this.abandonPrefetches();
|
|
761
|
+
for (const waiter of this.observeQueue.splice(0)) waiter.reject(abandonedError());
|
|
762
|
+
this.aborter.abort();
|
|
763
|
+
}
|
|
764
|
+
/** End the batch readers now (a long provider command is about to run) without ending the session:
|
|
765
|
+
* a later read opens a new one. */
|
|
766
|
+
async closeBatches() {
|
|
767
|
+
const batches = [...this.batches.values(), ...this.retiring];
|
|
768
|
+
this.batches.clear();
|
|
769
|
+
await Promise.all(batches.map((b) => b.close()));
|
|
770
|
+
this.retiring.clear();
|
|
771
|
+
}
|
|
772
|
+
/** `{ observedAt, reused }`: the OLDEST head observation this command used (else the time the
|
|
773
|
+
* session began), and whether any of them came from the observation store. */
|
|
774
|
+
observation() {
|
|
775
|
+
let oldest = null, reused = false;
|
|
776
|
+
for (const u of this.used.values()) {
|
|
777
|
+
if (u.reused) reused = true;
|
|
778
|
+
if (oldest === null || Date.parse(u.observedAt) < Date.parse(oldest)) oldest = u.observedAt;
|
|
779
|
+
}
|
|
780
|
+
return { observedAt: oldest ?? this.startedAt, reused };
|
|
781
|
+
}
|
|
782
|
+
/** End the session: nothing queued runs, every git child still running is killed, and every batch child
|
|
783
|
+
* is ended (stdin closed, awaited (bounded), killed if it lingers). */
|
|
784
|
+
async close() {
|
|
785
|
+
this.stop();
|
|
786
|
+
await this.closeBatches();
|
|
787
|
+
}
|
|
788
|
+
/** Synchronous close for `process.on("exit")`: no waiting is possible there. */
|
|
789
|
+
closeNow() {
|
|
790
|
+
this.stop();
|
|
791
|
+
for (const b of [...this.batches.values(), ...this.retiring]) b.kill();
|
|
792
|
+
this.batches.clear();
|
|
793
|
+
this.retiring.clear();
|
|
794
|
+
reapOnExit(); // no timer runs on the exit path: SIGTERM, a bounded synchronous grace, then SIGKILL
|
|
795
|
+
}
|
|
796
|
+
/** A session rides remoteOptions, which callers may serialise (memo keys): never its innards. */
|
|
797
|
+
toJSON() { return "[oats read session]"; }
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/** One command's read session (see the module header). `maxAge` seconds (0 = observe live). Test seams:
|
|
801
|
+
* `now`, `fingerprint`, `treeIndexBudget`, `parsedLimits`, `batchTimeoutMs`, `cacheWriteWaitMs`, `fetchTimeoutMs`. */
|
|
802
|
+
export function createReadSession(options = {}) { return new ReadSession(options); }
|
|
803
|
+
/** An observation the command no longer wants (its session closed, or its prefetch abandoned): never adopted
|
|
804
|
+
* by a caller that is still reading, so its shape only has to be a typed remote failure. */
|
|
805
|
+
function abandonedError() {
|
|
806
|
+
return fail("E_REMOTE_UNREADABLE", "observation abandoned: the command no longer needs it", { reason: "unknown", abandoned: true });
|
|
807
|
+
}
|
|
808
|
+
/** Give up this session's prefetches that no caller has adopted (lib/workspace.mjs discoverWorkspace, when the
|
|
809
|
+
* host cannot be observed or once its members are read). Session only; never throws. */
|
|
810
|
+
export function abandonPrefetches(options = {}) { sessionOf(options)?.abandonPrefetches(); }
|
|
811
|
+
function sessionOf(options) { return options?.session instanceof ReadSession ? options.session : null; }
|
|
812
|
+
|
|
813
|
+
// ---------------------------------------------------------------------------
|
|
814
|
+
// the parsed cache: values derived from the bytes at (repo key, commit)
|
|
815
|
+
// ---------------------------------------------------------------------------
|
|
816
|
+
|
|
817
|
+
/** The prune bounds (count AND bytes, least recently used first; maintainer amendment 5). */
|
|
818
|
+
export const PARSED_LIMITS = Object.freeze({
|
|
819
|
+
maxEntries: 8192, maxBytes: 128 * 1024 * 1024, // prune when either is exceeded …
|
|
820
|
+
keepEntries: 6144, keepBytes: 96 * 1024 * 1024, // … down to both of these
|
|
821
|
+
staleFingerprintMs: 7 * 24 * 3600 * 1000, // another kernel's directory, unused this long
|
|
822
|
+
staleObservationMs: 30 * 24 * 3600 * 1000, // an observation record this old
|
|
823
|
+
staleTempMs: 3600 * 1000, // a writer's temp file left by a crash
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
/** The kernel code fingerprint: sha256 over package.json's version, the resolved version of the
|
|
827
|
+
* `yaml` parser, then every lib/**\/*.mjs and docs/*.schema.json (path NUL bytes NUL), in sorted
|
|
828
|
+
* path order. A checkout whose parsing changed (its code, or a parser bumped without a kernel
|
|
829
|
+
* version bump) never reads another build's entries. Computed once per process, lazily. */
|
|
830
|
+
let fingerprintMemo = null;
|
|
831
|
+
export function kernelFingerprint() {
|
|
832
|
+
if (fingerprintMemo) return fingerprintMemo;
|
|
833
|
+
const root = fileURLToPath(new URL("..", import.meta.url));
|
|
834
|
+
const files = [];
|
|
835
|
+
const walk = (dir) => {
|
|
836
|
+
for (const d of readdirSync(dir, { withFileTypes: true })) {
|
|
837
|
+
const p = join(dir, d.name);
|
|
838
|
+
if (d.isDirectory()) walk(p);
|
|
839
|
+
else if (d.isFile() && d.name.endsWith(".mjs")) files.push(p);
|
|
840
|
+
}
|
|
841
|
+
};
|
|
842
|
+
walk(join(root, "lib"));
|
|
843
|
+
for (const name of readdirSync(join(root, "docs"))) if (name.endsWith(".schema.json")) files.push(join(root, "docs", name));
|
|
844
|
+
const rels = files.map((f) => relative(root, f).split(sep).join("/")).sort();
|
|
845
|
+
const hash = createHash("sha256");
|
|
846
|
+
hash.update(String(JSON.parse(readFileSync(join(root, "package.json"), "utf8")).version)); hash.update("\0");
|
|
847
|
+
hash.update(`yaml@${resolvedVersion("yaml")}`); hash.update("\0");
|
|
848
|
+
for (const rel of rels) { hash.update(rel); hash.update("\0"); hash.update(readFileSync(join(root, rel))); hash.update("\0"); }
|
|
849
|
+
fingerprintMemo = hash.digest("hex");
|
|
850
|
+
return fingerprintMemo;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/** The version of the package `name` this kernel imports (the package.json above its resolved entry),
|
|
854
|
+
* or "unresolved". */
|
|
855
|
+
function resolvedVersion(name) {
|
|
856
|
+
try {
|
|
857
|
+
let dir = dirname(fileURLToPath(import.meta.resolve(name)));
|
|
858
|
+
for (;;) {
|
|
859
|
+
try {
|
|
860
|
+
const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
|
|
861
|
+
if (pkg.name === name) return String(pkg.version);
|
|
862
|
+
} catch { /* no package.json here */ }
|
|
863
|
+
const up = dirname(dir);
|
|
864
|
+
if (up === dir) return "unresolved";
|
|
865
|
+
dir = up;
|
|
866
|
+
}
|
|
867
|
+
} catch { return "unresolved"; }
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
const TRANSIENT_REASONS = new Set(["timeout", "network", "auth", "unknown", "cache"]);
|
|
871
|
+
/** A value the parsed cache may keep: JSON-safe (its round trip is deepStrictEqual — plain or
|
|
872
|
+
* null-prototype objects, dense arrays, finite numbers other than -0, strings, booleans, null; no
|
|
873
|
+
* undefined, no cycle) and free of transient failures (any E_REMOTE_UNREADABLE, or a remote
|
|
874
|
+
* failure reason timeout/network/auth/unknown). → encoded { data, nullProto: [pointer] } | null */
|
|
875
|
+
function encodeCacheable(value) {
|
|
876
|
+
const nullProto = [];
|
|
877
|
+
const ancestors = new Set();
|
|
878
|
+
const esc = (k) => k.replace(/~/g, "~0").replace(/\//g, "~1");
|
|
879
|
+
const ok = (v, ptr) => {
|
|
880
|
+
if (v === null || typeof v === "string" || typeof v === "boolean") return true;
|
|
881
|
+
if (typeof v === "number") return Number.isFinite(v) && !Object.is(v, -0);
|
|
882
|
+
if (typeof v !== "object" || ancestors.has(v)) return false;
|
|
883
|
+
if (Object.getOwnPropertySymbols(v).length) return false;
|
|
884
|
+
ancestors.add(v);
|
|
885
|
+
try {
|
|
886
|
+
const descriptors = Object.getOwnPropertyDescriptors(v);
|
|
887
|
+
if (Array.isArray(v)) {
|
|
888
|
+
if (Object.keys(descriptors).length !== v.length + 1) return false; // indices + length only
|
|
889
|
+
for (let i = 0; i < v.length; i++) { const d = descriptors[i]; if (!d || !("value" in d) || !d.enumerable || !ok(d.value, `${ptr}/${i}`)) return false; }
|
|
890
|
+
return true;
|
|
891
|
+
}
|
|
892
|
+
const proto = Object.getPrototypeOf(v);
|
|
893
|
+
if (proto !== Object.prototype && proto !== null) return false;
|
|
894
|
+
if (proto === null) nullProto.push(ptr);
|
|
895
|
+
if (v.code === "E_REMOTE_UNREADABLE" || (typeof v.code === "string" && v.code.startsWith("E_REMOTE_") && TRANSIENT_REASONS.has(v.reason))) return false;
|
|
896
|
+
for (const [k, d] of Object.entries(descriptors)) if (!("value" in d) || !d.enumerable || !ok(d.value, `${ptr}/${esc(k)}`)) return false;
|
|
897
|
+
return true;
|
|
898
|
+
} finally { ancestors.delete(v); }
|
|
899
|
+
};
|
|
900
|
+
return ok(value, "") ? { data: value, nullProto } : null;
|
|
901
|
+
}
|
|
902
|
+
/** A URL that may carry a secret: any userinfo on http(s) (a bare token), or a `user:password` userinfo on any
|
|
903
|
+
* scheme. Repo content can hold one (a member ref written with credentials); such a value is never written
|
|
904
|
+
* to the parsed cache. `ssh://git@host/…` (a user, no password) is not a secret. */
|
|
905
|
+
const CREDENTIAL_URL_RE = /\bhttps?:\/\/[^\s"'\/@]+@|\b[a-z][a-z0-9+.-]*:\/\/[^\s"'\/@:]+:[^\s"'\/@]*@/i;
|
|
906
|
+
function carriesCredential(text) { return CREDENTIAL_URL_RE.test(text); }
|
|
907
|
+
function reviveCached(data, nullProto) {
|
|
908
|
+
if (!nullProto.length) return data;
|
|
909
|
+
const wanted = new Set(nullProto);
|
|
910
|
+
const esc = (k) => k.replace(/~/g, "~0").replace(/\//g, "~1");
|
|
911
|
+
const walk = (v, ptr) => {
|
|
912
|
+
if (Array.isArray(v)) { for (let i = 0; i < v.length; i++) v[i] = walk(v[i], `${ptr}/${i}`); return v; }
|
|
913
|
+
if (v === null || typeof v !== "object") return v;
|
|
914
|
+
const out = wanted.has(ptr) ? Object.create(null) : v;
|
|
915
|
+
for (const k of Object.keys(v)) {
|
|
916
|
+
const child = walk(v[k], `${ptr}/${esc(k)}`);
|
|
917
|
+
if (out === v && k !== "__proto__") v[k] = child; // an own "__proto__" key is data: never an assignment
|
|
918
|
+
else Object.defineProperty(out, k, { value: child, enumerable: true, writable: true, configurable: true });
|
|
919
|
+
}
|
|
920
|
+
return out;
|
|
921
|
+
};
|
|
922
|
+
return walk(data, "");
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
/** Write `text` to `file` atomically: a temp file in the same directory, then rename. Failures are ignored
|
|
926
|
+
* (every store here is an optimisation, never an error). → whether it was written. */
|
|
927
|
+
function writeAtomicQuiet(file, text) {
|
|
928
|
+
const dir = dirname(file);
|
|
929
|
+
const tmp = join(dir, `.${file.slice(dir.length + 1)}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`);
|
|
930
|
+
try {
|
|
931
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
932
|
+
writeFileSync(tmp, text, { mode: 0o600 });
|
|
933
|
+
renameSync(tmp, file);
|
|
934
|
+
return true;
|
|
935
|
+
} catch {
|
|
936
|
+
try { rmSync(tmp, { force: true }); } catch { /* nothing */ }
|
|
937
|
+
return false;
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
/** Read and parse a JSON store file → value | undefined (absent, unreadable, corrupt: a corrupt or partial
|
|
941
|
+
* file is removed when it can be). */
|
|
942
|
+
function readStoreFile(file) {
|
|
943
|
+
let text;
|
|
944
|
+
try { text = readFileSync(file, "utf8"); } catch { return undefined; }
|
|
945
|
+
try { return JSON.parse(text); }
|
|
946
|
+
catch { try { rmSync(file, { force: true }); } catch { /* nothing */ } return undefined; }
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
function parsedDir(root, session) { return join(root, ".parsed", session.fingerprint ?? kernelFingerprint()); }
|
|
950
|
+
|
|
951
|
+
/**
|
|
952
|
+
* The parsed cache (see the module header): `compute()`'s value for `item` at (refText's key, commit),
|
|
953
|
+
* from `<cacheRoot>/.parsed/<fingerprint>/` when an intact entry is there — no git process — else computed,
|
|
954
|
+
* kept when cacheable, and returned. Without a session (library callers), or for a commit that is not a
|
|
955
|
+
* full OID, it only computes. A thrown compute() is never kept.
|
|
956
|
+
*/
|
|
957
|
+
export async function memoAtCommit(refText, commit, item, compute, options = {}) {
|
|
958
|
+
const slot = parsedSlot(refText, commit, item, options);
|
|
959
|
+
if (!slot) return compute();
|
|
960
|
+
if (slot.hit) return slot.value;
|
|
961
|
+
const { session, key, root, dir, fingerprint, file } = slot;
|
|
962
|
+
const value = await compute();
|
|
963
|
+
const encoded = encodeCacheable(value);
|
|
964
|
+
const text = encoded ? JSON.stringify({ v: 1, fingerprint, key, commit, item, nullProto: encoded.nullProto, value: encoded.data }) : null;
|
|
965
|
+
if (text !== null && !carriesCredential(text) && writeAtomicQuiet(file, text)) {
|
|
966
|
+
session.parsedDirs.add(dir);
|
|
967
|
+
if (!session.pruned) { session.pruned = true; pruneStores(root, { fingerprint, limits: session.parsedLimits ?? PARSED_LIMITS, now: session.now() }); }
|
|
968
|
+
}
|
|
969
|
+
return value;
|
|
970
|
+
}
|
|
971
|
+
/** The parsed-cache slot of (refText's key, commit, item) in this session → null when there is no cache
|
|
972
|
+
* (no session, not a full OID, an unparseable ref), else { …where, hit, value? } — a hit read and revived,
|
|
973
|
+
* a corrupt or mismatched entry removed (a miss). */
|
|
974
|
+
function parsedSlot(refText, commit, item, options) {
|
|
975
|
+
const session = sessionOf(options);
|
|
976
|
+
if (!session || typeof commit !== "string" || !OID_RE.test(commit) || typeof item !== "string") return null;
|
|
977
|
+
let key;
|
|
978
|
+
try { key = parseRepoRef(refText, options).key; } catch { return null; }
|
|
979
|
+
const root = cacheRootOf(options);
|
|
980
|
+
const dir = parsedDir(root, session);
|
|
981
|
+
const fingerprint = session.fingerprint ?? kernelFingerprint();
|
|
982
|
+
const file = join(dir, `${sha256(JSON.stringify([key, commit, item]))}.json`);
|
|
983
|
+
const slot = { session, key, root, dir, fingerprint, file, hit: false };
|
|
984
|
+
const entry = readStoreFile(file);
|
|
985
|
+
if (entry !== undefined) {
|
|
986
|
+
const intact = entry && typeof entry === "object" && entry.v === 1 && entry.fingerprint === fingerprint && entry.key === key
|
|
987
|
+
&& entry.commit === commit && entry.item === item && Object.hasOwn(entry, "value") && Array.isArray(entry.nullProto);
|
|
988
|
+
if (intact) {
|
|
989
|
+
touchParsed(file, dir, session);
|
|
990
|
+
return { ...slot, hit: true, value: reviveCached(entry.value, entry.nullProto) };
|
|
991
|
+
}
|
|
992
|
+
try { rmSync(file, { force: true }); } catch { /* a miss either way */ }
|
|
993
|
+
}
|
|
994
|
+
return slot;
|
|
995
|
+
}
|
|
996
|
+
/** A parsed-cache entry, read only: its value when an intact entry is there, else undefined. Never computes,
|
|
997
|
+
* never starts a git process (the member prefetch's source). */
|
|
998
|
+
export function peekAtCommit(refText, commit, item, options = {}) {
|
|
999
|
+
const slot = parsedSlot(refText, commit, item, options);
|
|
1000
|
+
return slot?.hit ? slot.value : undefined;
|
|
1001
|
+
}
|
|
1002
|
+
/** A hit bumps the entry's mtime (LRU) and, once per session, its fingerprint directory's (in use). */
|
|
1003
|
+
function touchParsed(file, dir, session) {
|
|
1004
|
+
const now = new Date(session.now());
|
|
1005
|
+
try { utimesSync(file, now, now); } catch { /* ignore */ }
|
|
1006
|
+
if (!session.parsedDirs.has(dir)) { session.parsedDirs.add(dir); try { utimesSync(dir, now, now); } catch { /* ignore */ } }
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
/**
|
|
1010
|
+
* Bound the stores under `root` (one scan): the current fingerprint's entries, least recently used first,
|
|
1011
|
+
* down to keepEntries AND keepBytes once over maxEntries OR maxBytes; other fingerprint directories unused
|
|
1012
|
+
* for staleFingerprintMs; observation records older than staleObservationMs; temp files a crashed writer
|
|
1013
|
+
* left. Never throws. → { removed, entries, bytes } for the current fingerprint.
|
|
1014
|
+
*/
|
|
1015
|
+
export function pruneStores(root, { fingerprint = kernelFingerprint(), limits = PARSED_LIMITS, now = Date.now() } = {}) {
|
|
1016
|
+
const L = { ...PARSED_LIMITS, ...limits };
|
|
1017
|
+
const list = (dir) => { try { return readdirSync(dir); } catch { return []; } };
|
|
1018
|
+
const stat = (p) => { try { return statSync(p); } catch { return null; } };
|
|
1019
|
+
const remove = (p, opts = {}) => { try { rmSync(p, { force: true, ...opts }); return true; } catch { return false; } };
|
|
1020
|
+
const parsed = join(root, ".parsed");
|
|
1021
|
+
let removed = 0, entries = 0, bytes = 0;
|
|
1022
|
+
for (const name of list(parsed)) {
|
|
1023
|
+
const dir = join(parsed, name);
|
|
1024
|
+
if (name === fingerprint) {
|
|
1025
|
+
const files = [];
|
|
1026
|
+
for (const f of list(dir)) {
|
|
1027
|
+
const p = join(dir, f), st = stat(p);
|
|
1028
|
+
if (!st) continue;
|
|
1029
|
+
if (f.endsWith(".tmp")) { if (now - st.mtimeMs > L.staleTempMs && remove(p)) removed++; continue; }
|
|
1030
|
+
files.push({ p, size: st.size, mtime: st.mtimeMs });
|
|
1031
|
+
}
|
|
1032
|
+
entries = files.length; bytes = files.reduce((n, f) => n + f.size, 0);
|
|
1033
|
+
if (entries > L.maxEntries || bytes > L.maxBytes) {
|
|
1034
|
+
files.sort((a, b) => a.mtime - b.mtime);
|
|
1035
|
+
for (const f of files) {
|
|
1036
|
+
if (entries <= L.keepEntries && bytes <= L.keepBytes) break;
|
|
1037
|
+
if (remove(f.p)) { removed++; entries--; bytes -= f.size; }
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
} else {
|
|
1041
|
+
const st = stat(dir);
|
|
1042
|
+
if (st && now - st.mtimeMs > L.staleFingerprintMs && remove(dir, { recursive: true })) removed++;
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
const observed = join(root, ".observed");
|
|
1046
|
+
for (const f of list(observed)) {
|
|
1047
|
+
const p = join(observed, f), st = stat(p);
|
|
1048
|
+
if (st && now - st.mtimeMs > (f.endsWith(".tmp") ? L.staleTempMs : L.staleObservationMs) && remove(p)) removed++;
|
|
1049
|
+
}
|
|
1050
|
+
return { removed, entries, bytes };
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
// ---------------------------------------------------------------------------
|
|
1054
|
+
// the observation store: successful live head observations, for --max-age
|
|
1055
|
+
// ---------------------------------------------------------------------------
|
|
1056
|
+
|
|
1057
|
+
const observationFile = (root, ref, atArgs) => join(root, ".observed", `${sha256(`${ref.key}\0${atArgs.join("\0")}\0${sha256(ref.url)}`)}.json`);
|
|
1058
|
+
|
|
1059
|
+
/** A recorded observation of (ref.key, atArgs) reusable now under session.maxAge, else null. With
|
|
1060
|
+
* `anyAge`, an intact record of any age (what the prefetch learns the last-read commit from). */
|
|
1061
|
+
function readObservation(root, ref, atArgs, session, { anyAge = false } = {}) {
|
|
1062
|
+
const rec = readStoreFile(observationFile(root, ref, atArgs));
|
|
1063
|
+
if (!rec || typeof rec !== "object" || rec.v !== 1 || rec.key !== ref.key || rec.urlDigest !== sha256(ref.url)) return null;
|
|
1064
|
+
if (!Array.isArray(rec.args) || rec.args.length !== atArgs.length || rec.args.some((a, i) => a !== atArgs[i])) return null;
|
|
1065
|
+
if (typeof rec.commit !== "string" || !OID_RE.test(rec.commit) || (rec.ref !== null && typeof rec.ref !== "string")) return null;
|
|
1066
|
+
const at = typeof rec.observedAt === "string" ? Date.parse(rec.observedAt) : NaN;
|
|
1067
|
+
const now = session.now();
|
|
1068
|
+
if (!Number.isFinite(at) || (!anyAge && (at > now + 5000 || now - at > session.maxAge * 1000))) return null;
|
|
1069
|
+
return { commit: rec.commit, ref: rec.ref, observedAt: rec.observedAt };
|
|
1070
|
+
}
|
|
1071
|
+
function writeObservation(root, ref, atArgs, obs) {
|
|
1072
|
+
writeAtomicQuiet(observationFile(root, ref, atArgs), JSON.stringify({ v: 1, key: ref.key, args: atArgs, urlDigest: sha256(ref.url), commit: obs.commit, ref: obs.ref, observedAt: obs.observedAt }));
|
|
1073
|
+
}
|
|
1074
|
+
|
|
302
1075
|
/** The COMMIT <oid> peels to, when <oid> is present in the cache and is a commit or an
|
|
303
1076
|
* annotated tag chain ending in one; null on a missing object, a pruned store, or a
|
|
304
1077
|
* tag/tree/blob that does not peel to a commit. (`rev-parse <oid>^{commit}` peels.) */
|
|
@@ -314,40 +1087,206 @@ async function objectType(repo, oid) {
|
|
|
314
1087
|
}
|
|
315
1088
|
|
|
316
1089
|
/**
|
|
317
|
-
* Ensure <oid> (a full OID of a COMMIT, or of an annotated TAG that peels to one) and
|
|
318
|
-
*
|
|
319
|
-
* commit: a tag OID given as `at` is accepted, but the commit
|
|
320
|
-
* commit it points to (fix M4). The pin is on the peeled commit; a tag
|
|
321
|
-
* `refs/oats/tags/<oid>` pin so gc cannot break the chain either.
|
|
1090
|
+
* Ensure <oid> (a full OID of a COMMIT, or of an annotated TAG that peels to one), its trees and its
|
|
1091
|
+
* small blobs are present in the cache (larger blobs: ensureBlobs, when a read needs them). → { repo,
|
|
1092
|
+
* commit } where `commit` is the PEELED commit: a tag OID given as `at` is accepted, but the commit
|
|
1093
|
+
* recorded everywhere is the commit it points to (fix M4). The pin is on the peeled commit; a tag
|
|
1094
|
+
* object gets its own `refs/oats/tags/<oid>` pin so gc cannot break the chain either.
|
|
322
1095
|
*/
|
|
323
1096
|
async function ensureCommit(ref, oid, options) {
|
|
324
|
-
const
|
|
325
|
-
|
|
1097
|
+
const dir = cacheDirOf(cacheRootOf(options), ref);
|
|
1098
|
+
// A session keeps POSITIVE peels: a commit present in the cache stays present for the command
|
|
1099
|
+
// (pinned, and nothing here runs gc). A negative answer is never kept.
|
|
1100
|
+
const session = sessionOf(options);
|
|
1101
|
+
const known = session?.peels.get(`${dir}\0${oid}`);
|
|
1102
|
+
if (known) return { repo: repoHandle(dir, options.exec ?? runGit, session), commit: known };
|
|
1103
|
+
const remember = (commit) => { if (session) { session.peels.set(`${dir}\0${oid}`, commit); session.peels.set(`${dir}\0${commit}`, commit); } return commit; };
|
|
326
1104
|
return withCacheLock(dir, async () => {
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
1105
|
+
// Already present (the common case): a read, so no write lock.
|
|
1106
|
+
let peeled = false; // the object store was asked already, and said no
|
|
1107
|
+
const usable = async (repo) => {
|
|
1108
|
+
if (!existsSync(join(dir, "HEAD"))) return null;
|
|
1109
|
+
if (!keepsPartialCache(await gitVersion(repo.exec)) && await fetchMode(repo) === "partial") return null;
|
|
1110
|
+
peeled = true;
|
|
1111
|
+
return peelCommit(repo, oid);
|
|
1112
|
+
};
|
|
1113
|
+
const present = await usable(repoHandle(dir, options.exec ?? runGit, session));
|
|
1114
|
+
if (present) return { repo: repoHandle(dir, options.exec ?? runGit, session), commit: remember(present) };
|
|
1115
|
+
// Every write below (init, config, fetch, pin) holds the cache's cross-process write lock; a process that
|
|
1116
|
+
// waited for it finds what the holder fetched.
|
|
1117
|
+
return withCacheWriteLock(ref, dir, "fetch", session, async ({ waited }) => {
|
|
1118
|
+
let repo = await cacheRepo(ref, options);
|
|
1119
|
+
const version = await gitVersion(repo.exec);
|
|
1120
|
+
// A partial cache is only safe where git cannot fetch a missing blob on its own: an older git rebuilds it whole.
|
|
1121
|
+
if (!keepsPartialCache(version) && await fetchMode(repo) === "partial") repo = await rebuildWhole(repo, ref, options, version);
|
|
1122
|
+
const cached = peeled && !waited ? null : await peelCommit(repo, oid);
|
|
1123
|
+
if (cached) return { repo, commit: remember(cached) }; // pinned AND present AND (peels to) a commit
|
|
1124
|
+
const mode = await fetchMode(repo)
|
|
1125
|
+
?? (keepsPartialCache(version) ? await startPartialFetches(repo, ref) : await recordFullFetches(repo, ref, olderGitNotice(version, ref)));
|
|
1126
|
+
const filter = mode === "partial" ? [`--filter=${PARTIAL_FILTER}`] : ["--no-filter"];
|
|
1127
|
+
const { stderr } = await fetchInto(repo, ref, ["fetch", "-q", "--depth", "1", "--no-tags", "--no-recurse-submodules", ...filter, "origin", oid], { what: oid, commit: oid });
|
|
1128
|
+
// A server without filters says so and sends the whole tree: nothing is lost, the cache remembers.
|
|
1129
|
+
if (mode === "partial" && /filtering not recognized by server/i.test(stderr.toString("utf8"))) await recordFullFetches(repo, ref);
|
|
343
1130
|
const commit = await peelCommit(repo, oid);
|
|
344
1131
|
if (!commit) {
|
|
345
1132
|
const type = await objectType(repo, oid);
|
|
346
1133
|
throw fail("E_REMOTE_UNREADABLE", `${oid} in ${ref.url} is ${type ? `a ${type}` : "missing"}, not a commit`, { url: ref.url, key: ref.key, reason: "not-found", commit: oid, type });
|
|
347
1134
|
}
|
|
348
|
-
await repo
|
|
349
|
-
if (commit !== oid) await repo
|
|
350
|
-
return { repo, commit };
|
|
1135
|
+
await writePin(repo, ref, pinRef(commit), commit, commit);
|
|
1136
|
+
if (commit !== oid) await writePin(repo, ref, `refs/oats/tags/${oid}`, oid, commit);
|
|
1137
|
+
return { repo, commit: remember(commit) };
|
|
1138
|
+
});
|
|
1139
|
+
});
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
/** Pin `name` to `oid` in the cache repo. Two processes pinning one commit write the same value: a lost lock
|
|
1143
|
+
* race is retried, and a ref that already holds `oid` (another process's write) counts as written. */
|
|
1144
|
+
async function writePin(repo, ref, name, oid, commit) {
|
|
1145
|
+
try { await cacheGit(repo, ["update-ref", name, oid]); }
|
|
1146
|
+
catch (error) {
|
|
1147
|
+
let current = null;
|
|
1148
|
+
try { current = (await repo.local(["rev-parse", "--verify", "-q", name])).stdout.toString("utf8").trim(); } catch {}
|
|
1149
|
+
if (current === oid) return;
|
|
1150
|
+
throw cacheFailure(ref, error, { cacheDir: repo.dir, stage: "pin", commit });
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
/** The filter a partial fetch asks for: every blob up to SMALL_BLOB_LIMIT comes with the commit. */
|
|
1155
|
+
const PARTIAL_FILTER = `blob:limit=${SMALL_BLOB_LIMIT / 1024}k`;
|
|
1156
|
+
|
|
1157
|
+
/** How this cache fetches (its own `oats.fetch` config): "partial", "full" (its server cannot serve
|
|
1158
|
+
* partial fetches), or null before its first fetch. */
|
|
1159
|
+
async function fetchMode(repo) {
|
|
1160
|
+
try { return (await repo.local(["config", "--get", "oats.fetch"])).stdout.toString("utf8").trim() || null; } catch { return null; }
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
/** Make the cache a partial clone of the remote "origin". Its url is never written (it may carry
|
|
1164
|
+
* credentials): every fetch passes it with `-c remote.origin.url=` (fetchInto). */
|
|
1165
|
+
async function startPartialFetches(repo, ref) {
|
|
1166
|
+
for (const [key, value] of [["core.repositoryformatversion", "1"], ["extensions.partialclone", "origin"], ["remote.origin.promisor", "true"],
|
|
1167
|
+
["remote.origin.partialclonefilter", PARTIAL_FILTER], ["oats.fetch", "partial"]]) await writeConfig(repo, ref, key, value);
|
|
1168
|
+
return "partial";
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
/** One `git config <key> <value>` in the cache repo. Two processes starting one cache write the same values,
|
|
1172
|
+
* so a lost lock race is simply retried. */
|
|
1173
|
+
async function writeConfig(repo, ref, key, value) {
|
|
1174
|
+
try { await cacheGit(repo, ["config", key, value]); }
|
|
1175
|
+
catch (error) { throw unreadable(ref, error, { cacheDir: repo.dir, stage: "config" }); }
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
/** This cache fetches whole trees from now on (its server cannot serve partial fetches, or this git cannot keep
|
|
1179
|
+
* a partial cache): recorded, and said once (the session's notices; the CLI prints them when the command ends). */
|
|
1180
|
+
async function recordFullFetches(repo, ref, notice = `${redactUrl(ref.url)} does not serve partial fetches; OATS fetches whole trees from it`) {
|
|
1181
|
+
await writeConfig(repo, ref, "oats.fetch", "full");
|
|
1182
|
+
repo.session?.notices.push(notice);
|
|
1183
|
+
return "full";
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
const olderGitNotice = (version, ref) =>
|
|
1187
|
+
`git ${version?.text ?? "(unknown version)"} cannot keep a partial cache (it needs ${PARTIAL_FETCH_GIT.join(".")}); OATS fetches whole trees from ${redactUrl(ref.url)}`;
|
|
1188
|
+
|
|
1189
|
+
/** `git --version` of the git `exec` runs, asked once per exec: → { text: "2.54.0", major, minor } | null. */
|
|
1190
|
+
const gitVersions = new WeakMap();
|
|
1191
|
+
export function gitVersion(exec = runGit) {
|
|
1192
|
+
let version = gitVersions.get(exec);
|
|
1193
|
+
if (!version) {
|
|
1194
|
+
version = Promise.resolve().then(() => exec(["--version"])).then((out) => {
|
|
1195
|
+
const m = /git version ((\d+)\.(\d+)[^\s]*)/.exec(out.stdout.toString("utf8"));
|
|
1196
|
+
return m ? { text: m[1], major: Number(m[2]), minor: Number(m[3]) } : null;
|
|
1197
|
+
}, () => null);
|
|
1198
|
+
gitVersions.set(exec, version);
|
|
1199
|
+
}
|
|
1200
|
+
return version;
|
|
1201
|
+
}
|
|
1202
|
+
/** Whether this git keeps a partial cache honest (GIT_NO_LAZY_FETCH): an unknown version does not. */
|
|
1203
|
+
export function keepsPartialCache(version) {
|
|
1204
|
+
const [major, minor] = PARTIAL_FETCH_GIT;
|
|
1205
|
+
return version != null && (version.major > major || (version.major === major && version.minor >= minor));
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
/** Delete a partial cache an older git cannot read (it would fetch a missing blob on its own, or die), and start
|
|
1209
|
+
* it again recording whole-tree fetches. The cache is disposable: everything in it is fetched again. */
|
|
1210
|
+
async function rebuildWhole(repo, ref, options, version) {
|
|
1211
|
+
const session = repo.session;
|
|
1212
|
+
if (session) {
|
|
1213
|
+
session.batches.get(repo.dir)?.kill();
|
|
1214
|
+
session.batches.delete(repo.dir);
|
|
1215
|
+
for (const map of [session.peels, session.trees]) for (const k of [...map.keys()]) if (k.startsWith(`${repo.dir}\0`)) map.delete(k);
|
|
1216
|
+
}
|
|
1217
|
+
rmSync(repo.dir, { recursive: true, force: true });
|
|
1218
|
+
const fresh = await cacheRepo(ref, options);
|
|
1219
|
+
await recordFullFetches(fresh, ref, olderGitNotice(version, ref));
|
|
1220
|
+
return fresh;
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
/** `git fetch` from the remote into the cache (args start at the subcommand and name the remote "origin"):
|
|
1224
|
+
* a lost on-disk `.lock` race is retried, a timeout names the fetch (`what`) and how long it ran, any other
|
|
1225
|
+
* failure is E_REMOTE_UNREADABLE (`raw`: the git error itself, for a caller that reads its stderr). → { stdout, stderr } */
|
|
1226
|
+
async function fetchInto(repo, ref, args, { what, commit, input, raw = false } = {}) {
|
|
1227
|
+
const started = Date.now();
|
|
1228
|
+
let lastError;
|
|
1229
|
+
try { return await cacheGit(repo, ["-c", `remote.origin.url=${ref.url}`, ...args], { timeout: repo.session?.fetchTimeoutMs ?? GIT_FETCH_TIMEOUT_MS, ...(input !== undefined ? { input } : {}) }); }
|
|
1230
|
+
catch (error) { lastError = error; }
|
|
1231
|
+
if (lastError?.timedOut) {
|
|
1232
|
+
const elapsedMs = Date.now() - started;
|
|
1233
|
+
throw fail("E_REMOTE_UNREADABLE", `cannot read remote ${ref.url} (timeout): git fetch of ${what} timed out after ${Math.round(elapsedMs / 1000)} s`,
|
|
1234
|
+
{ url: ref.url, key: ref.key, reason: "timeout", commit, operation: "fetch", elapsedMs });
|
|
1235
|
+
}
|
|
1236
|
+
throw raw ? lastError : unreadable(ref, lastError, { commit, ...(classifyRemoteFailure(lastError) === "cache" ? { cacheDir: repo.dir, stage: "fetch" } : {}) });
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
/** The sizes of `oids` in the cache, one `cat-file --batch-check`: → Map oid → size, null when the cache lacks it. */
|
|
1240
|
+
async function blobSizes(repo, oids) {
|
|
1241
|
+
const out = (await repo.local(["cat-file", "--batch-check"], { input: oids.map((o) => `${o}\n`).join("") })).stdout.toString("utf8");
|
|
1242
|
+
const sizes = new Map(oids.map((o) => [o, null]));
|
|
1243
|
+
for (const line of out.split("\n")) {
|
|
1244
|
+
const [oid, type, size] = line.split(" ");
|
|
1245
|
+
if (sizes.has(oid) && type !== "missing" && /^[0-9]+$/.test(size ?? "")) sizes.set(oid, Number(size));
|
|
1246
|
+
}
|
|
1247
|
+
return sizes;
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
const isRefusedWant = (error) => /unadvertised object|not our ref|does not allow request|allow-(tip|reachable|any)-sha1-in-want/i.test(stderrText(error));
|
|
1251
|
+
|
|
1252
|
+
/**
|
|
1253
|
+
* Make the blobs of `entries` (ls-tree entries of `commit`) present before anything reads them: those
|
|
1254
|
+
* whose size is unknown (null: the cache lacks them) are fetched in ONE fetch, by id, and every entry's
|
|
1255
|
+
* `size` is then the blob's real size. A server that refuses wants of blobs by id gets the commit fetched
|
|
1256
|
+
* again whole (`--refetch`), and the cache records it (recordFullFetches).
|
|
1257
|
+
*/
|
|
1258
|
+
async function ensureBlobs(repo, ref, commit, entries) {
|
|
1259
|
+
const unknown = entries.filter((e) => e.size === null);
|
|
1260
|
+
if (!unknown.length) return;
|
|
1261
|
+
const oids = [...new Set(unknown.map((e) => e.oid))];
|
|
1262
|
+
await withCacheLock(repo.dir, async () => {
|
|
1263
|
+
let sizes = await blobSizes(repo, oids); // another read may have fetched them meanwhile
|
|
1264
|
+
let wanted = oids.filter((o) => sizes.get(o) === null);
|
|
1265
|
+
if (wanted.length) await withCacheWriteLock(ref, repo.dir, "fetch", repo.session, async ({ waited }) => {
|
|
1266
|
+
if (waited) { // another process may have fetched them while this one waited
|
|
1267
|
+
sizes = await blobSizes(repo, oids);
|
|
1268
|
+
wanted = oids.filter((o) => sizes.get(o) === null);
|
|
1269
|
+
if (!wanted.length) return;
|
|
1270
|
+
}
|
|
1271
|
+
const what = `${wanted.length} blob${wanted.length === 1 ? "" : "s"} at ${commit}`;
|
|
1272
|
+
const refetch = () => fetchInto(repo, ref, ["fetch", "-q", "--refetch", "--no-filter", "--depth", "1", "--no-tags", "--no-recurse-submodules", "origin", commit], { what: commit, commit });
|
|
1273
|
+
if (await fetchMode(repo) === "full") await refetch(); // a commit fetched partially before its server was found out
|
|
1274
|
+
else {
|
|
1275
|
+
try {
|
|
1276
|
+
await fetchInto(repo, ref, ["-c", "fetch.negotiationAlgorithm=noop", "fetch", "-q", "--no-tags", "--no-write-fetch-head", "--stdin", "origin"],
|
|
1277
|
+
{ what, commit, input: wanted.map((o) => `${o}\n`).join(""), raw: true });
|
|
1278
|
+
} catch (error) {
|
|
1279
|
+
if (typeof error?.code === "string" && error.code.startsWith("E_")) throw error;
|
|
1280
|
+
if (!isRefusedWant(error)) throw unreadable(ref, error, { commit, ...(classifyRemoteFailure(error) === "cache" ? { cacheDir: repo.dir, stage: "fetch" } : {}) });
|
|
1281
|
+
await refetch();
|
|
1282
|
+
await recordFullFetches(repo, ref);
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
sizes = await blobSizes(repo, oids);
|
|
1286
|
+
const still = oids.find((o) => sizes.get(o) === null);
|
|
1287
|
+
if (still) throw fail("E_REMOTE_UNREADABLE", `cannot read remote ${ref.url} (not-found): blob ${still} at ${commit} was not fetched`, { url: ref.url, key: ref.key, reason: "not-found", commit, oid: still });
|
|
1288
|
+
});
|
|
1289
|
+
for (const e of unknown) e.size = sizes.get(e.oid);
|
|
351
1290
|
});
|
|
352
1291
|
}
|
|
353
1292
|
|
|
@@ -391,23 +1330,120 @@ function resolveAt(parsed, at) {
|
|
|
391
1330
|
* at: undefined → remote default branch (ls-remote --symref HEAD); a full OID; or a tag/branch name.
|
|
392
1331
|
* → { key, url, commit, ref, observedAt } | throws E_REMOTE_UNREADABLE { url, reason }.
|
|
393
1332
|
* Never half-succeeds; never prompts. A full OID already in the cache costs no network call.
|
|
1333
|
+
* With a session: one head observation per (cache root, url, ref) per command, and — under
|
|
1334
|
+
* session.maxAge — a recorded observation no older than that is reused instead of `ls-remote`
|
|
1335
|
+
* (the result then carries the RECORDED observedAt and `reused: true`). A full-OID `at` is not a
|
|
1336
|
+
* head observation: never recorded, reused or counted.
|
|
394
1337
|
*/
|
|
395
1338
|
export async function observeRemote(refText, { at, ...options } = {}) {
|
|
396
1339
|
const ref = parseRepoRef(refText, options);
|
|
397
|
-
const exec = options.exec ?? runGit;
|
|
398
1340
|
if (at !== undefined && at !== null && typeof at !== "string") throw fail("E_REPO_REF", "at must be a string (full OID, tag or branch name)", { at });
|
|
399
1341
|
if (typeof at === "string" && OID_RE.test(at)) {
|
|
400
1342
|
// A tag OID is accepted here; the commit recorded is the one it peels to (M4).
|
|
401
1343
|
const { commit } = await ensureCommit(ref, at, options);
|
|
402
1344
|
return { key: ref.key, url: ref.url, commit, ref: null, observedAt: new Date().toISOString() };
|
|
403
1345
|
}
|
|
1346
|
+
const args = lsRemoteArgs(ref, at);
|
|
1347
|
+
const session = sessionOf(options);
|
|
1348
|
+
if (!session) return observeLive(ref, args, at, options);
|
|
1349
|
+
const memoKey = JSON.stringify([cacheRootOf(options), args]);
|
|
1350
|
+
const obs = await sessionObservation(ref, args, at, options, session, memoKey, false);
|
|
1351
|
+
// Counted only here, by a caller that uses it: a prefetched head nobody asks for never is.
|
|
1352
|
+
session.used.set(memoKey, { observedAt: obs.observedAt, reused: obs.reused === true });
|
|
1353
|
+
return { ...obs, key: ref.key, url: ref.url };
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
/** The `ls-remote` argv of a head observation (`at`: HEAD, a tag or a branch; never a full OID). */
|
|
1357
|
+
function lsRemoteArgs(ref, at) {
|
|
404
1358
|
const wantHead = at === undefined || at === null || at === "" || at === "HEAD";
|
|
405
1359
|
if (!wantHead && AT_BAD_RE.test(at)) throw fail("E_REPO_REF", `at must be a full OID or a plain tag/branch name, got ${JSON.stringify(at)}`, { at });
|
|
406
1360
|
const args = ["ls-remote", "--symref", ref.url];
|
|
407
1361
|
if (wantHead) args.push("HEAD");
|
|
408
1362
|
else args.push(`refs/tags/${at}`, `refs/tags/${at}^{}`, `refs/heads/${at}`, at);
|
|
1363
|
+
return args;
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
/**
|
|
1367
|
+
* The session's one observation of (cache root, url, ref args): started by the first caller, adopted by
|
|
1368
|
+
* every later one. A failure started by an ordinary caller is dropped from the memo, so a later caller
|
|
1369
|
+
* retries. A failure started by a PREFETCH (prefetchObservation) is kept and adopted: it happened moments
|
|
1370
|
+
* earlier in this same command, and a retry would double a 30 s timeout on an unreachable member
|
|
1371
|
+
* (spec Addendum 3, item 4).
|
|
1372
|
+
*/
|
|
1373
|
+
function sessionObservation(ref, args, at, options, session, memoKey, prefetch) {
|
|
1374
|
+
let pending = session.observations.get(memoKey);
|
|
1375
|
+
if (!pending) {
|
|
1376
|
+
const entry = prefetch ? { promise: null, abandoned: false } : null;
|
|
1377
|
+
pending = observeInSession(ref, args, at, options, session, entry);
|
|
1378
|
+
if (!prefetch) pending = pending.catch((error) => { session.observations.delete(memoKey); throw error; });
|
|
1379
|
+
else { entry.promise = pending; session.prefetched.set(memoKey, entry); }
|
|
1380
|
+
session.observations.set(memoKey, pending);
|
|
1381
|
+
} else if (!prefetch) session.prefetched.delete(memoKey); // adopted: never abandoned from here on
|
|
1382
|
+
return pending;
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
/**
|
|
1386
|
+
* The commit this machine last observed `refText` at (`at` as observeRemote takes it), from its observation
|
|
1387
|
+
* record — of any age: this is not reuse — or the full OID `at` itself. Session only; no git process.
|
|
1388
|
+
* → full OID | null (no session, no intact record for this key, ref args and url digest).
|
|
1389
|
+
*/
|
|
1390
|
+
export function lastObservedCommit(refText, { at, ...options } = {}) {
|
|
1391
|
+
const session = sessionOf(options);
|
|
1392
|
+
if (!session) return null;
|
|
1393
|
+
try {
|
|
1394
|
+
if (typeof at === "string" && OID_RE.test(at)) return at;
|
|
1395
|
+
const ref = parseRepoRef(refText, options);
|
|
1396
|
+
const record = readObservation(cacheRootOf(options), ref, lsRemoteArgs(ref, at).slice(3), session, { anyAge: true });
|
|
1397
|
+
return record ? record.commit : null;
|
|
1398
|
+
} catch { return null; }
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
/**
|
|
1402
|
+
* Start the session's head observation of `refText` now, without waiting for it (the member prefetch,
|
|
1403
|
+
* spec Addendum 3): the same memoised observation a later observeRemote adopts — success or failure —
|
|
1404
|
+
* under the same observation limit, reusing a record exactly as observeRemote would. Session only; an
|
|
1405
|
+
* unparseable ref or a full-OID `at` is ignored. Never throws, never rejects unhandled.
|
|
1406
|
+
*/
|
|
1407
|
+
export function prefetchObservation(refText, { at, ...options } = {}) {
|
|
1408
|
+
const session = sessionOf(options);
|
|
1409
|
+
if (!session || (typeof at === "string" && OID_RE.test(at))) return;
|
|
1410
|
+
try {
|
|
1411
|
+
const ref = parseRepoRef(refText, options);
|
|
1412
|
+
const args = lsRemoteArgs(ref, at);
|
|
1413
|
+
sessionObservation(ref, args, at, options, session, JSON.stringify([cacheRootOf(options), args]), true).catch(() => { /* adopted by its caller */ });
|
|
1414
|
+
} catch { /* not a ref: nothing to prefetch */ }
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
/** A head observation in a session: a reusable record (maxAge > 0) whose commit is pinned or can still be
|
|
1418
|
+
* fetched, else live — and every successful live observation is recorded. A problem with a record is
|
|
1419
|
+
* never an error (live instead); a failed live observation is exactly today's error. */
|
|
1420
|
+
async function observeInSession(ref, args, at, options, session, prefetch = null) {
|
|
1421
|
+
const root = cacheRootOf(options);
|
|
1422
|
+
const atArgs = args.slice(3);
|
|
1423
|
+
await session.observeSlot(); // held for every git process below: the reuse path's fetch as much as ls-remote's
|
|
1424
|
+
try {
|
|
1425
|
+
// A prefetch given up while it queued (or a session closed meanwhile) runs no git at all.
|
|
1426
|
+
if (session.closed || prefetch?.abandoned) throw abandonedError();
|
|
1427
|
+
if (session.maxAge > 0) {
|
|
1428
|
+
const record = readObservation(root, ref, atArgs, session);
|
|
1429
|
+
if (record) {
|
|
1430
|
+
const reused = { key: ref.key, url: ref.url, commit: record.commit, ref: record.ref, observedAt: record.observedAt, reused: true };
|
|
1431
|
+
if (existsSync(join(cacheDirOf(root, ref), pinRef(record.commit)))) return reused;
|
|
1432
|
+
try { return { ...reused, commit: (await ensureCommit(ref, record.commit, options)).commit }; }
|
|
1433
|
+
catch { /* the recorded commit can no longer be fetched: observe live */ }
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
const obs = await observeLive(ref, args, at, options);
|
|
1437
|
+
writeObservation(root, ref, atArgs, obs);
|
|
1438
|
+
return obs;
|
|
1439
|
+
} finally { session.observeDone(); }
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
async function observeLive(ref, args, at, options) {
|
|
1443
|
+
const exec = options.exec ?? runGit;
|
|
1444
|
+
const signal = sessionOf(options)?.signal;
|
|
409
1445
|
let out;
|
|
410
|
-
try { out = await exec(args, { timeout: GIT_TIMEOUT_MS }); }
|
|
1446
|
+
try { out = await exec(args, { timeout: GIT_TIMEOUT_MS, ...(signal ? { signal } : {}) }); }
|
|
411
1447
|
catch (error) { throw unreadable(ref, error, { at: at ?? null }); }
|
|
412
1448
|
const parsed = parseLsRemote(out.stdout);
|
|
413
1449
|
const hit = resolveAt(parsed, at);
|
|
@@ -433,7 +1469,8 @@ function normalizeTreePath(path, { allowRoot }) {
|
|
|
433
1469
|
return parts.join("/");
|
|
434
1470
|
}
|
|
435
1471
|
|
|
436
|
-
/** Parse `git ls-tree -l -z` output → [{ mode, type, oid, size, path }].
|
|
1472
|
+
/** Parse `git ls-tree -l -z` output → [{ mode, type, oid, size, path }]. `size` is null for a tree, and for a
|
|
1473
|
+
* blob the cache does not hold (git prints "BAD" for it and still succeeds): ensureBlobs learns it. */
|
|
437
1474
|
function parseLsTree(stdout) {
|
|
438
1475
|
const entries = [];
|
|
439
1476
|
for (const record of stdout.toString("utf8").split("\0")) {
|
|
@@ -441,7 +1478,7 @@ function parseLsTree(stdout) {
|
|
|
441
1478
|
const tab = record.indexOf("\t");
|
|
442
1479
|
const meta = record.slice(0, tab).trim().split(/\s+/), path = record.slice(tab + 1);
|
|
443
1480
|
const [mode, type, oid, size] = meta;
|
|
444
|
-
entries.push({ mode, type, oid, size: size
|
|
1481
|
+
entries.push({ mode, type, oid, size: /^[0-9]+$/.test(size) ? Number(size) : null, path });
|
|
445
1482
|
}
|
|
446
1483
|
return entries;
|
|
447
1484
|
}
|
|
@@ -469,32 +1506,200 @@ function assertNoCollisions(entries, ref, commit, prefix) {
|
|
|
469
1506
|
}
|
|
470
1507
|
|
|
471
1508
|
/** `git ls-tree -l -z [flags] <spec> [-- <path>]`; null when <spec> names no tree.
|
|
472
|
-
* Any other failure is E_REMOTE_UNREADABLE (reason "timeout" for the timeout kill,
|
|
473
|
-
* "unknown") — never a raw Node/git error: enumerateRepo turns E_REMOTE_* into a problem
|
|
1509
|
+
* Any other failure is E_REMOTE_UNREADABLE (reason "timeout" for the timeout kill, "killed"
|
|
1510
|
+
* for any other signal exit, else "unknown") — never a raw Node/git error: enumerateRepo turns E_REMOTE_* into a problem
|
|
474
1511
|
* row and would otherwise abort the whole discovery on one unexplained listing (L4). */
|
|
475
|
-
async function lsTree(repo, spec, { flags = [], path, ref, commit } = {}) {
|
|
1512
|
+
async function lsTree(repo, spec, { flags = [], path, ref, commit, maxBuffer = 64 * 1024 * 1024 } = {}) {
|
|
476
1513
|
try {
|
|
477
1514
|
const args = ["ls-tree", "-l", "-z", ...flags, spec];
|
|
478
1515
|
if (path !== undefined) args.push("--", ...[].concat(path));
|
|
479
|
-
const out = await repo.local(args, { maxBuffer
|
|
1516
|
+
const out = await repo.local(args, { maxBuffer });
|
|
480
1517
|
return parseLsTree(out.stdout);
|
|
481
1518
|
} catch (error) {
|
|
482
1519
|
if (typeof error?.code === "string" && error.code.startsWith("E_")) throw error; // already an oats error
|
|
483
1520
|
const text = stderrText(error).toLowerCase();
|
|
484
1521
|
if (/not a tree object|not a valid object name|does not exist|bad object|fatal: not a tree|path .* does not exist|exists on disk, but not in/.test(text)) return null;
|
|
485
|
-
const
|
|
486
|
-
const
|
|
487
|
-
|
|
1522
|
+
const killed = !error?.timedOut && !error?.overflowed && typeof error?.signal === "string" && error.signal !== "";
|
|
1523
|
+
const reason = error?.timedOut ? "timeout" : killed ? "killed" : "unknown";
|
|
1524
|
+
const why = error?.overflowed ? "listing exceeded the output budget" : killed ? `git was killed (signal ${error.signal})` : (text.trim().split("\n")[0] || error?.code || error?.message || "git ls-tree failed");
|
|
1525
|
+
throw fail("E_REMOTE_UNREADABLE", `cannot list ${spec}${path !== undefined ? ` -- ${path}` : ""} in ${ref?.key ?? repo.dir} (${reason}: ${why})`, { url: ref?.url ?? null, key: ref?.key ?? null, reason, commit: commit ?? null, spec, path: path ?? null, cause: error?.code ?? null, overflowed: error?.overflowed === true, ...(killed ? { signal: error.signal } : {}) });
|
|
488
1526
|
}
|
|
489
1527
|
}
|
|
490
1528
|
|
|
1529
|
+
/** A session's recursive listing of <commit> (one `ls-tree -r -t -l -z` per cache repo and commit):
|
|
1530
|
+
* → Promise<{ list, pos } | null>, null without a session or when the listing failed (the output budget,
|
|
1531
|
+
* a timeout, anything) — every caller then falls back to today's per-path listing, so the index can
|
|
1532
|
+
* never raise an error per-path reading would not. `list` is in git's order: an entry, then (for a
|
|
1533
|
+
* tree) everything below it, contiguously. `pos` maps a path to its FIRST entry. */
|
|
1534
|
+
function treeIndex(repo, commit, ref) {
|
|
1535
|
+
const session = repo.session;
|
|
1536
|
+
if (!session) return null;
|
|
1537
|
+
const k = `${repo.dir}\0${commit}`;
|
|
1538
|
+
let index = session.trees.get(k);
|
|
1539
|
+
if (!index) {
|
|
1540
|
+
index = lsTree(repo, commit, { flags: ["-r", "-t"], ref, commit, maxBuffer: session.treeIndexBudget }).then((list) => {
|
|
1541
|
+
if (!list) return null;
|
|
1542
|
+
const pos = new Map();
|
|
1543
|
+
list.forEach((e, i) => { if (!pos.has(e.path)) pos.set(e.path, i); });
|
|
1544
|
+
return { list, pos };
|
|
1545
|
+
}, () => null);
|
|
1546
|
+
session.trees.set(k, index);
|
|
1547
|
+
}
|
|
1548
|
+
return index;
|
|
1549
|
+
}
|
|
1550
|
+
/** The index entries below the tree at `rel` ("" = the root), paths relative to it — what
|
|
1551
|
+
* `ls-tree -r -t <commit>:<rel>` lists, in the same order. */
|
|
1552
|
+
function indexDescendants(index, rel) {
|
|
1553
|
+
if (!rel) return index.list;
|
|
1554
|
+
const i = index.pos.get(rel);
|
|
1555
|
+
if (i === undefined) return [];
|
|
1556
|
+
const prefix = `${rel}/`, out = [];
|
|
1557
|
+
for (let j = i + 1; j < index.list.length && index.list[j].path.startsWith(prefix); j++) out.push({ ...index.list[j], path: index.list[j].path.slice(prefix.length) });
|
|
1558
|
+
return out;
|
|
1559
|
+
}
|
|
1560
|
+
|
|
491
1561
|
/** Return the single ls-tree entry for <commit>:<path>, or null when absent. */
|
|
492
1562
|
async function entryAt(repo, commit, path, ref) {
|
|
1563
|
+
const index = await treeIndex(repo, commit, ref);
|
|
1564
|
+
if (index) { const i = index.pos.get(path); return i === undefined ? null : index.list[i]; }
|
|
493
1565
|
const entries = await lsTree(repo, commit, { path, ref, commit });
|
|
494
1566
|
if (!entries) return null;
|
|
495
1567
|
return entries.find((e) => e.path === path) ?? null;
|
|
496
1568
|
}
|
|
497
1569
|
|
|
1570
|
+
/** End our own, still-running child gracefully (SIGTERM, then SIGKILL after the grace; never a pid a failed
|
|
1571
|
+
* spawn left at 0: lib/process-group.mjs). */
|
|
1572
|
+
function killChild(child) {
|
|
1573
|
+
if (child.exitCode === null && child.signalCode === null) terminateGroup(child);
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
/**
|
|
1577
|
+
* A session's `git cat-file --batch` for one cache repo: read(oid, budget) → Promise<Buffer>. Spawned
|
|
1578
|
+
* like repo.local (same `-C <dir> -c gc.auto=0`, cwd, helper-free gitEnv()); unref'd while idle, so it
|
|
1579
|
+
* never keeps the process alive. A header over `budget` rejects that request with `{ oversize }` and its
|
|
1580
|
+
* body is skipped, never buffered; a non-blob answers as `cat-file blob` would ("bad file"). `missing`,
|
|
1581
|
+
* the child dying (or never starting: ENOENT), or no answer within GIT_TIMEOUT_MS rejects EVERY pending
|
|
1582
|
+
* request with a git-shaped error (classifyRemoteFailure reads it as today: timeout → "timeout") and
|
|
1583
|
+
* ends the child; the next read starts a new one.
|
|
1584
|
+
*/
|
|
1585
|
+
function openBatch(dir, timeoutMs = GIT_TIMEOUT_MS) {
|
|
1586
|
+
// Detached, as its own process group: killChild's group kill also ends anything git started.
|
|
1587
|
+
const child = watchGroup(spawn("git", ["-C", dir, "-c", "gc.auto=0", "cat-file", "--batch"], { cwd: dir, env: gitEnv(), detached: true, stdio: ["pipe", "pipe", "pipe"] }));
|
|
1588
|
+
liveChildren.add(child);
|
|
1589
|
+
child.once("close", () => liveChildren.delete(child));
|
|
1590
|
+
const queue = []; // { resolve, reject, budget, size? }
|
|
1591
|
+
let chunks = [], length = 0, skip = 0, stderr = "", timer = null, dead = null, exited = null;
|
|
1592
|
+
const exitedPromise = new Promise((r) => { exited = r; });
|
|
1593
|
+
const handles = [child, child.stdin, child.stdout, child.stderr];
|
|
1594
|
+
const idle = () => { for (const h of handles) h?.unref?.(); };
|
|
1595
|
+
const busy = () => { for (const h of handles) h?.ref?.(); };
|
|
1596
|
+
const arm = () => {
|
|
1597
|
+
clearTimeout(timer); timer = null;
|
|
1598
|
+
if (!queue.length) { idle(); return; }
|
|
1599
|
+
timer = setTimeout(() => die(Object.assign(new Error(`git cat-file --batch: no answer within ${timeoutMs} ms`), { killed: true, signal: "SIGKILL", timedOut: true, stderr: Buffer.from(stderr) })), timeoutMs);
|
|
1600
|
+
};
|
|
1601
|
+
const die = (error) => {
|
|
1602
|
+
if (dead) return;
|
|
1603
|
+
dead = error; reader.dead = true;
|
|
1604
|
+
clearTimeout(timer); timer = null;
|
|
1605
|
+
killChild(child);
|
|
1606
|
+
while (queue.length) queue.shift().reject(error);
|
|
1607
|
+
idle();
|
|
1608
|
+
};
|
|
1609
|
+
const take = (n) => {
|
|
1610
|
+
const out = Buffer.allocUnsafe(n);
|
|
1611
|
+
let off = 0;
|
|
1612
|
+
while (off < n) {
|
|
1613
|
+
const c = chunks[0], k = Math.min(c.length, n - off);
|
|
1614
|
+
c.copy(out, off, 0, k); off += k;
|
|
1615
|
+
if (k === c.length) chunks.shift(); else chunks[0] = c.subarray(k);
|
|
1616
|
+
}
|
|
1617
|
+
length -= n;
|
|
1618
|
+
return out;
|
|
1619
|
+
};
|
|
1620
|
+
const drop = (n) => {
|
|
1621
|
+
length -= n;
|
|
1622
|
+
while (n > 0) { const c = chunks[0]; if (c.length <= n) { n -= c.length; chunks.shift(); } else { chunks[0] = c.subarray(n); n = 0; } }
|
|
1623
|
+
};
|
|
1624
|
+
const lineEnd = () => { let off = 0; for (const c of chunks) { const i = c.indexOf(10); if (i >= 0) return off + i; off += c.length; } return -1; };
|
|
1625
|
+
const pump = () => {
|
|
1626
|
+
for (;;) {
|
|
1627
|
+
if (skip > 0) { const n = Math.min(skip, length); drop(n); skip -= n; if (skip > 0) return; }
|
|
1628
|
+
if (!length) return;
|
|
1629
|
+
const head = queue[0];
|
|
1630
|
+
if (!head) { die(Object.assign(new Error("git cat-file --batch: unexpected output"), { stderr: Buffer.from(stderr) })); return; }
|
|
1631
|
+
if (head.size === undefined) {
|
|
1632
|
+
const nl = lineEnd();
|
|
1633
|
+
if (nl < 0) { if (length > 4096) die(Object.assign(new Error("git cat-file --batch: malformed header"), { stderr: Buffer.from(stderr) })); return; }
|
|
1634
|
+
const line = take(nl + 1).toString("utf8").slice(0, -1);
|
|
1635
|
+
const [name, type, sizeText, ...rest] = line.split(" ");
|
|
1636
|
+
if (type === "missing" || type === undefined) { die(Object.assign(new Error(line), { code: 128, stderr: Buffer.from(`fatal: Not a valid object name ${name}\n`), missing: type === "missing" })); return; }
|
|
1637
|
+
const size = Number(sizeText);
|
|
1638
|
+
if (rest.length || !Number.isSafeInteger(size) || size < 0) { die(Object.assign(new Error(`git cat-file --batch: malformed header ${JSON.stringify(line)}`), { stderr: Buffer.from(stderr) })); return; }
|
|
1639
|
+
if (type !== "blob") { queue.shift().reject(Object.assign(new Error(`${name} is a ${type}`), { code: 128, stderr: Buffer.from(`fatal: git cat-file ${name}: bad file\n`) })); skip = size + 1; arm(); continue; }
|
|
1640
|
+
if (size > head.budget) { queue.shift().reject(Object.assign(new Error(`${name} is ${size} bytes`), { oversize: size })); skip = size + 1; arm(); continue; }
|
|
1641
|
+
head.size = size;
|
|
1642
|
+
}
|
|
1643
|
+
if (length < head.size + 1) return;
|
|
1644
|
+
const body = take(head.size);
|
|
1645
|
+
drop(1); // the LF after every body
|
|
1646
|
+
queue.shift().resolve(body);
|
|
1647
|
+
arm();
|
|
1648
|
+
}
|
|
1649
|
+
};
|
|
1650
|
+
child.stdout.on("data", (chunk) => { chunks.push(chunk); length += chunk.length; pump(); });
|
|
1651
|
+
child.stderr.on("data", (chunk) => { if (stderr.length < 65536) stderr += chunk.toString("utf8"); });
|
|
1652
|
+
child.stdin.on("error", () => { /* EPIPE after the child died: 'close' reports it */ });
|
|
1653
|
+
child.on("error", (error) => { die(error); exited(); }); // ENOENT and friends: never a hang
|
|
1654
|
+
child.on("close", (code, signal) => { die(Object.assign(new Error(`git cat-file --batch exited (${signal ?? code})`), { code, signal, stderr: Buffer.from(stderr) })); exited(); });
|
|
1655
|
+
idle();
|
|
1656
|
+
const reader = {
|
|
1657
|
+
dead: false,
|
|
1658
|
+
read(oid, budget) {
|
|
1659
|
+
if (dead) return Promise.reject(dead);
|
|
1660
|
+
return new Promise((resolvePromise, reject) => {
|
|
1661
|
+
queue.push({ resolve: resolvePromise, reject, budget });
|
|
1662
|
+
busy();
|
|
1663
|
+
if (queue.length === 1) arm();
|
|
1664
|
+
child.stdin.write(`${oid}\n`);
|
|
1665
|
+
});
|
|
1666
|
+
},
|
|
1667
|
+
/** End stdin; wait (bounded) for the child to go; kill it if it lingers. */
|
|
1668
|
+
async close() {
|
|
1669
|
+
reader.dead = true; // never handed out again; git still answers what it was already asked
|
|
1670
|
+
if (!dead) { try { child.stdin.end(); } catch { /* already closed */ } }
|
|
1671
|
+
if (child.exitCode !== null || child.signalCode !== null || child.pid === undefined) return;
|
|
1672
|
+
const t = setTimeout(() => killChild(child), 2000);
|
|
1673
|
+
await exitedPromise;
|
|
1674
|
+
clearTimeout(t);
|
|
1675
|
+
},
|
|
1676
|
+
/** End it now (process exit, eviction): dead at once, so nothing is ever queued on it again. */
|
|
1677
|
+
kill() { try { child.stdin.destroy(); } catch { /* nothing */ } die(Object.assign(new Error("git cat-file --batch was ended"), { stderr: Buffer.from(stderr) })); },
|
|
1678
|
+
};
|
|
1679
|
+
return reader;
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
/** The session's batch reader for this cache repo (least recently used idle ones closed beyond BATCH_LIMIT);
|
|
1683
|
+
* null without a session, after close(), or with an injected exec (tests keep per-blob reads). */
|
|
1684
|
+
function catFileBatch(repo) {
|
|
1685
|
+
const session = repo.session;
|
|
1686
|
+
if (!session || session.closed || repo.exec !== runGit) return null;
|
|
1687
|
+
let reader = session.batches.get(repo.dir);
|
|
1688
|
+
if (reader && !reader.dead) { session.batches.delete(repo.dir); session.batches.set(repo.dir, reader); return reader; }
|
|
1689
|
+
reader = openBatch(repo.dir, session.batchTimeoutMs);
|
|
1690
|
+
session.batches.set(repo.dir, reader);
|
|
1691
|
+
if (session.batches.size > BATCH_LIMIT) {
|
|
1692
|
+
for (const [dir, other] of session.batches) {
|
|
1693
|
+
if (session.batches.size <= BATCH_LIMIT) break;
|
|
1694
|
+
if (other === reader) continue;
|
|
1695
|
+
session.batches.delete(dir);
|
|
1696
|
+
session.retiring.add(other); // git answers what it was already asked, then ends on EOF
|
|
1697
|
+
other.close().finally(() => session.retiring.delete(other));
|
|
1698
|
+
}
|
|
1699
|
+
}
|
|
1700
|
+
return reader;
|
|
1701
|
+
}
|
|
1702
|
+
|
|
498
1703
|
/**
|
|
499
1704
|
* → { bytes, size } | E_REMOTE_UNREADABLE | E_REMOTE_PATH_MISSING { path } | E_REMOTE_FILE_OVERSIZE { path, size, budget }
|
|
500
1705
|
* A symlink at <path> is refused (E_REMOTE_TREE_UNSAFE { path, why: "symlink" }); a directory → E_REMOTE_PATH_MISSING.
|
|
@@ -507,7 +1712,19 @@ export async function readRemoteFile(refText, commitArg, path, options = {}) {
|
|
|
507
1712
|
const entry = await entryAt(repo, commit, rel, ref);
|
|
508
1713
|
if (!entry || entry.type !== "blob") throw fail("E_REMOTE_PATH_MISSING", `${rel} is not a file in ${ref.key}@${commit.slice(0, 12)}`, { path: rel, key: ref.key, commit });
|
|
509
1714
|
if (entry.mode === "120000") throw fail("E_REMOTE_TREE_UNSAFE", `${rel} is a symlink`, { path: rel, why: "symlink", key: ref.key, commit });
|
|
510
|
-
|
|
1715
|
+
const oversize = (size) => fail("E_REMOTE_FILE_OVERSIZE", `${rel} is ${size} bytes (budget ${FILE_BUDGET})`, { path: rel, size, budget: FILE_BUDGET, key: ref.key, commit });
|
|
1716
|
+
await ensureBlobs(repo, ref, commit, [entry]);
|
|
1717
|
+
if (entry.size > FILE_BUDGET) throw oversize(entry.size);
|
|
1718
|
+
const batch = catFileBatch(repo);
|
|
1719
|
+
if (batch) {
|
|
1720
|
+
let bytes;
|
|
1721
|
+
try { bytes = await batch.read(entry.oid, FILE_BUDGET); }
|
|
1722
|
+
catch (error) {
|
|
1723
|
+
if (error?.oversize !== undefined) throw oversize(error.oversize);
|
|
1724
|
+
throw unreadable(ref, error, { commit, path: rel });
|
|
1725
|
+
}
|
|
1726
|
+
return { bytes, size: bytes.length };
|
|
1727
|
+
}
|
|
511
1728
|
let out;
|
|
512
1729
|
try { out = await repo.local(["cat-file", "blob", entry.oid], { maxBuffer: FILE_BUDGET + 1024 }); }
|
|
513
1730
|
catch (error) { throw unreadable(ref, error, { commit, path: rel }); }
|
|
@@ -521,9 +1738,19 @@ export async function remoteTreeOids(refText, commitArg, dirs, options = {}) {
|
|
|
521
1738
|
const ref = parseRepoRef(refText, options);
|
|
522
1739
|
requireCommit(commitArg);
|
|
523
1740
|
const rels = dirs.map((dir) => normalizeTreePath(dir, { allowRoot: false }));
|
|
524
|
-
|
|
525
|
-
const
|
|
526
|
-
|
|
1741
|
+
// Pure at the commit: a session keeps the answer in the parsed cache (as [dir, oid] pairs).
|
|
1742
|
+
const pairs = await memoAtCommit(ref, commitArg, `tree-oids\0${rels.join("\0")}`, async () => {
|
|
1743
|
+
const { repo, commit } = await ensureCommit(ref, commitArg, options);
|
|
1744
|
+
// One listing of nested paths (`a` beside `a/b`) recurses into `a` and never shows it: only the
|
|
1745
|
+
// per-path listing reproduces that, so nested requests keep it.
|
|
1746
|
+
const requested = new Set(rels);
|
|
1747
|
+
const nested = rels.some((rel) => rel.split("/").slice(0, -1).some((_, i, parts) => requested.has(parts.slice(0, i + 1).join("/"))));
|
|
1748
|
+
const index = rels.length && !nested ? await treeIndex(repo, commit, ref) : null;
|
|
1749
|
+
if (index) return rels.map((rel) => { const i = index.pos.get(rel); return i !== undefined && index.list[i].type === "tree" ? index.list[i].oid : null; });
|
|
1750
|
+
const entries = rels.length ? (await lsTree(repo, commit, { path: rels, ref, commit })) ?? [] : [];
|
|
1751
|
+
return rels.map((rel) => entries.find((e) => e.path === rel && e.type === "tree")?.oid ?? null);
|
|
1752
|
+
}, options);
|
|
1753
|
+
return new Map(dirs.map((dir, i) => [dir, pairs[i]]));
|
|
527
1754
|
}
|
|
528
1755
|
|
|
529
1756
|
/** A browsable URL of a repo at a commit (feature desktop-facts): the file's page with `path`, else the tree.
|
|
@@ -535,7 +1762,8 @@ export function browseUrl(key, commit, path = null) {
|
|
|
535
1762
|
}
|
|
536
1763
|
|
|
537
1764
|
/**
|
|
538
|
-
* → [{ path, type: "blob"|"tree"
|
|
1765
|
+
* → [{ path, type: "blob"|"tree"|"symlink" }] relative to <dir>, depth-bounded (depth 1 = direct children).
|
|
1766
|
+
* A listing never needs a blob, so it never fetches one (and so carries no sizes).
|
|
539
1767
|
* Missing dir → []. Symlinks are reported as type "symlink" so callers can skip them.
|
|
540
1768
|
*/
|
|
541
1769
|
export async function listRemoteTree(refText, commitArg, dir, { depth = 2, ...options } = {}) {
|
|
@@ -549,7 +1777,8 @@ export async function listRemoteTree(refText, commitArg, dir, { depth = 2, ...op
|
|
|
549
1777
|
const entry = await entryAt(repo, commit, rel, ref);
|
|
550
1778
|
if (!entry || entry.type !== "tree") return [];
|
|
551
1779
|
}
|
|
552
|
-
const
|
|
1780
|
+
const index = await treeIndex(repo, commit, ref);
|
|
1781
|
+
const entries = index ? indexDescendants(index, rel) : await lsTree(repo, spec, { flags: ["-r", "-t"], ref, commit });
|
|
553
1782
|
if (!entries) return [];
|
|
554
1783
|
const result = [];
|
|
555
1784
|
for (const e of entries) {
|
|
@@ -560,9 +1789,7 @@ export async function listRemoteTree(refText, commitArg, dir, { depth = 2, ...op
|
|
|
560
1789
|
assertSafeEntryPath(e.path, ref, commit, rel ? `${rel}/${e.path}` : e.path);
|
|
561
1790
|
if (e.type === "commit") continue; // submodule gitlinks are not part of the observable tree
|
|
562
1791
|
const type = e.mode === "120000" ? "symlink" : e.type;
|
|
563
|
-
|
|
564
|
-
if (e.type === "blob") row.size = e.size;
|
|
565
|
-
result.push(row);
|
|
1792
|
+
result.push({ path: e.path, type });
|
|
566
1793
|
}
|
|
567
1794
|
result.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
568
1795
|
return result;
|
|
@@ -665,8 +1892,8 @@ export async function fetchRemoteTree(refText, commitArg, dir, destDir, options
|
|
|
665
1892
|
}
|
|
666
1893
|
const entries = await lsTree(repo, spec, { flags: ["-r", "-t"], ref, commit });
|
|
667
1894
|
if (!entries) throw fail("E_REMOTE_PATH_MISSING", `${rel || "."} is missing in ${ref.key}@${commit.slice(0, 12)}`, { path: rel, key: ref.key, commit });
|
|
668
|
-
// Inspect everything BEFORE writing anything: names, modes, types,
|
|
669
|
-
|
|
1895
|
+
// Inspect everything BEFORE writing anything: names, modes, types, collisions, then (once the blobs
|
|
1896
|
+
// are present) sizes.
|
|
670
1897
|
const blobs = [], trees = [], links = [];
|
|
671
1898
|
// `allowSymlinks(relPath)` (opt-in, narrow): a symlink whose TARGET is relative
|
|
672
1899
|
// and stays inside the fetched subtree may be materialized as a symlink — the
|
|
@@ -683,12 +1910,17 @@ export async function fetchRemoteTree(refText, commitArg, dir, destDir, options
|
|
|
683
1910
|
if (e.type === "commit") throw fail("E_REMOTE_TREE_UNSAFE", `${shown} is a submodule`, { path: shown, why: "device", key: ref.key, commit });
|
|
684
1911
|
if (e.type === "tree") { trees.push(e); continue; }
|
|
685
1912
|
if (e.type !== "blob" || !/^100(644|755)$/.test(e.mode)) throw fail("E_REMOTE_TREE_UNSAFE", `${shown} has unsupported mode ${e.mode}`, { path: shown, why: "device", key: ref.key, commit });
|
|
686
|
-
total += e.size;
|
|
687
|
-
if (total > TREE_BUDGET) throw fail("E_REMOTE_TREE_UNSAFE", `${rel || "."} exceeds ${TREE_BUDGET} bytes`, { path: rel || ".", why: "oversize", size: total, budget: TREE_BUDGET, key: ref.key, commit });
|
|
688
1913
|
blobs.push(e);
|
|
689
1914
|
}
|
|
690
|
-
blobs.sort(byPath);
|
|
691
1915
|
assertNoCollisions(entries, ref, commit, rel);
|
|
1916
|
+
// Every blob of the subtree (files and link targets) in ONE fetch, then the budget on their real sizes.
|
|
1917
|
+
await ensureBlobs(repo, ref, commit, [...blobs, ...links]);
|
|
1918
|
+
let total = 0;
|
|
1919
|
+
for (const b of blobs) {
|
|
1920
|
+
total += b.size;
|
|
1921
|
+
if (total > TREE_BUDGET) throw fail("E_REMOTE_TREE_UNSAFE", `${rel || "."} exceeds ${TREE_BUDGET} bytes`, { path: rel || ".", why: "oversize", size: total, budget: TREE_BUDGET, key: ref.key, commit });
|
|
1922
|
+
}
|
|
1923
|
+
blobs.sort(byPath);
|
|
692
1924
|
mkdirSync(dirname(dest), { recursive: true });
|
|
693
1925
|
const staging = join(dirname(dest), `.${dest.split(sep).pop()}.oats-staging-${process.pid}-${Date.now().toString(36)}`);
|
|
694
1926
|
try {
|
|
@@ -699,25 +1931,41 @@ export async function fetchRemoteTree(refText, commitArg, dir, destDir, options
|
|
|
699
1931
|
// the git tree listed first.
|
|
700
1932
|
const items = [...blobs.map((b) => ({ kind: "blob", e: b })), ...links.map((l) => ({ kind: "link", e: l }))].sort((a, b) => byPath(a.e, b.e));
|
|
701
1933
|
const d = createDigest();
|
|
1934
|
+
// Blob bytes (all present now: ensureBlobs above): through the session's `cat-file --batch` reader when
|
|
1935
|
+
// there is one (no process per blob), else one `cat-file blob` each. A reader opened before ensureBlobs
|
|
1936
|
+
// fetched a blob still finds it (git re-reads its packs on a miss); one still missing answers `missing`
|
|
1937
|
+
// (GIT_NO_LAZY_FETCH), never a fetch. A batch read may take what is left of TREE_BUDGET, never
|
|
1938
|
+
// FILE_BUDGET (a single blob over 4 MiB still copies); a symlink target keeps its 64 KiB cap. A blob the
|
|
1939
|
+
// reader answers `missing`, or over its budget, is read once more alone: that read's error is this path's
|
|
1940
|
+
// error without a session, classified as it always was (the reader's own words are not git's). Any other
|
|
1941
|
+
// failure, the reader dying or its session closing included, is E_REMOTE_UNREADABLE, as a per-blob read's is.
|
|
1942
|
+
const LINK_BUDGET = 64 * 1024;
|
|
1943
|
+
let read = 0;
|
|
1944
|
+
const readBlob = async (oid, path, budget, maxBuffer) => {
|
|
1945
|
+
const batch = catFileBatch(repo);
|
|
1946
|
+
try {
|
|
1947
|
+
if (batch) {
|
|
1948
|
+
try { return await batch.read(oid, budget); }
|
|
1949
|
+
catch (error) { if (error?.oversize === undefined && error?.missing !== true) throw error; }
|
|
1950
|
+
}
|
|
1951
|
+
return (await repo.local(["cat-file", "blob", oid], { maxBuffer })).stdout;
|
|
1952
|
+
} catch (error) { throw unreadable(ref, error, { commit, path }); }
|
|
1953
|
+
};
|
|
702
1954
|
for (const { kind, e } of items) {
|
|
703
1955
|
if (kind === "blob") {
|
|
704
1956
|
const b = e;
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
catch (error) { throw unreadable(ref, error, { commit, path: b.path }); }
|
|
1957
|
+
const bytes = await readBlob(b.oid, b.path, TREE_BUDGET - read, TREE_BUDGET + 1024);
|
|
1958
|
+
read += bytes.length;
|
|
708
1959
|
const mode = b.mode === "100755" ? 0o755 : 0o644;
|
|
709
1960
|
const target = join(staging, ...b.path.split("/"));
|
|
710
1961
|
mkdirSync(dirname(target), { recursive: true, mode: 0o755 });
|
|
711
1962
|
const fd = openSync(target, "wx", mode);
|
|
712
|
-
try { writeSync(fd,
|
|
713
|
-
d.add(b.path, mode,
|
|
1963
|
+
try { writeSync(fd, bytes); } finally { closeSync(fd); }
|
|
1964
|
+
d.add(b.path, mode, bytes);
|
|
714
1965
|
continue;
|
|
715
1966
|
}
|
|
716
1967
|
const l = e;
|
|
717
|
-
|
|
718
|
-
try { out = await repo.local(["cat-file", "blob", l.oid], { maxBuffer: 64 * 1024 }); }
|
|
719
|
-
catch (error) { throw unreadable(ref, error, { commit, path: l.path }); }
|
|
720
|
-
const linkTarget = out.stdout.toString("utf8").trim();
|
|
1968
|
+
const linkTarget = (await readBlob(l.oid, l.path, LINK_BUDGET, LINK_BUDGET)).toString("utf8").trim();
|
|
721
1969
|
const from = dirname(l.path === "" ? "x" : l.path);
|
|
722
1970
|
const resolvedRel = posixNormalize(from === "." ? linkTarget : `${from}/${linkTarget}`);
|
|
723
1971
|
if (isAbsolute(linkTarget) || linkTarget.includes("\0") || resolvedRel.startsWith("../") || resolvedRel === "..") {
|