@awebai/oats 0.32.0 → 0.34.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 +128 -20
- package/docs/capabilities.md +22 -0
- package/docs/configuration.md +2 -2
- package/docs/design/2026-09-23-workspace-module-contracts.md +3 -2
- package/docs/desktop-cli-api.md +231 -7
- package/docs/first-team.md +20 -2
- package/docs/implementation.md +78 -2
- package/docs/integrations.md +1 -1
- package/docs/official-catalog.md +2 -2
- package/docs/packages.md +2 -2
- package/docs/release-notes/v0.33.0.md +174 -0
- package/docs/release-notes/v0.34.0.md +63 -0
- package/docs/souls-and-instances.md +64 -0
- package/docs/workspaces.md +1 -1
- package/lib/capability-show.mjs +208 -0
- package/lib/core.mjs +89 -17
- package/lib/harness-trust.mjs +139 -0
- package/lib/instance-inspect.mjs +16 -2
- package/lib/packages.mjs +1 -1
- package/lib/process-group.mjs +54 -0
- package/lib/remote.mjs +721 -112
- package/lib/resolve.mjs +56 -14
- 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;
|
|
@@ -98,7 +117,15 @@
|
|
|
98
117
|
* unref'd while idle and ended by session.close();
|
|
99
118
|
* - `maxAge` (seconds): > 0 lets observeRemote reuse a recorded head observation
|
|
100
119
|
* (the observation store below) no older than that;
|
|
101
|
-
* - the head observations used, for the `observation` output block
|
|
120
|
+
* - the head observations used, for the `observation` output block;
|
|
121
|
+
* - `deadline` (DEADLINE; the deployment reads `oats status` and `oats workspace status` only, READ_REMOTE_BUDGET_MS
|
|
122
|
+
* after their session starts): every remote step gets what is left of it, not its own default — each git call's
|
|
123
|
+
* timeout (ls-remote, fetch, ls-tree, the batch readers' answers, every cache plumbing call, the git version
|
|
124
|
+
* probe), the cache write lock's wait, the half-initialised cache's wait and the lock-race backoff. A step the deadline ends is a
|
|
125
|
+
* `timeout` (git's group killed as any timeout kills it; a step not started yet starts no git), so a member
|
|
126
|
+
* not read by then degrades as any unreadable member does (a peel or version the deadline ended is a timeout,
|
|
127
|
+
* never a missing commit or an older git). A wait the deadline cuts never changes what it judges: past it no
|
|
128
|
+
* lock is taken or reclaimed, and a cache directory is not taken for a crash's leftover.
|
|
102
129
|
*
|
|
103
130
|
* PARSED CACHE (`memoAtCommit`, session only): a value derived only from the bytes at
|
|
104
131
|
* (repo key, commit) by a given kernel never changes, so it is kept on disk under
|
|
@@ -119,17 +146,28 @@ import { execFileSync, spawn } from "node:child_process";
|
|
|
119
146
|
import { createHash, randomBytes } from "node:crypto";
|
|
120
147
|
import { setMaxListeners } from "node:events";
|
|
121
148
|
import {
|
|
122
|
-
closeSync, existsSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, rmSync,
|
|
123
|
-
statSync, utimesSync, writeFileSync, writeSync, symlinkSync, readlinkSync } from "node:fs";
|
|
149
|
+
closeSync, existsSync, linkSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync,
|
|
150
|
+
statSync, unlinkSync, utimesSync, writeFileSync, writeSync, symlinkSync, readlinkSync } from "node:fs";
|
|
124
151
|
import { homedir } from "node:os";
|
|
125
|
-
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
152
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
126
153
|
import { fileURLToPath } from "node:url";
|
|
127
154
|
import { oatsError } from "./errors.mjs";
|
|
128
|
-
import { killGroup } from "./process-group.mjs";
|
|
155
|
+
import { killGroup, signalGroup, terminateGroup, watchGroup } from "./process-group.mjs";
|
|
129
156
|
|
|
130
157
|
export const FILE_BUDGET = 4 * 1024 * 1024; // readRemoteFile: 4 MiB per file
|
|
131
158
|
export const TREE_BUDGET = 64 * 1024 * 1024; // fetchRemoteTree: 64 MiB per subtree
|
|
159
|
+
/** A commit is fetched with its trees and the blobs up to this size; a read fetches any larger blob it needs. */
|
|
160
|
+
export const SMALL_BLOB_LIMIT = 64 * 1024;
|
|
161
|
+
/** The oldest git that keeps a partial cache honest: GIT_NO_LAZY_FETCH arrived in 2.45; an older git fetches a
|
|
162
|
+
* missing blob on its own, or dies trying. With an older git every cache fetches whole trees. */
|
|
163
|
+
export const PARTIAL_FETCH_GIT = [2, 45];
|
|
132
164
|
export const GIT_TIMEOUT_MS = 30_000;
|
|
165
|
+
/** The fetch of a commit into the cache: the first one transfers the commit's whole tree, which for a
|
|
166
|
+
* large workspace host takes far longer than GIT_TIMEOUT_MS (awebai/oats#362). */
|
|
167
|
+
export const GIT_FETCH_TIMEOUT_MS = 600_000;
|
|
168
|
+
/** The remote budget of a deployment read (`oats status`, `oats workspace status`): its session's `deadline` is
|
|
169
|
+
* this long after the session starts, so the command answers inside a caller's own limit (the Desktop's 30 s). */
|
|
170
|
+
export const READ_REMOTE_BUDGET_MS = 12_000;
|
|
133
171
|
/** A session's tree index: the output budget of one `ls-tree -r -t -l -z <commit>`. */
|
|
134
172
|
export const TREE_INDEX_BUDGET = 64 * 1024 * 1024;
|
|
135
173
|
/** `--max-age` bounds (seconds). */
|
|
@@ -254,23 +292,50 @@ function gitEnv() {
|
|
|
254
292
|
GIT_ASKPASS: "/usr/bin/false",
|
|
255
293
|
GIT_SSH_COMMAND: sshCommand(),
|
|
256
294
|
GIT_LITERAL_PATHSPECS: "1",
|
|
295
|
+
// A cache holds a commit's trees and its small blobs (SMALL_BLOB_LIMIT); a read fetches what else it needs
|
|
296
|
+
// first (ensureBlobs). git must never fetch a missing blob on its own, one round trip at a time.
|
|
297
|
+
GIT_NO_LAZY_FETCH: "1",
|
|
257
298
|
};
|
|
258
299
|
}
|
|
259
300
|
|
|
301
|
+
/** Every git child this module started that has not exited yet (runGit's and the batch readers'): what
|
|
302
|
+
* reapOnExit still has to end when the process exits. */
|
|
303
|
+
const liveChildren = new Set();
|
|
304
|
+
/** The exit path (`process.on("exit")`, ReadSession.closeNow) cannot wait for a timer, so the graceful kill
|
|
305
|
+
* is done synchronously and bounded: every live git group gets SIGTERM, the process blocks for at most
|
|
306
|
+
* EXIT_GRACE_MS (git removes its lock files on SIGTERM in far less), then SIGKILL ends what is left. A
|
|
307
|
+
* child's pid cannot be reused meanwhile: Node has not reaped it. */
|
|
308
|
+
const EXIT_GRACE_MS = 200;
|
|
309
|
+
function reapOnExit() {
|
|
310
|
+
// A child stays tracked until its stdio closes: a leader that exited while a descendant still holds its pipes
|
|
311
|
+
// is still here, and its group still gets both signals.
|
|
312
|
+
const children = [...liveChildren];
|
|
313
|
+
if (!children.length) return;
|
|
314
|
+
for (const child of children) signalGroup(child, "SIGTERM");
|
|
315
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, EXIT_GRACE_MS);
|
|
316
|
+
for (const child of children) signalGroup(child, "SIGKILL");
|
|
317
|
+
liveChildren.clear();
|
|
318
|
+
}
|
|
319
|
+
|
|
260
320
|
/** Default exec dependency: runs `git <args>`; resolves { stdout, stderr } (Buffers);
|
|
261
321
|
* rejects with { code, signal, killed, stderr, stdout, timedOut, overflowed }.
|
|
262
|
-
* timedOut — the `timeout` kill (killed=true, signal
|
|
322
|
+
* timedOut — the `timeout` kill (killed=true, signal SIGTERM, no error.code); set by
|
|
323
|
+
* our own timer only: a git that some other process killed (an OOM kill)
|
|
324
|
+
* exits with its signal and timedOut false;
|
|
263
325
|
* overflowed — stdout or stderr exceeded `maxBuffer` (the child is killed too, and
|
|
264
326
|
* error.code = ERR_CHILD_PROCESS_STDIO_MAXBUFFER): NOT a timeout.
|
|
265
327
|
* An abort of `signal` rejects with an AbortError (code ABORT_ERR). git runs detached,
|
|
266
328
|
* as its own process group: a timeout, an overflow or an abort kills the group, so
|
|
267
329
|
* git's ssh or remote helper dies with it (a kill of git alone left them running until
|
|
268
|
-
* their connection ended). Injectable via options.exec. */
|
|
269
|
-
export function runGit(args, { cwd, maxBuffer = 16 * 1024 * 1024, timeout = GIT_TIMEOUT_MS, signal } = {}) {
|
|
330
|
+
* their connection ended). `input` is written to git's stdin, which is then closed. Injectable via options.exec. */
|
|
331
|
+
export function runGit(args, { cwd, maxBuffer = 16 * 1024 * 1024, timeout = GIT_TIMEOUT_MS, input, signal } = {}) {
|
|
270
332
|
return new Promise((resolvePromise, reject) => {
|
|
271
333
|
if (signal?.aborted) { reject(abortError(signal)); return; }
|
|
272
|
-
// stdin is a pipe
|
|
273
|
-
const child = spawn("git", args, { cwd, env: gitEnv(), detached: true, stdio: ["pipe", "pipe", "pipe"] });
|
|
334
|
+
// stdin is a pipe: written with `input` and closed, else never written, as execFile gave it.
|
|
335
|
+
const child = watchGroup(spawn("git", args, { cwd, env: gitEnv(), detached: true, stdio: ["pipe", "pipe", "pipe"] }));
|
|
336
|
+
liveChildren.add(child);
|
|
337
|
+
child.once("close", () => liveChildren.delete(child));
|
|
338
|
+
if (input !== undefined) { child.stdin.on("error", () => { /* EPIPE after the child died: 'close' reports it */ }); child.stdin.end(input); }
|
|
274
339
|
const out = [], err = [], size = { out: 0, err: 0 };
|
|
275
340
|
let done = false;
|
|
276
341
|
const output = () => ({ stdout: Buffer.concat(out), stderr: Buffer.concat(err) });
|
|
@@ -283,20 +348,25 @@ export function runGit(args, { cwd, maxBuffer = 16 * 1024 * 1024, timeout = GIT_
|
|
|
283
348
|
};
|
|
284
349
|
const stop = (error) => {
|
|
285
350
|
if (done) return;
|
|
286
|
-
|
|
287
|
-
|
|
351
|
+
// SIGTERM first (git removes its lock files), SIGKILL after the grace. The error says which kill this
|
|
352
|
+
// is (`timedOut` for our own timer), never the signal that finally ended git.
|
|
353
|
+
terminateGroup(child);
|
|
354
|
+
// stdout and stderr are drained, never destroyed: 'close' must still wait for a descendant holding them, so
|
|
355
|
+
// terminateGroup's SIGKILL reaches it (and only it). Unref'd, they never keep the command alive.
|
|
356
|
+
child.stdin.destroy();
|
|
357
|
+
for (const s of [child.stdout, child.stderr]) { s.removeAllListeners("data"); s.resume(); s.unref?.(); }
|
|
288
358
|
settle(Object.assign(error, output()));
|
|
289
359
|
};
|
|
290
360
|
const onAbort = () => stop(abortError(signal));
|
|
291
361
|
const timer = timeout > 0 ? setTimeout(() => stop(Object.assign(new Error(`Command failed: git ${args.join(" ")} (timed out after ${timeout} ms)`),
|
|
292
|
-
{ code: null, killed: true, signal: "
|
|
362
|
+
{ code: null, killed: true, signal: "SIGTERM", timedOut: true, overflowed: false })), timeout) : null;
|
|
293
363
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
294
364
|
const collect = (chunks, key) => (chunk) => {
|
|
295
365
|
const room = maxBuffer - size[key];
|
|
296
366
|
if (chunk.length > room) {
|
|
297
367
|
chunks.push(chunk.subarray(0, Math.max(0, room))); size[key] = maxBuffer;
|
|
298
368
|
stop(Object.assign(new RangeError(`${key === "out" ? "stdout" : "stderr"} maxBuffer length exceeded`),
|
|
299
|
-
{ code: "ERR_CHILD_PROCESS_STDIO_MAXBUFFER", killed: true, signal: "
|
|
369
|
+
{ code: "ERR_CHILD_PROCESS_STDIO_MAXBUFFER", killed: true, signal: "SIGTERM", overflowed: true, timedOut: false }));
|
|
300
370
|
return;
|
|
301
371
|
}
|
|
302
372
|
chunks.push(chunk); size[key] += chunk.length;
|
|
@@ -308,7 +378,7 @@ export function runGit(args, { cwd, maxBuffer = 16 * 1024 * 1024, timeout = GIT_
|
|
|
308
378
|
if (code === 0) { settle(null, output()); return; }
|
|
309
379
|
const { stdout, stderr } = output();
|
|
310
380
|
settle(Object.assign(new Error(`Command failed: git ${args.join(" ")}\n${stderr.toString("utf8")}`),
|
|
311
|
-
{ code, signal: exitSignal, killed: false, stdout, stderr, overflowed: false, timedOut:
|
|
381
|
+
{ code, signal: exitSignal, killed: false, stdout, stderr, overflowed: false, timedOut: false }));
|
|
312
382
|
});
|
|
313
383
|
});
|
|
314
384
|
}
|
|
@@ -317,17 +387,38 @@ function abortError(signal) {
|
|
|
317
387
|
return Object.assign(new Error("The operation was aborted", { cause: signal?.reason }), { name: "AbortError", code: "ABORT_ERR", overflowed: false, timedOut: false });
|
|
318
388
|
}
|
|
319
389
|
|
|
390
|
+
/** A git call the session's deadline left no time for: shaped as runGit's own timeout (classified "timeout"). */
|
|
391
|
+
function deadlineError(args) {
|
|
392
|
+
return Object.assign(new Error(`Command failed: git ${args.join(" ")} (the read budget ended before it ran)`),
|
|
393
|
+
{ code: null, killed: false, signal: null, timedOut: true, overflowed: false, stdout: Buffer.alloc(0), stderr: Buffer.alloc(0) });
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** `exec(args, opts)` under the session's deadline: its timeout (opts.timeout, else GIT_TIMEOUT_MS) cut to what is
|
|
397
|
+
* left, and no git started once nothing is. Without a deadline, exactly `exec(args, opts)`. */
|
|
398
|
+
function sessionExec(exec, session, args, opts = {}) {
|
|
399
|
+
if (session?.deadline == null) return exec(args, opts);
|
|
400
|
+
const timeout = session.remaining(opts.timeout ?? GIT_TIMEOUT_MS);
|
|
401
|
+
if (timeout <= 0) return Promise.reject(deadlineError(args));
|
|
402
|
+
return exec(args, { ...opts, timeout });
|
|
403
|
+
}
|
|
404
|
+
|
|
320
405
|
function stderrText(error) {
|
|
321
406
|
const s = error?.stderr;
|
|
322
407
|
return Buffer.isBuffer(s) ? s.toString("utf8") : typeof s === "string" ? s : String(error?.message ?? "");
|
|
323
408
|
}
|
|
324
409
|
|
|
325
|
-
/** Classify a failed network git call into the contract's
|
|
410
|
+
/** Classify a failed network git call into the contract's reasons. A `maxBuffer`
|
|
326
411
|
* overflow is never a timeout (the child is killed in both cases; only the timeout kill
|
|
327
|
-
* counts) — it falls through to the stderr text, else "network".
|
|
412
|
+
* counts) — it falls through to the stderr text, else "network". A timeout is our own
|
|
413
|
+
* timer's kill (`timedOut`), whatever signal it sent. A git lock still held by another
|
|
414
|
+
* process (a lost race that outlived retryLockRace, or a dead process's stale lock) is
|
|
415
|
+
* "cache": a local cache write failed, nothing about the remote. Any other signal exit is
|
|
416
|
+
* "killed" (the system killed git, e.g. out of memory). */
|
|
328
417
|
export function classifyRemoteFailure(error) {
|
|
329
418
|
if (error?.overflowed === true || error?.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") return classifyText(error) ?? "network";
|
|
330
|
-
if (error?.timedOut
|
|
419
|
+
if (error?.timedOut) return "timeout";
|
|
420
|
+
if (isLockRace(error) || isLocalWriteFailure(error)) return "cache";
|
|
421
|
+
if (typeof error?.signal === "string" && error.signal) return "killed";
|
|
331
422
|
return classifyText(error) ?? "network";
|
|
332
423
|
}
|
|
333
424
|
|
|
@@ -341,8 +432,44 @@ function classifyText(error) {
|
|
|
341
432
|
}
|
|
342
433
|
|
|
343
434
|
function unreadable(ref, error, extra = {}) {
|
|
344
|
-
const reason = classifyRemoteFailure(error);
|
|
345
|
-
|
|
435
|
+
const reason = classifyRemoteFailure(error), killed = reason === "killed";
|
|
436
|
+
if (reason === "cache") return cacheFailure(ref, error, extra);
|
|
437
|
+
return fail("E_REMOTE_UNREADABLE", `cannot read remote ${ref.url} (${reason})${killed ? `: git was killed (signal ${error.signal})` : ""}`,
|
|
438
|
+
{ url: ref.url, key: ref.key, reason, ...(killed ? { signal: error.signal } : {}), ...extra });
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** The lock file a git lock error names (`Unable to create '<path>.lock'`, `could not lock config file <path>`),
|
|
442
|
+
* else null. */
|
|
443
|
+
function lockFileOf(error, cwd = null) {
|
|
444
|
+
const text = stderrText(error);
|
|
445
|
+
const m = /Unable to create '([^']+\.lock)'/.exec(text) ?? /could not lock config file ([^:\s]+)/.exec(text);
|
|
446
|
+
if (!m) return null;
|
|
447
|
+
const file = m[1].endsWith(".lock") ? m[1] : `${m[1]}.lock`;
|
|
448
|
+
// git names a lock relative to its cwd (`could not lock config file config`), or as `<dir>/./refs/…`.
|
|
449
|
+
return isAbsolute(file) ? resolve(file) : cwd ? resolve(cwd, file) : file;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** git could not write a file of the local cache repo (a FETCH_HEAD, a pack, an object it cannot open or
|
|
453
|
+
* create: permissions, a directory in the way, a read-only or full disk): a fact about this machine, never
|
|
454
|
+
* about the remote. Only local file wording counts: "Permission denied (publickey)" stays auth. */
|
|
455
|
+
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));
|
|
456
|
+
|
|
457
|
+
/** A write to the local cache repo failed — reason "cache" (a timeout stays "timeout"), never "network" — with a
|
|
458
|
+
* message that says what to do: a lock still held names its file, safe to remove once no oats process runs;
|
|
459
|
+
* any other failure carries git's own words. `extra.stage` says which write (init, pin, config, fetch). */
|
|
460
|
+
function cacheFailure(ref, error, extra = {}) {
|
|
461
|
+
const url = redactUrl(ref.url);
|
|
462
|
+
if (error?.timedOut) return fail("E_REMOTE_UNREADABLE", `cannot write the cache of ${url} (timeout)`, { url: ref.url, key: ref.key, reason: "timeout", ...extra });
|
|
463
|
+
const where = extra.cacheDir ? ` at ${extra.cacheDir}` : "";
|
|
464
|
+
const lock = isLockRace(error) ? lockFileOf(error, extra.cacheDir) : null;
|
|
465
|
+
if (isLockRace(error)) {
|
|
466
|
+
const held = lock ? `${lock} is still held` : "a git lock in it is still held";
|
|
467
|
+
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`,
|
|
468
|
+
{ url: ref.url, key: ref.key, reason: "cache", ...extra, ...(lock ? { lock } : {}) });
|
|
469
|
+
}
|
|
470
|
+
const why = stderrText(error).trim().split("\n").filter(Boolean).join(" ") || error?.message || error?.code || "git failed";
|
|
471
|
+
const check = isLocalWriteFailure(error) ? `; check that ${extra.cacheDir ?? "the cache"} is writable by this user and its disk has room` : "";
|
|
472
|
+
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 });
|
|
346
473
|
}
|
|
347
474
|
|
|
348
475
|
// ---------------------------------------------------------------------------
|
|
@@ -366,7 +493,188 @@ async function withCacheLock(dir, fn) {
|
|
|
366
493
|
}
|
|
367
494
|
|
|
368
495
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
369
|
-
|
|
496
|
+
/** A write another process won (a git lock it holds, or the cache's `shallow` file it rewrote mid-fetch): a race
|
|
497
|
+
* on the local cache, retried, and never a fact about the remote. */
|
|
498
|
+
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));
|
|
499
|
+
/** How long a git lock is waited for before it is judged. Every oats writer of this kernel holds the cache's
|
|
500
|
+
* write lock (withCacheWriteLock), so a git lock met under it belongs to an older kernel's live write, or to a
|
|
501
|
+
* git that was killed: the wait covers the first, short writes; a fetch's lock is judged by its age. */
|
|
502
|
+
const LOCK_WAIT_MS = 3_000;
|
|
503
|
+
/** Run `fn` (one git call that writes the cache repo), again after a lost on-disk `.lock` race, until
|
|
504
|
+
* LOCK_WAIT_MS has passed (backoff up to 500 ms, jittered); a timeout or any other failure is never retried.
|
|
505
|
+
* A session's deadline cuts the backoff: the next call then ends as its timeout (sessionExec). */
|
|
506
|
+
async function retryLockRace(fn, session = null) {
|
|
507
|
+
const deadline = Date.now() + LOCK_WAIT_MS;
|
|
508
|
+
for (let attempt = 0; ; attempt++) {
|
|
509
|
+
try { return await fn(); }
|
|
510
|
+
catch (error) {
|
|
511
|
+
if (!isLockRace(error) || error.timedOut) throw error;
|
|
512
|
+
if (Date.now() >= deadline) throw error;
|
|
513
|
+
const backoff = Math.min(500, 50 * 2 ** attempt) * (0.5 + Math.random());
|
|
514
|
+
await sleep(session ? session.remaining(backoff) : backoff);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// ---------------------------------------------------------------------------
|
|
520
|
+
// the cache write lock (cross-process)
|
|
521
|
+
// ---------------------------------------------------------------------------
|
|
522
|
+
|
|
523
|
+
/** How long a write waits for another live oats process writing the same cache: a whole fetch, and a margin. */
|
|
524
|
+
const CACHE_WRITE_WAIT_MS = GIT_FETCH_TIMEOUT_MS + 60_000;
|
|
525
|
+
/** A write lock with no readable owner (made by hand, or by a filesystem fault: ours is linked into place
|
|
526
|
+
* whole) is judged by its age instead. */
|
|
527
|
+
const UNREADABLE_LOCK_STALE_MS = 30_000;
|
|
528
|
+
/** How old a git `*.lock` must be before a write holding the cache write lock may remove it: older than the
|
|
529
|
+
* longest fetch any oats allows, so an older kernel's live fetch (it takes no write lock) is never broken. */
|
|
530
|
+
const GIT_LOCK_STALE_MS = GIT_FETCH_TIMEOUT_MS + 60_000;
|
|
531
|
+
|
|
532
|
+
function pidAlive(pid) { try { process.kill(pid, 0); return true; } catch (e) { return e.code === "EPERM"; } }
|
|
533
|
+
|
|
534
|
+
/** The write lock of the cache repo `dir`: `<cacheRoot>/.locks/<repo>.lock`, outside git's own namespace. */
|
|
535
|
+
const cacheWriteLockOf = (dir) => join(dirname(dir), ".locks", `${basename(dir)}.lock`);
|
|
536
|
+
|
|
537
|
+
/** The write lock as { owner, ino, mtimeMs } (owner null when it holds no readable { pid, token }), or null
|
|
538
|
+
* when it is gone. A symlink or anything but a regular file is an unreadable lock; a lock that cannot be read
|
|
539
|
+
* at all (a directory in its place, EACCES) is thrown: retrying cannot clear it. */
|
|
540
|
+
function readCacheWriteLock(path) {
|
|
541
|
+
let st;
|
|
542
|
+
try { st = lstatSync(path); } catch (error) { if (error.code === "ENOENT") return null; throw error; }
|
|
543
|
+
let owner = null;
|
|
544
|
+
if (st.isFile()) {
|
|
545
|
+
try { const o = JSON.parse(readFileSync(path, "utf8")); if (Number.isSafeInteger(o?.pid) && typeof o?.token === "string") owner = o; }
|
|
546
|
+
catch (error) { if (error.code === "ENOENT") return null; if (error.code && error.code !== "ENOENT" && !(error instanceof SyntaxError)) throw error; }
|
|
547
|
+
}
|
|
548
|
+
return { owner, ino: st.ino, mtimeMs: st.mtimeMs };
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Run `fn` holding the cache repo's cross-process write lock (docs: oats-kernel-expert decision
|
|
553
|
+
* record-lock-liveness-tradeoff): every oats write to a cache repo — init, config, fetch, pin — happens under
|
|
554
|
+
* it, so two processes never write one cache at once; reads take no lock. The lock is an exclusive-create file
|
|
555
|
+
* holding { pid, token, startedAt }, linked into place whole. A live holder is waited for, never stolen from,
|
|
556
|
+
* whatever its age; a dead holder's lock (or an unreadable one past UNREADABLE_LOCK_STALE_MS) is removed after
|
|
557
|
+
* checking it is still the same file. Every pass that does not acquire falls through one deadline check and
|
|
558
|
+
* one sleep. At the deadline: E_REMOTE_UNREADABLE { reason: "cache", stage, cacheDir, lock, holderPid }; at once
|
|
559
|
+
* when a reclaimer died holding the reclaim guard (reclaimCacheWriteLock), with details.guard.
|
|
560
|
+
* `fn({ waited })`: whether another process held the lock first (what it wrote is worth checking again).
|
|
561
|
+
*/
|
|
562
|
+
async function withCacheWriteLock(ref, dir, stage, session, fn) {
|
|
563
|
+
const signal = session?.signal;
|
|
564
|
+
const lock = cacheWriteLockOf(dir);
|
|
565
|
+
const owner = { pid: process.pid, token: randomBytes(12).toString("hex"), startedAt: new Date().toISOString() };
|
|
566
|
+
const url = redactUrl(ref.url);
|
|
567
|
+
const refuse = (message, extra = {}) => fail("E_REMOTE_UNREADABLE", `cannot write the cache of ${url} at ${dir} (cache, ${stage}): ${message}`,
|
|
568
|
+
{ url: ref.url, key: ref.key, reason: "cache", stage, cacheDir: dir, lock, ...extra });
|
|
569
|
+
try { mkdirSync(dirname(lock), { recursive: true, mode: 0o700 }); } catch (error) { throw refuse(`cannot create ${dirname(lock)} (${error.code ?? error.message})`); }
|
|
570
|
+
const deadline = Date.now() + (session?.cacheWriteWaitMs ?? CACHE_WRITE_WAIT_MS);
|
|
571
|
+
let held = null, waited = false;
|
|
572
|
+
// The session's deadline ends the wait before anything is taken or reclaimed: the read is a timeout, and the
|
|
573
|
+
// lock (live, stale or unreadable) stays exactly as it is.
|
|
574
|
+
const budgetEnded = () => {
|
|
575
|
+
let now = held;
|
|
576
|
+
try { now = readCacheWriteLock(lock); } catch { /* as last seen */ }
|
|
577
|
+
const holder = now?.owner ? `oats process ${now.owner.pid}` : "another process";
|
|
578
|
+
return fail("E_REMOTE_UNREADABLE", `cannot write the cache of ${url} at ${dir} (timeout, ${stage}): the read budget ended ${now ? `while ${holder} held its write lock ${lock}` : `before it took its write lock ${lock}`}`,
|
|
579
|
+
{ url: ref.url, key: ref.key, reason: "timeout", stage, cacheDir: dir, lock, ...(now?.owner ? { holderPid: now.owner.pid } : {}) });
|
|
580
|
+
};
|
|
581
|
+
for (let attempt = 0; ; attempt++) {
|
|
582
|
+
if (signal?.aborted) throw unreadable(ref, abortError(signal), { cacheDir: dir, stage });
|
|
583
|
+
if (session?.expired()) throw budgetEnded();
|
|
584
|
+
const tmp = `${lock}.${owner.pid}.${owner.token}`;
|
|
585
|
+
try {
|
|
586
|
+
writeFileSync(tmp, JSON.stringify(owner) + "\n", { mode: 0o600 });
|
|
587
|
+
try { linkSync(tmp, lock); break; } catch (error) { if (error.code !== "EEXIST") throw error; }
|
|
588
|
+
} catch (error) { throw refuse(`cannot take its write lock ${lock} (${error.code ?? error.message})`); }
|
|
589
|
+
finally { try { unlinkSync(tmp); } catch { /* never written */ } }
|
|
590
|
+
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`); }
|
|
591
|
+
if (held && isStaleLock(held) && !session?.expired()) {
|
|
592
|
+
const abandoned = reclaimCacheWriteLock(lock, held, owner);
|
|
593
|
+
if (abandoned) {
|
|
594
|
+
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`,
|
|
595
|
+
{ guard: abandoned.guard, ...(held.owner ? { holderPid: held.owner.pid } : {}) });
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
if (Date.now() >= deadline) {
|
|
599
|
+
throw refuse(held?.owner ? `oats process ${held.owner.pid} has been writing it since ${held.owner.startedAt} (lock ${lock}); try again once it finishes`
|
|
600
|
+
: `its write lock ${lock} is held; it is safe to remove once no oats process is running`, held?.owner ? { holderPid: held.owner.pid } : {});
|
|
601
|
+
}
|
|
602
|
+
if (session?.expired()) throw budgetEnded();
|
|
603
|
+
waited = true;
|
|
604
|
+
const backoff = Math.min(250, 25 * 2 ** attempt) * (0.5 + Math.random());
|
|
605
|
+
await sleep(session ? Math.max(1, session.remaining(backoff)) : backoff);
|
|
606
|
+
}
|
|
607
|
+
try { return await fn({ waited }); }
|
|
608
|
+
finally {
|
|
609
|
+
// Release only our own lock: one reclaimed from us meanwhile belongs to its new holder.
|
|
610
|
+
try { if (readCacheWriteLock(lock)?.owner?.token === owner.token) unlinkSync(lock); } catch { /* gone */ }
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/** Whether a lock record (readCacheWriteLock) is provably abandoned: its owner's pid is gone, or it has no
|
|
615
|
+
* readable owner and is older than UNREADABLE_LOCK_STALE_MS. A live owner is never stale, whatever its age. */
|
|
616
|
+
const isStaleLock = (held) => (held.owner ? !pidAlive(held.owner.pid) : Date.now() - held.mtimeMs > UNREADABLE_LOCK_STALE_MS);
|
|
617
|
+
/** The same record still at `path`: the same owner token, or (no readable owner) the same file. */
|
|
618
|
+
const sameLock = (now, held) => (held.owner ? now?.owner?.token === held.owner.token : now && !now.owner && now.ino === held.ino && now.mtimeMs === held.mtimeMs);
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* Remove the write lock `held`, proven stale — serialized among reclaimers by the guard `<lock>.reclaim`
|
|
622
|
+
* (exclusive create, holding { pid, token }). Under the guard the lock is read again and removed only if it is
|
|
623
|
+
* still that stale record. Only a reclaimer ever removes another process's lock, and reclaimers hold the guard,
|
|
624
|
+
* so the lock cannot change between that check and the unlink: a reclaimer that paused cannot delete the lock a
|
|
625
|
+
* live process took meanwhile. A live guard is waited for (the caller's next pass). A guard whose holder died
|
|
626
|
+
* (or unreadable and old) is never removed: removing it would race exactly as removing the lock does, with no
|
|
627
|
+
* guard left to serialize that, so two reclaimers could each delete the other's live guard, then lock. It is
|
|
628
|
+
* returned instead, { guard, pid }, and the caller refuses naming it: a reclaimer dying inside its guard is a
|
|
629
|
+
* microseconds window, and a human removing the file once no oats process runs is the safe recovery. Any other
|
|
630
|
+
* failure leaves the lock to the caller's next pass and its deadline. → null, or the abandoned guard.
|
|
631
|
+
*/
|
|
632
|
+
function reclaimCacheWriteLock(lock, held, me) {
|
|
633
|
+
const guard = `${lock}.reclaim`;
|
|
634
|
+
try { writeFileSync(guard, JSON.stringify({ pid: me.pid, token: me.token }) + "\n", { flag: "wx", mode: 0o600 }); }
|
|
635
|
+
catch (error) {
|
|
636
|
+
if (error?.code !== "EEXIST") return null;
|
|
637
|
+
try { const g = readCacheWriteLock(guard); if (g && isStaleLock(g)) return { guard, pid: g.owner?.pid ?? null }; } catch { /* next pass */ }
|
|
638
|
+
return null;
|
|
639
|
+
}
|
|
640
|
+
try {
|
|
641
|
+
const now = readCacheWriteLock(lock);
|
|
642
|
+
if (now && sameLock(now, held) && isStaleLock(now)) unlinkSync(lock);
|
|
643
|
+
} catch { /* next pass */ }
|
|
644
|
+
finally { try { if (readCacheWriteLock(guard)?.owner?.token === me.token) unlinkSync(guard); } catch { /* gone */ } }
|
|
645
|
+
return null;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/** Remove the git lock file `lockFile` that a cache write met, if — and only if — it provably belongs to no live
|
|
649
|
+
* writer: it is inside this cache repo, ends in `.lock`, is a regular file (never a symlink), and is older than
|
|
650
|
+
* GIT_LOCK_STALE_MS. Called only while holding the cache write lock (no oats writer of this kernel can hold it).
|
|
651
|
+
* → whether it was removed. */
|
|
652
|
+
function reclaimGitLock(repo, lockFile) {
|
|
653
|
+
if (typeof lockFile !== "string" || !lockFile.endsWith(".lock") || !isAbsolute(lockFile)) return false;
|
|
654
|
+
let root, parent;
|
|
655
|
+
try { root = realpathSync(repo.dir); parent = realpathSync(dirname(lockFile)); } catch { return false; }
|
|
656
|
+
if (parent !== root && !parent.startsWith(root + sep)) return false;
|
|
657
|
+
const file = join(parent, basename(lockFile));
|
|
658
|
+
let st;
|
|
659
|
+
try { st = lstatSync(file); } catch { return false; }
|
|
660
|
+
if (!st.isFile() || Date.now() - st.mtimeMs <= GIT_LOCK_STALE_MS) return false;
|
|
661
|
+
try { unlinkSync(file); return true; } catch { return false; }
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/** One git call that writes the cache repo (config, fetch, update-ref), made while holding its write lock:
|
|
665
|
+
* a lost lock race (an older kernel's write) is retried briefly; a git lock that outlives that and is
|
|
666
|
+
* provably stale (reclaimGitLock: a git killed mid-write) is removed — said once as a session notice — and
|
|
667
|
+
* the call made once more. Anything else is thrown as git raised it. */
|
|
668
|
+
async function cacheGit(repo, args, opts = {}) {
|
|
669
|
+
const run = () => repo.local(args, opts);
|
|
670
|
+
try { return await retryLockRace(run, repo.session); }
|
|
671
|
+
catch (error) {
|
|
672
|
+
const lockFile = isLockRace(error) ? lockFileOf(error, repo.dir) : null;
|
|
673
|
+
if (!lockFile || !reclaimGitLock(repo, lockFile)) throw error;
|
|
674
|
+
repo.session?.notices.push(`removed a stale git lock ${lockFile} (left by a git process that was killed mid-write)`);
|
|
675
|
+
return await run();
|
|
676
|
+
}
|
|
677
|
+
}
|
|
370
678
|
|
|
371
679
|
const sha256 = (text) => createHash("sha256").update(text).digest("hex");
|
|
372
680
|
const cacheRootOf = (options) => options.cacheDir ?? defaultCacheRoot();
|
|
@@ -378,23 +686,62 @@ function redactUrl(url) {
|
|
|
378
686
|
}
|
|
379
687
|
|
|
380
688
|
function repoHandle(dir, exec, session) {
|
|
381
|
-
const local = (args, opts = {}) => exec
|
|
689
|
+
const local = (args, opts = {}) => sessionExec(exec, session, ["-C", dir, "-c", "gc.auto=0", ...args], { cwd: dir, ...(session ? { signal: session.signal } : {}), ...opts });
|
|
382
690
|
return { dir, exec, local, session };
|
|
383
691
|
}
|
|
384
692
|
|
|
385
693
|
async function cacheRepo(ref, options) {
|
|
386
694
|
const exec = options.exec ?? runGit;
|
|
387
695
|
const dir = cacheDirOf(cacheRootOf(options), ref);
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
696
|
+
const session = sessionOf(options);
|
|
697
|
+
if (!existsSync(join(dir, "HEAD"))) await initCacheRepo(ref, dir, exec, session);
|
|
698
|
+
return repoHandle(dir, exec, session);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/** How long a cache directory without HEAD is waited for (an older kernel initialising it in place) before it
|
|
702
|
+
* is taken for a crash's leftover and replaced. */
|
|
703
|
+
const HALF_INIT_WAIT_MS = 2_000;
|
|
704
|
+
|
|
705
|
+
/**
|
|
706
|
+
* Create the cache repo at `dir` in one atomic step: `git init --bare` into a private sibling directory, then
|
|
707
|
+
* rename it into place, so `dir` never exists half-initialised. Processes racing to create one cache all
|
|
708
|
+
* succeed: a loser's rename finds the winner's complete repo and drops its own copy. A directory at `dir`
|
|
709
|
+
* without HEAD (a crash's leftover, or an older kernel initialising in place) is waited for (bounded), then
|
|
710
|
+
* moved aside and replaced; the cache is disposable. Any other failure is E_REMOTE_UNREADABLE
|
|
711
|
+
* { reason: "cache", stage: "init", cacheDir }.
|
|
712
|
+
*/
|
|
713
|
+
async function initCacheRepo(ref, dir, exec, session = null) {
|
|
714
|
+
const failInit = (error) => cacheFailure(ref, error, { cacheDir: dir, stage: "init" });
|
|
715
|
+
const tmp = `${dir}.init-${process.pid}-${randomBytes(4).toString("hex")}`;
|
|
716
|
+
try {
|
|
717
|
+
try { mkdirSync(tmp, { recursive: true, mode: 0o700 }); } catch (error) { throw failInit(error); }
|
|
718
|
+
try { await sessionExec(exec, session, ["init", "-q", "--bare", tmp]); } catch (error) { throw failInit(error); }
|
|
719
|
+
try { writeFileSync(join(tmp, "oats-remote.json"), JSON.stringify({ key: ref.key, url: redactUrl(ref.url) }, null, 2) + "\n"); } catch {}
|
|
720
|
+
const deadline = Date.now() + HALF_INIT_WAIT_MS;
|
|
721
|
+
for (;;) {
|
|
722
|
+
try { renameSync(tmp, dir); return; } // replaces nothing, or an EMPTY directory
|
|
723
|
+
catch (error) {
|
|
724
|
+
if (error?.code !== "ENOTEMPTY" && error?.code !== "EEXIST") throw failInit(error);
|
|
725
|
+
}
|
|
726
|
+
if (existsSync(join(dir, "HEAD"))) return; // another process created it first
|
|
727
|
+
if (Date.now() < deadline) {
|
|
728
|
+
// The session's deadline never shortens the judgment: a directory waited for less than the whole wait is
|
|
729
|
+
// never taken for a leftover. The read ends as a timeout instead.
|
|
730
|
+
if (session?.expired()) throw failInit(deadlineError(["init", "-q", "--bare", dir]));
|
|
731
|
+
await sleep(Math.max(1, Math.min(50, session ? session.remaining(50) : 50)));
|
|
732
|
+
continue;
|
|
733
|
+
}
|
|
734
|
+
// No HEAD after the wait: a leftover. Move it aside (atomic; a process racing to do the same loses
|
|
735
|
+
// with ENOENT, which is fine) and take its place.
|
|
736
|
+
const aside = `${dir}.stale-${process.pid}-${randomBytes(4).toString("hex")}`;
|
|
737
|
+
try { renameSync(dir, aside); } catch (error) { if (error?.code !== "ENOENT") throw failInit(error); }
|
|
738
|
+
// A repo another process renamed in between the check and the move is complete: put it back.
|
|
739
|
+
if (existsSync(join(aside, "HEAD"))) { try { renameSync(aside, dir); continue; } catch { /* taken again meanwhile */ } }
|
|
740
|
+
rmSync(aside, { recursive: true, force: true });
|
|
394
741
|
}
|
|
395
|
-
|
|
742
|
+
} finally {
|
|
743
|
+
rmSync(tmp, { recursive: true, force: true }); // gone already after a successful rename
|
|
396
744
|
}
|
|
397
|
-
return repoHandle(dir, exec, sessionOf(options));
|
|
398
745
|
}
|
|
399
746
|
|
|
400
747
|
function pinRef(oid) { return `refs/oats/commits/${oid}`; }
|
|
@@ -404,14 +751,19 @@ function pinRef(oid) { return `refs/oats/commits/${oid}`; }
|
|
|
404
751
|
// ---------------------------------------------------------------------------
|
|
405
752
|
|
|
406
753
|
class ReadSession {
|
|
407
|
-
constructor({ maxAge = 0, now = Date.now, fingerprint = null, treeIndexBudget = TREE_INDEX_BUDGET, parsedLimits = null, batchTimeoutMs = GIT_TIMEOUT_MS
|
|
754
|
+
constructor({ maxAge = 0, now = Date.now, fingerprint = null, treeIndexBudget = TREE_INDEX_BUDGET, parsedLimits = null, batchTimeoutMs = GIT_TIMEOUT_MS,
|
|
755
|
+
cacheWriteWaitMs = CACHE_WRITE_WAIT_MS, fetchTimeoutMs = GIT_FETCH_TIMEOUT_MS, deadline = null } = {}) {
|
|
408
756
|
if (!Number.isInteger(maxAge) || maxAge < 0 || maxAge > MAX_AGE_LIMIT) throw new TypeError(`maxAge must be an integer from 0 to ${MAX_AGE_LIMIT}`);
|
|
757
|
+
if (deadline !== null && !Number.isFinite(deadline)) throw new TypeError("deadline must be null or a time in Date.now() milliseconds");
|
|
409
758
|
this.maxAge = maxAge;
|
|
410
759
|
this.now = now;
|
|
411
760
|
this.startedAt = new Date(now()).toISOString();
|
|
412
761
|
this.fingerprint = fingerprint; // tests inject one; else the kernel's own (kernelFingerprint)
|
|
413
762
|
this.treeIndexBudget = treeIndexBudget;
|
|
414
763
|
this.batchTimeoutMs = batchTimeoutMs; // tests shorten it; a batch answer is otherwise waited for as long as any git call
|
|
764
|
+
this.cacheWriteWaitMs = cacheWriteWaitMs; // tests shorten it: how long a write waits for another live writer of its cache
|
|
765
|
+
this.fetchTimeoutMs = fetchTimeoutMs; // tests shorten it: a fetch's own timeout
|
|
766
|
+
this.deadline = deadline; // null, or when every remote step of the command must be over (remaining())
|
|
415
767
|
this.parsedLimits = parsedLimits; // tests inject small prune bounds
|
|
416
768
|
this.observations = new Map(); // memo key → Promise<head observation>
|
|
417
769
|
this.used = new Map(); // memo key → { observedAt, reused }: the heads this command used
|
|
@@ -426,6 +778,7 @@ class ReadSession {
|
|
|
426
778
|
this.aborter = new AbortController(); // close()/closeNow() kill every git child still running for this session
|
|
427
779
|
this.signal = this.aborter.signal;
|
|
428
780
|
setMaxListeners(0, this.signal); // every running git child listens (up to 16 at once): no leak warning
|
|
781
|
+
this.notices = []; // what the command should tell the operator once it ends (the CLI prints them)
|
|
429
782
|
this.pruned = false;
|
|
430
783
|
this.closed = false;
|
|
431
784
|
}
|
|
@@ -487,13 +840,20 @@ class ReadSession {
|
|
|
487
840
|
for (const b of [...this.batches.values(), ...this.retiring]) b.kill();
|
|
488
841
|
this.batches.clear();
|
|
489
842
|
this.retiring.clear();
|
|
843
|
+
reapOnExit(); // no timer runs on the exit path: SIGTERM, a bounded synchronous grace, then SIGKILL
|
|
490
844
|
}
|
|
845
|
+
/** What a wait or a git call of `ms` may take: `ms`, cut to what is left before the deadline (never below 0).
|
|
846
|
+
* Without a deadline, `ms` itself. */
|
|
847
|
+
remaining(ms) { return this.deadline === null ? ms : Math.max(0, Math.min(ms, this.deadline - Date.now())); }
|
|
848
|
+
/** Whether the deadline has passed: every remote step not over by then ends as a `timeout`. */
|
|
849
|
+
expired() { return this.remaining(Infinity) === 0; }
|
|
491
850
|
/** A session rides remoteOptions, which callers may serialise (memo keys): never its innards. */
|
|
492
851
|
toJSON() { return "[oats read session]"; }
|
|
493
852
|
}
|
|
494
853
|
|
|
495
|
-
/** One command's read session (see the module header). `maxAge` seconds (0 = observe live).
|
|
496
|
-
*
|
|
854
|
+
/** One command's read session (see the module header). `maxAge` seconds (0 = observe live). `deadline` (Date.now()
|
|
855
|
+
* milliseconds, or null): every remote step ends by then (the module header's DEADLINE). Test seams: `now`,
|
|
856
|
+
* `fingerprint`, `treeIndexBudget`, `parsedLimits`, `batchTimeoutMs`, `cacheWriteWaitMs`, `fetchTimeoutMs`. */
|
|
497
857
|
export function createReadSession(options = {}) { return new ReadSession(options); }
|
|
498
858
|
/** An observation the command no longer wants (its session closed, or its prefetch abandoned): never adopted
|
|
499
859
|
* by a caller that is still reading, so its shape only has to be a typed remote failure. */
|
|
@@ -562,7 +922,7 @@ function resolvedVersion(name) {
|
|
|
562
922
|
} catch { return "unresolved"; }
|
|
563
923
|
}
|
|
564
924
|
|
|
565
|
-
const TRANSIENT_REASONS = new Set(["timeout", "network", "auth", "unknown"]);
|
|
925
|
+
const TRANSIENT_REASONS = new Set(["timeout", "network", "auth", "unknown", "cache"]);
|
|
566
926
|
/** A value the parsed cache may keep: JSON-safe (its round trip is deepStrictEqual — plain or
|
|
567
927
|
* null-prototype objects, dense arrays, finite numbers other than -0, strings, booleans, null; no
|
|
568
928
|
* undefined, no cycle) and free of transient failures (any E_REMOTE_UNREADABLE, or a remote
|
|
@@ -782,11 +1142,11 @@ async function objectType(repo, oid) {
|
|
|
782
1142
|
}
|
|
783
1143
|
|
|
784
1144
|
/**
|
|
785
|
-
* Ensure <oid> (a full OID of a COMMIT, or of an annotated TAG that peels to one) and
|
|
786
|
-
*
|
|
787
|
-
* commit: a tag OID given as `at` is accepted, but the commit
|
|
788
|
-
* commit it points to (fix M4). The pin is on the peeled commit; a tag
|
|
789
|
-
* `refs/oats/tags/<oid>` pin so gc cannot break the chain either.
|
|
1145
|
+
* Ensure <oid> (a full OID of a COMMIT, or of an annotated TAG that peels to one), its trees and its
|
|
1146
|
+
* small blobs are present in the cache (larger blobs: ensureBlobs, when a read needs them). → { repo,
|
|
1147
|
+
* commit } where `commit` is the PEELED commit: a tag OID given as `at` is accepted, but the commit
|
|
1148
|
+
* recorded everywhere is the commit it points to (fix M4). The pin is on the peeled commit; a tag
|
|
1149
|
+
* object gets its own `refs/oats/tags/<oid>` pin so gc cannot break the chain either.
|
|
790
1150
|
*/
|
|
791
1151
|
async function ensureCommit(ref, oid, options) {
|
|
792
1152
|
const dir = cacheDirOf(cacheRootOf(options), ref);
|
|
@@ -797,30 +1157,215 @@ async function ensureCommit(ref, oid, options) {
|
|
|
797
1157
|
if (known) return { repo: repoHandle(dir, options.exec ?? runGit, session), commit: known };
|
|
798
1158
|
const remember = (commit) => { if (session) { session.peels.set(`${dir}\0${oid}`, commit); session.peels.set(`${dir}\0${commit}`, commit); } return commit; };
|
|
799
1159
|
return withCacheLock(dir, async () => {
|
|
800
|
-
|
|
801
|
-
|
|
1160
|
+
// Already present (the common case): a read, so no write lock.
|
|
1161
|
+
let peeled = false; // the object store was asked already, and said no
|
|
1162
|
+
const usable = async (repo) => {
|
|
1163
|
+
if (!existsSync(join(dir, "HEAD"))) return null;
|
|
1164
|
+
if (!keepsPartialCache(await readVersion(repo, ref)) && await fetchMode(repo) === "partial") return null;
|
|
1165
|
+
peeled = true;
|
|
1166
|
+
return peelCommit(repo, oid);
|
|
1167
|
+
};
|
|
1168
|
+
const present = await usable(repoHandle(dir, options.exec ?? runGit, session));
|
|
1169
|
+
if (present) return { repo: repoHandle(dir, options.exec ?? runGit, session), commit: remember(present) };
|
|
1170
|
+
// Every write below (init, config, fetch, pin) holds the cache's cross-process write lock; a process that
|
|
1171
|
+
// waited for it finds what the holder fetched.
|
|
1172
|
+
return withCacheWriteLock(ref, dir, "fetch", session, async ({ waited }) => {
|
|
1173
|
+
let repo = await cacheRepo(ref, options);
|
|
1174
|
+
const version = await readVersion(repo, ref);
|
|
1175
|
+
// A partial cache is only safe where git cannot fetch a missing blob on its own: an older git rebuilds it whole.
|
|
1176
|
+
if (!keepsPartialCache(version) && await fetchMode(repo) === "partial") repo = await rebuildWhole(repo, ref, options, version);
|
|
1177
|
+
const cached = peeled && !waited ? null : await peelCommit(repo, oid);
|
|
802
1178
|
if (cached) return { repo, commit: remember(cached) }; // pinned AND present AND (peels to) a commit
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
} catch (error) {
|
|
810
|
-
lastError = error;
|
|
811
|
-
if (!isLockRace(error) || error.timedOut) break;
|
|
812
|
-
await sleep(100 * (attempt + 1) ** 2);
|
|
813
|
-
}
|
|
814
|
-
}
|
|
815
|
-
if (lastError) throw unreadable(ref, lastError, { commit: oid });
|
|
1179
|
+
const mode = await fetchMode(repo)
|
|
1180
|
+
?? (keepsPartialCache(version) ? await startPartialFetches(repo, ref) : await recordFullFetches(repo, ref, olderGitNotice(version, ref)));
|
|
1181
|
+
const filter = mode === "partial" ? [`--filter=${PARTIAL_FILTER}`] : ["--no-filter"];
|
|
1182
|
+
const { stderr } = await fetchInto(repo, ref, ["fetch", "-q", "--depth", "1", "--no-tags", "--no-recurse-submodules", ...filter, "origin", oid], { what: oid, commit: oid });
|
|
1183
|
+
// A server without filters says so and sends the whole tree: nothing is lost, the cache remembers.
|
|
1184
|
+
if (mode === "partial" && /filtering not recognized by server/i.test(stderr.toString("utf8"))) await recordFullFetches(repo, ref);
|
|
816
1185
|
const commit = await peelCommit(repo, oid);
|
|
817
1186
|
if (!commit) {
|
|
1187
|
+
// A peel the session's deadline ended is the read's timeout, never a missing commit.
|
|
1188
|
+
if (session?.expired()) throw unreadable(ref, deadlineError(["rev-parse", `${oid}^{commit}`]), { commit: oid, cacheDir: repo.dir, stage: "fetch" });
|
|
818
1189
|
const type = await objectType(repo, oid);
|
|
819
1190
|
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 });
|
|
820
1191
|
}
|
|
821
|
-
await repo
|
|
822
|
-
if (commit !== oid) await repo
|
|
1192
|
+
await writePin(repo, ref, pinRef(commit), commit, commit);
|
|
1193
|
+
if (commit !== oid) await writePin(repo, ref, `refs/oats/tags/${oid}`, oid, commit);
|
|
823
1194
|
return { repo, commit: remember(commit) };
|
|
1195
|
+
});
|
|
1196
|
+
});
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
/** Pin `name` to `oid` in the cache repo. Two processes pinning one commit write the same value: a lost lock
|
|
1200
|
+
* race is retried, and a ref that already holds `oid` (another process's write) counts as written. */
|
|
1201
|
+
async function writePin(repo, ref, name, oid, commit) {
|
|
1202
|
+
try { await cacheGit(repo, ["update-ref", name, oid]); }
|
|
1203
|
+
catch (error) {
|
|
1204
|
+
let current = null;
|
|
1205
|
+
try { current = (await repo.local(["rev-parse", "--verify", "-q", name])).stdout.toString("utf8").trim(); } catch {}
|
|
1206
|
+
if (current === oid) return;
|
|
1207
|
+
throw cacheFailure(ref, error, { cacheDir: repo.dir, stage: "pin", commit });
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
/** The filter a partial fetch asks for: every blob up to SMALL_BLOB_LIMIT comes with the commit. */
|
|
1212
|
+
const PARTIAL_FILTER = `blob:limit=${SMALL_BLOB_LIMIT / 1024}k`;
|
|
1213
|
+
|
|
1214
|
+
/** How this cache fetches (its own `oats.fetch` config): "partial", "full" (its server cannot serve
|
|
1215
|
+
* partial fetches), or null before its first fetch. */
|
|
1216
|
+
async function fetchMode(repo) {
|
|
1217
|
+
try { return (await repo.local(["config", "--get", "oats.fetch"])).stdout.toString("utf8").trim() || null; } catch { return null; }
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
/** Make the cache a partial clone of the remote "origin". Its url is never written (it may carry
|
|
1221
|
+
* credentials): every fetch passes it with `-c remote.origin.url=` (fetchInto). */
|
|
1222
|
+
async function startPartialFetches(repo, ref) {
|
|
1223
|
+
for (const [key, value] of [["core.repositoryformatversion", "1"], ["extensions.partialclone", "origin"], ["remote.origin.promisor", "true"],
|
|
1224
|
+
["remote.origin.partialclonefilter", PARTIAL_FILTER], ["oats.fetch", "partial"]]) await writeConfig(repo, ref, key, value);
|
|
1225
|
+
return "partial";
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
/** One `git config <key> <value>` in the cache repo. Two processes starting one cache write the same values,
|
|
1229
|
+
* so a lost lock race is simply retried. */
|
|
1230
|
+
async function writeConfig(repo, ref, key, value) {
|
|
1231
|
+
try { await cacheGit(repo, ["config", key, value]); }
|
|
1232
|
+
catch (error) { throw unreadable(ref, error, { cacheDir: repo.dir, stage: "config" }); }
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
/** This cache fetches whole trees from now on (its server cannot serve partial fetches, or this git cannot keep
|
|
1236
|
+
* a partial cache): recorded, and said once (the session's notices; the CLI prints them when the command ends). */
|
|
1237
|
+
async function recordFullFetches(repo, ref, notice = `${redactUrl(ref.url)} does not serve partial fetches; OATS fetches whole trees from it`) {
|
|
1238
|
+
await writeConfig(repo, ref, "oats.fetch", "full");
|
|
1239
|
+
repo.session?.notices.push(notice);
|
|
1240
|
+
return "full";
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
const olderGitNotice = (version, ref) =>
|
|
1244
|
+
`git ${version?.text ?? "(unknown version)"} cannot keep a partial cache (it needs ${PARTIAL_FETCH_GIT.join(".")}); OATS fetches whole trees from ${redactUrl(ref.url)}`;
|
|
1245
|
+
|
|
1246
|
+
/** `git --version` of the git `exec` runs, asked once per exec: → { text: "2.54.0", major, minor } | null.
|
|
1247
|
+
* `opts` (a timeout, a signal) go to the call that asks. A call that timed out says nothing about this git:
|
|
1248
|
+
* it answers null to its waiters and is asked again next time. */
|
|
1249
|
+
const gitVersions = new WeakMap();
|
|
1250
|
+
export function gitVersion(exec = runGit, opts = undefined) {
|
|
1251
|
+
let version = gitVersions.get(exec);
|
|
1252
|
+
if (!version) {
|
|
1253
|
+
version = Promise.resolve().then(() => exec(["--version"], ...(opts ? [opts] : []))).then((out) => {
|
|
1254
|
+
const m = /git version ((\d+)\.(\d+)[^\s]*)/.exec(out.stdout.toString("utf8"));
|
|
1255
|
+
return m ? { text: m[1], major: Number(m[2]), minor: Number(m[3]) } : null;
|
|
1256
|
+
}, (error) => {
|
|
1257
|
+
if (error?.timedOut && gitVersions.get(exec) === version) gitVersions.delete(exec);
|
|
1258
|
+
return null;
|
|
1259
|
+
});
|
|
1260
|
+
gitVersions.set(exec, version);
|
|
1261
|
+
}
|
|
1262
|
+
return version;
|
|
1263
|
+
}
|
|
1264
|
+
/** gitVersion for a read of `ref` in `repo`'s session. Under a deadline the probe gets what is left of it, and
|
|
1265
|
+
* is waited for no longer (another caller may own the probe): a version the deadline left unknown is the
|
|
1266
|
+
* read's timeout, never taken for an older git (which would rebuild a partial cache). */
|
|
1267
|
+
async function readVersion(repo, ref) {
|
|
1268
|
+
const session = repo.session;
|
|
1269
|
+
if (session?.deadline == null) return gitVersion(repo.exec);
|
|
1270
|
+
const ended = () => unreadable(ref, deadlineError(["--version"]), { cacheDir: repo.dir, stage: "fetch" });
|
|
1271
|
+
const left = session.remaining(GIT_TIMEOUT_MS);
|
|
1272
|
+
if (left <= 0) throw ended();
|
|
1273
|
+
let timer;
|
|
1274
|
+
const late = new Promise((resolvePromise) => { timer = setTimeout(() => resolvePromise(ended), left); });
|
|
1275
|
+
try {
|
|
1276
|
+
const version = await Promise.race([gitVersion(repo.exec, { timeout: left, signal: session.signal }), late]);
|
|
1277
|
+
if (version === ended || (version === null && session.expired())) throw ended();
|
|
1278
|
+
return version;
|
|
1279
|
+
} finally { clearTimeout(timer); }
|
|
1280
|
+
}
|
|
1281
|
+
/** Whether this git keeps a partial cache honest (GIT_NO_LAZY_FETCH): an unknown version does not. */
|
|
1282
|
+
export function keepsPartialCache(version) {
|
|
1283
|
+
const [major, minor] = PARTIAL_FETCH_GIT;
|
|
1284
|
+
return version != null && (version.major > major || (version.major === major && version.minor >= minor));
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
/** Delete a partial cache an older git cannot read (it would fetch a missing blob on its own, or die), and start
|
|
1288
|
+
* it again recording whole-tree fetches. The cache is disposable: everything in it is fetched again. */
|
|
1289
|
+
async function rebuildWhole(repo, ref, options, version) {
|
|
1290
|
+
const session = repo.session;
|
|
1291
|
+
if (session) {
|
|
1292
|
+
session.batches.get(repo.dir)?.kill();
|
|
1293
|
+
session.batches.delete(repo.dir);
|
|
1294
|
+
for (const map of [session.peels, session.trees]) for (const k of [...map.keys()]) if (k.startsWith(`${repo.dir}\0`)) map.delete(k);
|
|
1295
|
+
}
|
|
1296
|
+
rmSync(repo.dir, { recursive: true, force: true });
|
|
1297
|
+
const fresh = await cacheRepo(ref, options);
|
|
1298
|
+
await recordFullFetches(fresh, ref, olderGitNotice(version, ref));
|
|
1299
|
+
return fresh;
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
/** `git fetch` from the remote into the cache (args start at the subcommand and name the remote "origin"):
|
|
1303
|
+
* a lost on-disk `.lock` race is retried, a timeout names the fetch (`what`) and how long it ran, any other
|
|
1304
|
+
* failure is E_REMOTE_UNREADABLE (`raw`: the git error itself, for a caller that reads its stderr). → { stdout, stderr } */
|
|
1305
|
+
async function fetchInto(repo, ref, args, { what, commit, input, raw = false } = {}) {
|
|
1306
|
+
const started = Date.now();
|
|
1307
|
+
let lastError;
|
|
1308
|
+
try { return await cacheGit(repo, ["-c", `remote.origin.url=${ref.url}`, ...args], { timeout: repo.session?.fetchTimeoutMs ?? GIT_FETCH_TIMEOUT_MS, ...(input !== undefined ? { input } : {}) }); }
|
|
1309
|
+
catch (error) { lastError = error; }
|
|
1310
|
+
if (lastError?.timedOut) {
|
|
1311
|
+
const elapsedMs = Date.now() - started;
|
|
1312
|
+
throw fail("E_REMOTE_UNREADABLE", `cannot read remote ${ref.url} (timeout): git fetch of ${what} timed out after ${Math.round(elapsedMs / 1000)} s`,
|
|
1313
|
+
{ url: ref.url, key: ref.key, reason: "timeout", commit, operation: "fetch", elapsedMs });
|
|
1314
|
+
}
|
|
1315
|
+
throw raw ? lastError : unreadable(ref, lastError, { commit, ...(classifyRemoteFailure(lastError) === "cache" ? { cacheDir: repo.dir, stage: "fetch" } : {}) });
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
/** The sizes of `oids` in the cache, one `cat-file --batch-check`: → Map oid → size, null when the cache lacks it. */
|
|
1319
|
+
async function blobSizes(repo, oids) {
|
|
1320
|
+
const out = (await repo.local(["cat-file", "--batch-check"], { input: oids.map((o) => `${o}\n`).join("") })).stdout.toString("utf8");
|
|
1321
|
+
const sizes = new Map(oids.map((o) => [o, null]));
|
|
1322
|
+
for (const line of out.split("\n")) {
|
|
1323
|
+
const [oid, type, size] = line.split(" ");
|
|
1324
|
+
if (sizes.has(oid) && type !== "missing" && /^[0-9]+$/.test(size ?? "")) sizes.set(oid, Number(size));
|
|
1325
|
+
}
|
|
1326
|
+
return sizes;
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
const isRefusedWant = (error) => /unadvertised object|not our ref|does not allow request|allow-(tip|reachable|any)-sha1-in-want/i.test(stderrText(error));
|
|
1330
|
+
|
|
1331
|
+
/**
|
|
1332
|
+
* Make the blobs of `entries` (ls-tree entries of `commit`) present before anything reads them: those
|
|
1333
|
+
* whose size is unknown (null: the cache lacks them) are fetched in ONE fetch, by id, and every entry's
|
|
1334
|
+
* `size` is then the blob's real size. A server that refuses wants of blobs by id gets the commit fetched
|
|
1335
|
+
* again whole (`--refetch`), and the cache records it (recordFullFetches).
|
|
1336
|
+
*/
|
|
1337
|
+
async function ensureBlobs(repo, ref, commit, entries) {
|
|
1338
|
+
const unknown = entries.filter((e) => e.size === null);
|
|
1339
|
+
if (!unknown.length) return;
|
|
1340
|
+
const oids = [...new Set(unknown.map((e) => e.oid))];
|
|
1341
|
+
await withCacheLock(repo.dir, async () => {
|
|
1342
|
+
let sizes = await blobSizes(repo, oids); // another read may have fetched them meanwhile
|
|
1343
|
+
let wanted = oids.filter((o) => sizes.get(o) === null);
|
|
1344
|
+
if (wanted.length) await withCacheWriteLock(ref, repo.dir, "fetch", repo.session, async ({ waited }) => {
|
|
1345
|
+
if (waited) { // another process may have fetched them while this one waited
|
|
1346
|
+
sizes = await blobSizes(repo, oids);
|
|
1347
|
+
wanted = oids.filter((o) => sizes.get(o) === null);
|
|
1348
|
+
if (!wanted.length) return;
|
|
1349
|
+
}
|
|
1350
|
+
const what = `${wanted.length} blob${wanted.length === 1 ? "" : "s"} at ${commit}`;
|
|
1351
|
+
const refetch = () => fetchInto(repo, ref, ["fetch", "-q", "--refetch", "--no-filter", "--depth", "1", "--no-tags", "--no-recurse-submodules", "origin", commit], { what: commit, commit });
|
|
1352
|
+
if (await fetchMode(repo) === "full") await refetch(); // a commit fetched partially before its server was found out
|
|
1353
|
+
else {
|
|
1354
|
+
try {
|
|
1355
|
+
await fetchInto(repo, ref, ["-c", "fetch.negotiationAlgorithm=noop", "fetch", "-q", "--no-tags", "--no-write-fetch-head", "--stdin", "origin"],
|
|
1356
|
+
{ what, commit, input: wanted.map((o) => `${o}\n`).join(""), raw: true });
|
|
1357
|
+
} catch (error) {
|
|
1358
|
+
if (typeof error?.code === "string" && error.code.startsWith("E_")) throw error;
|
|
1359
|
+
if (!isRefusedWant(error)) throw unreadable(ref, error, { commit, ...(classifyRemoteFailure(error) === "cache" ? { cacheDir: repo.dir, stage: "fetch" } : {}) });
|
|
1360
|
+
await refetch();
|
|
1361
|
+
await recordFullFetches(repo, ref);
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
sizes = await blobSizes(repo, oids);
|
|
1365
|
+
const still = oids.find((o) => sizes.get(o) === null);
|
|
1366
|
+
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 });
|
|
1367
|
+
});
|
|
1368
|
+
for (const e of unknown) e.size = sizes.get(e.oid);
|
|
824
1369
|
});
|
|
825
1370
|
}
|
|
826
1371
|
|
|
@@ -977,7 +1522,7 @@ async function observeLive(ref, args, at, options) {
|
|
|
977
1522
|
const exec = options.exec ?? runGit;
|
|
978
1523
|
const signal = sessionOf(options)?.signal;
|
|
979
1524
|
let out;
|
|
980
|
-
try { out = await exec(args, { timeout: GIT_TIMEOUT_MS, ...(signal ? { signal } : {}) }); }
|
|
1525
|
+
try { out = await sessionExec(exec, sessionOf(options), args, { timeout: GIT_TIMEOUT_MS, ...(signal ? { signal } : {}) }); }
|
|
981
1526
|
catch (error) { throw unreadable(ref, error, { at: at ?? null }); }
|
|
982
1527
|
const parsed = parseLsRemote(out.stdout);
|
|
983
1528
|
const hit = resolveAt(parsed, at);
|
|
@@ -1003,7 +1548,8 @@ function normalizeTreePath(path, { allowRoot }) {
|
|
|
1003
1548
|
return parts.join("/");
|
|
1004
1549
|
}
|
|
1005
1550
|
|
|
1006
|
-
/** Parse `git ls-tree -l -z` output → [{ mode, type, oid, size, path }].
|
|
1551
|
+
/** Parse `git ls-tree -l -z` output → [{ mode, type, oid, size, path }]. `size` is null for a tree, and for a
|
|
1552
|
+
* blob the cache does not hold (git prints "BAD" for it and still succeeds): ensureBlobs learns it. */
|
|
1007
1553
|
function parseLsTree(stdout) {
|
|
1008
1554
|
const entries = [];
|
|
1009
1555
|
for (const record of stdout.toString("utf8").split("\0")) {
|
|
@@ -1011,7 +1557,7 @@ function parseLsTree(stdout) {
|
|
|
1011
1557
|
const tab = record.indexOf("\t");
|
|
1012
1558
|
const meta = record.slice(0, tab).trim().split(/\s+/), path = record.slice(tab + 1);
|
|
1013
1559
|
const [mode, type, oid, size] = meta;
|
|
1014
|
-
entries.push({ mode, type, oid, size: size
|
|
1560
|
+
entries.push({ mode, type, oid, size: /^[0-9]+$/.test(size) ? Number(size) : null, path });
|
|
1015
1561
|
}
|
|
1016
1562
|
return entries;
|
|
1017
1563
|
}
|
|
@@ -1039,8 +1585,8 @@ function assertNoCollisions(entries, ref, commit, prefix) {
|
|
|
1039
1585
|
}
|
|
1040
1586
|
|
|
1041
1587
|
/** `git ls-tree -l -z [flags] <spec> [-- <path>]`; null when <spec> names no tree.
|
|
1042
|
-
* Any other failure is E_REMOTE_UNREADABLE (reason "timeout" for the timeout kill,
|
|
1043
|
-
* "unknown") — never a raw Node/git error: enumerateRepo turns E_REMOTE_* into a problem
|
|
1588
|
+
* Any other failure is E_REMOTE_UNREADABLE (reason "timeout" for the timeout kill, "killed"
|
|
1589
|
+
* for any other signal exit, else "unknown") — never a raw Node/git error: enumerateRepo turns E_REMOTE_* into a problem
|
|
1044
1590
|
* row and would otherwise abort the whole discovery on one unexplained listing (L4). */
|
|
1045
1591
|
async function lsTree(repo, spec, { flags = [], path, ref, commit, maxBuffer = 64 * 1024 * 1024 } = {}) {
|
|
1046
1592
|
try {
|
|
@@ -1052,9 +1598,10 @@ async function lsTree(repo, spec, { flags = [], path, ref, commit, maxBuffer = 6
|
|
|
1052
1598
|
if (typeof error?.code === "string" && error.code.startsWith("E_")) throw error; // already an oats error
|
|
1053
1599
|
const text = stderrText(error).toLowerCase();
|
|
1054
1600
|
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;
|
|
1055
|
-
const
|
|
1056
|
-
const
|
|
1057
|
-
|
|
1601
|
+
const killed = !error?.timedOut && !error?.overflowed && typeof error?.signal === "string" && error.signal !== "";
|
|
1602
|
+
const reason = error?.timedOut ? "timeout" : killed ? "killed" : "unknown";
|
|
1603
|
+
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");
|
|
1604
|
+
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 } : {}) });
|
|
1058
1605
|
}
|
|
1059
1606
|
}
|
|
1060
1607
|
|
|
@@ -1099,9 +1646,10 @@ async function entryAt(repo, commit, path, ref) {
|
|
|
1099
1646
|
return entries.find((e) => e.path === path) ?? null;
|
|
1100
1647
|
}
|
|
1101
1648
|
|
|
1102
|
-
/**
|
|
1649
|
+
/** End our own, still-running child gracefully (SIGTERM, then SIGKILL after the grace; never a pid a failed
|
|
1650
|
+
* spawn left at 0: lib/process-group.mjs). */
|
|
1103
1651
|
function killChild(child) {
|
|
1104
|
-
if (child.exitCode === null && child.signalCode === null)
|
|
1652
|
+
if (child.exitCode === null && child.signalCode === null) terminateGroup(child);
|
|
1105
1653
|
}
|
|
1106
1654
|
|
|
1107
1655
|
/**
|
|
@@ -1109,13 +1657,15 @@ function killChild(child) {
|
|
|
1109
1657
|
* like repo.local (same `-C <dir> -c gc.auto=0`, cwd, helper-free gitEnv()); unref'd while idle, so it
|
|
1110
1658
|
* never keeps the process alive. A header over `budget` rejects that request with `{ oversize }` and its
|
|
1111
1659
|
* body is skipped, never buffered; a non-blob answers as `cat-file blob` would ("bad file"). `missing`,
|
|
1112
|
-
* the child dying (or never starting: ENOENT), or no answer within GIT_TIMEOUT_MS
|
|
1113
|
-
* request with a git-shaped error (classifyRemoteFailure reads it
|
|
1114
|
-
* ends the child; the next read starts a new one.
|
|
1660
|
+
* the child dying (or never starting: ENOENT), or no answer within GIT_TIMEOUT_MS (cut to what is left of
|
|
1661
|
+
* `session`'s deadline) rejects EVERY pending request with a git-shaped error (classifyRemoteFailure reads it
|
|
1662
|
+
* as today: timeout → "timeout") and ends the child; the next read starts a new one.
|
|
1115
1663
|
*/
|
|
1116
|
-
function openBatch(dir, timeoutMs = GIT_TIMEOUT_MS) {
|
|
1117
|
-
// Detached, as its own process group: killChild's group kill also ends
|
|
1118
|
-
const child = spawn("git", ["-C", dir, "-c", "gc.auto=0", "cat-file", "--batch"], { cwd: dir, env: gitEnv(), detached: true, stdio: ["pipe", "pipe", "pipe"] });
|
|
1664
|
+
function openBatch(dir, timeoutMs = GIT_TIMEOUT_MS, session = null) {
|
|
1665
|
+
// Detached, as its own process group: killChild's group kill also ends anything git started.
|
|
1666
|
+
const child = watchGroup(spawn("git", ["-C", dir, "-c", "gc.auto=0", "cat-file", "--batch"], { cwd: dir, env: gitEnv(), detached: true, stdio: ["pipe", "pipe", "pipe"] }));
|
|
1667
|
+
liveChildren.add(child);
|
|
1668
|
+
child.once("close", () => liveChildren.delete(child));
|
|
1119
1669
|
const queue = []; // { resolve, reject, budget, size? }
|
|
1120
1670
|
let chunks = [], length = 0, skip = 0, stderr = "", timer = null, dead = null, exited = null;
|
|
1121
1671
|
const exitedPromise = new Promise((r) => { exited = r; });
|
|
@@ -1125,7 +1675,8 @@ function openBatch(dir, timeoutMs = GIT_TIMEOUT_MS) {
|
|
|
1125
1675
|
const arm = () => {
|
|
1126
1676
|
clearTimeout(timer); timer = null;
|
|
1127
1677
|
if (!queue.length) { idle(); return; }
|
|
1128
|
-
|
|
1678
|
+
const ms = session ? session.remaining(timeoutMs) : timeoutMs;
|
|
1679
|
+
timer = setTimeout(() => die(Object.assign(new Error(`git cat-file --batch: no answer within ${ms} ms`), { killed: true, signal: "SIGKILL", timedOut: true, stderr: Buffer.from(stderr) })), ms);
|
|
1129
1680
|
};
|
|
1130
1681
|
const die = (error) => {
|
|
1131
1682
|
if (dead) return;
|
|
@@ -1162,7 +1713,7 @@ function openBatch(dir, timeoutMs = GIT_TIMEOUT_MS) {
|
|
|
1162
1713
|
if (nl < 0) { if (length > 4096) die(Object.assign(new Error("git cat-file --batch: malformed header"), { stderr: Buffer.from(stderr) })); return; }
|
|
1163
1714
|
const line = take(nl + 1).toString("utf8").slice(0, -1);
|
|
1164
1715
|
const [name, type, sizeText, ...rest] = line.split(" ");
|
|
1165
|
-
if (type === "missing" || type === undefined) { die(Object.assign(new Error(line), { code: 128, stderr: Buffer.from(`fatal: Not a valid object name ${name}\n`) })); return; }
|
|
1716
|
+
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; }
|
|
1166
1717
|
const size = Number(sizeText);
|
|
1167
1718
|
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; }
|
|
1168
1719
|
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; }
|
|
@@ -1215,7 +1766,8 @@ function catFileBatch(repo) {
|
|
|
1215
1766
|
if (!session || session.closed || repo.exec !== runGit) return null;
|
|
1216
1767
|
let reader = session.batches.get(repo.dir);
|
|
1217
1768
|
if (reader && !reader.dead) { session.batches.delete(repo.dir); session.batches.set(repo.dir, reader); return reader; }
|
|
1218
|
-
|
|
1769
|
+
if (session.expired()) return null; // a per-blob read, which ends as the deadline's timeout (sessionExec)
|
|
1770
|
+
reader = openBatch(repo.dir, session.batchTimeoutMs, session);
|
|
1219
1771
|
session.batches.set(repo.dir, reader);
|
|
1220
1772
|
if (session.batches.size > BATCH_LIMIT) {
|
|
1221
1773
|
for (const [dir, other] of session.batches) {
|
|
@@ -1242,6 +1794,7 @@ export async function readRemoteFile(refText, commitArg, path, options = {}) {
|
|
|
1242
1794
|
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 });
|
|
1243
1795
|
if (entry.mode === "120000") throw fail("E_REMOTE_TREE_UNSAFE", `${rel} is a symlink`, { path: rel, why: "symlink", key: ref.key, commit });
|
|
1244
1796
|
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 });
|
|
1797
|
+
await ensureBlobs(repo, ref, commit, [entry]);
|
|
1245
1798
|
if (entry.size > FILE_BUDGET) throw oversize(entry.size);
|
|
1246
1799
|
const batch = catFileBatch(repo);
|
|
1247
1800
|
if (batch) {
|
|
@@ -1290,7 +1843,8 @@ export function browseUrl(key, commit, path = null) {
|
|
|
1290
1843
|
}
|
|
1291
1844
|
|
|
1292
1845
|
/**
|
|
1293
|
-
* → [{ path, type: "blob"|"tree"
|
|
1846
|
+
* → [{ path, type: "blob"|"tree"|"symlink" }] relative to <dir>, depth-bounded (depth 1 = direct children).
|
|
1847
|
+
* A listing never needs a blob, so it never fetches one (and so carries no sizes).
|
|
1294
1848
|
* Missing dir → []. Symlinks are reported as type "symlink" so callers can skip them.
|
|
1295
1849
|
*/
|
|
1296
1850
|
export async function listRemoteTree(refText, commitArg, dir, { depth = 2, ...options } = {}) {
|
|
@@ -1316,14 +1870,48 @@ export async function listRemoteTree(refText, commitArg, dir, { depth = 2, ...op
|
|
|
1316
1870
|
assertSafeEntryPath(e.path, ref, commit, rel ? `${rel}/${e.path}` : e.path);
|
|
1317
1871
|
if (e.type === "commit") continue; // submodule gitlinks are not part of the observable tree
|
|
1318
1872
|
const type = e.mode === "120000" ? "symlink" : e.type;
|
|
1319
|
-
|
|
1320
|
-
if (e.type === "blob") row.size = e.size;
|
|
1321
|
-
result.push(row);
|
|
1873
|
+
result.push({ path: e.path, type });
|
|
1322
1874
|
}
|
|
1323
1875
|
result.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
1324
1876
|
return result;
|
|
1325
1877
|
}
|
|
1326
1878
|
|
|
1879
|
+
/**
|
|
1880
|
+
* → { files: [{ path, size }], total } for the regular files (blobs) under <dir> at <commit>, recursively,
|
|
1881
|
+
* relative to <dir>, sorted by path in codepoint order (feature capability-show): `total` of them in all, and
|
|
1882
|
+
* `files` the first `limit` (every one without a limit). Symlinks and gitlinks are omitted; a name that could
|
|
1883
|
+
* escape a checkout ANYWHERE below <dir> is refused (E_REMOTE_TREE_UNSAFE, as listRemoteTree), past the limit
|
|
1884
|
+
* too. One listing (the session's tree index when it has one); the sizes a partial cache lacks are learned with
|
|
1885
|
+
* ONE ensureBlobs call over the listed files only, so nothing past the limit is fetched. A missing dir, or one
|
|
1886
|
+
* that is not a directory → { files: [], total: 0 }. Memoized at the commit (memoAtCommit), per limit.
|
|
1887
|
+
*/
|
|
1888
|
+
export async function listRemoteFiles(refText, commitArg, dir, { limit, ...options } = {}) {
|
|
1889
|
+
const ref = parseRepoRef(refText, options);
|
|
1890
|
+
requireCommit(commitArg);
|
|
1891
|
+
if (limit !== undefined && (!Number.isInteger(limit) || limit < 0)) throw fail("E_REPO_REF", "limit must be a non-negative integer", { limit });
|
|
1892
|
+
const rel = normalizeTreePath(dir, { allowRoot: true });
|
|
1893
|
+
const none = { files: [], total: 0 };
|
|
1894
|
+
return memoAtCommit(ref, commitArg, `files\0${rel}\0${limit ?? "all"}`, async () => {
|
|
1895
|
+
const { repo, commit } = await ensureCommit(ref, commitArg, options);
|
|
1896
|
+
if (rel) {
|
|
1897
|
+
const entry = await entryAt(repo, commit, rel, ref);
|
|
1898
|
+
if (!entry || entry.type !== "tree") return none;
|
|
1899
|
+
}
|
|
1900
|
+
const index = await treeIndex(repo, commit, ref);
|
|
1901
|
+
const entries = index ? indexDescendants(index, rel) : await lsTree(repo, rel ? `${commit}:${rel}` : commit, { flags: ["-r"], ref, commit });
|
|
1902
|
+
if (!entries) return none;
|
|
1903
|
+
const blobs = [];
|
|
1904
|
+
for (const e of entries) {
|
|
1905
|
+
assertSafeEntryPath(e.path, ref, commit, rel ? `${rel}/${e.path}` : e.path);
|
|
1906
|
+
if (e.type === "blob" && e.mode !== "120000") blobs.push({ ...e });
|
|
1907
|
+
}
|
|
1908
|
+
blobs.sort(byPath);
|
|
1909
|
+
const listed = limit === undefined ? blobs : blobs.slice(0, limit);
|
|
1910
|
+
await ensureBlobs(repo, ref, commit, listed);
|
|
1911
|
+
return { files: listed.map((e) => ({ path: e.path, size: e.size })), total: blobs.length };
|
|
1912
|
+
}, options);
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1327
1915
|
// ---------------------------------------------------------------------------
|
|
1328
1916
|
// digest
|
|
1329
1917
|
// ---------------------------------------------------------------------------
|
|
@@ -1421,8 +2009,8 @@ export async function fetchRemoteTree(refText, commitArg, dir, destDir, options
|
|
|
1421
2009
|
}
|
|
1422
2010
|
const entries = await lsTree(repo, spec, { flags: ["-r", "-t"], ref, commit });
|
|
1423
2011
|
if (!entries) throw fail("E_REMOTE_PATH_MISSING", `${rel || "."} is missing in ${ref.key}@${commit.slice(0, 12)}`, { path: rel, key: ref.key, commit });
|
|
1424
|
-
// Inspect everything BEFORE writing anything: names, modes, types,
|
|
1425
|
-
|
|
2012
|
+
// Inspect everything BEFORE writing anything: names, modes, types, collisions, then (once the blobs
|
|
2013
|
+
// are present) sizes.
|
|
1426
2014
|
const blobs = [], trees = [], links = [];
|
|
1427
2015
|
// `allowSymlinks(relPath)` (opt-in, narrow): a symlink whose TARGET is relative
|
|
1428
2016
|
// and stays inside the fetched subtree may be materialized as a symlink — the
|
|
@@ -1439,12 +2027,17 @@ export async function fetchRemoteTree(refText, commitArg, dir, destDir, options
|
|
|
1439
2027
|
if (e.type === "commit") throw fail("E_REMOTE_TREE_UNSAFE", `${shown} is a submodule`, { path: shown, why: "device", key: ref.key, commit });
|
|
1440
2028
|
if (e.type === "tree") { trees.push(e); continue; }
|
|
1441
2029
|
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 });
|
|
1442
|
-
total += e.size;
|
|
1443
|
-
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 });
|
|
1444
2030
|
blobs.push(e);
|
|
1445
2031
|
}
|
|
1446
|
-
blobs.sort(byPath);
|
|
1447
2032
|
assertNoCollisions(entries, ref, commit, rel);
|
|
2033
|
+
// Every blob of the subtree (files and link targets) in ONE fetch, then the budget on their real sizes.
|
|
2034
|
+
await ensureBlobs(repo, ref, commit, [...blobs, ...links]);
|
|
2035
|
+
let total = 0;
|
|
2036
|
+
for (const b of blobs) {
|
|
2037
|
+
total += b.size;
|
|
2038
|
+
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 });
|
|
2039
|
+
}
|
|
2040
|
+
blobs.sort(byPath);
|
|
1448
2041
|
mkdirSync(dirname(dest), { recursive: true });
|
|
1449
2042
|
const staging = join(dirname(dest), `.${dest.split(sep).pop()}.oats-staging-${process.pid}-${Date.now().toString(36)}`);
|
|
1450
2043
|
try {
|
|
@@ -1455,25 +2048,41 @@ export async function fetchRemoteTree(refText, commitArg, dir, destDir, options
|
|
|
1455
2048
|
// the git tree listed first.
|
|
1456
2049
|
const items = [...blobs.map((b) => ({ kind: "blob", e: b })), ...links.map((l) => ({ kind: "link", e: l }))].sort((a, b) => byPath(a.e, b.e));
|
|
1457
2050
|
const d = createDigest();
|
|
2051
|
+
// Blob bytes (all present now: ensureBlobs above): through the session's `cat-file --batch` reader when
|
|
2052
|
+
// there is one (no process per blob), else one `cat-file blob` each. A reader opened before ensureBlobs
|
|
2053
|
+
// fetched a blob still finds it (git re-reads its packs on a miss); one still missing answers `missing`
|
|
2054
|
+
// (GIT_NO_LAZY_FETCH), never a fetch. A batch read may take what is left of TREE_BUDGET, never
|
|
2055
|
+
// FILE_BUDGET (a single blob over 4 MiB still copies); a symlink target keeps its 64 KiB cap. A blob the
|
|
2056
|
+
// reader answers `missing`, or over its budget, is read once more alone: that read's error is this path's
|
|
2057
|
+
// error without a session, classified as it always was (the reader's own words are not git's). Any other
|
|
2058
|
+
// failure, the reader dying or its session closing included, is E_REMOTE_UNREADABLE, as a per-blob read's is.
|
|
2059
|
+
const LINK_BUDGET = 64 * 1024;
|
|
2060
|
+
let read = 0;
|
|
2061
|
+
const readBlob = async (oid, path, budget, maxBuffer) => {
|
|
2062
|
+
const batch = catFileBatch(repo);
|
|
2063
|
+
try {
|
|
2064
|
+
if (batch) {
|
|
2065
|
+
try { return await batch.read(oid, budget); }
|
|
2066
|
+
catch (error) { if (error?.oversize === undefined && error?.missing !== true) throw error; }
|
|
2067
|
+
}
|
|
2068
|
+
return (await repo.local(["cat-file", "blob", oid], { maxBuffer })).stdout;
|
|
2069
|
+
} catch (error) { throw unreadable(ref, error, { commit, path }); }
|
|
2070
|
+
};
|
|
1458
2071
|
for (const { kind, e } of items) {
|
|
1459
2072
|
if (kind === "blob") {
|
|
1460
2073
|
const b = e;
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
catch (error) { throw unreadable(ref, error, { commit, path: b.path }); }
|
|
2074
|
+
const bytes = await readBlob(b.oid, b.path, TREE_BUDGET - read, TREE_BUDGET + 1024);
|
|
2075
|
+
read += bytes.length;
|
|
1464
2076
|
const mode = b.mode === "100755" ? 0o755 : 0o644;
|
|
1465
2077
|
const target = join(staging, ...b.path.split("/"));
|
|
1466
2078
|
mkdirSync(dirname(target), { recursive: true, mode: 0o755 });
|
|
1467
2079
|
const fd = openSync(target, "wx", mode);
|
|
1468
|
-
try { writeSync(fd,
|
|
1469
|
-
d.add(b.path, mode,
|
|
2080
|
+
try { writeSync(fd, bytes); } finally { closeSync(fd); }
|
|
2081
|
+
d.add(b.path, mode, bytes);
|
|
1470
2082
|
continue;
|
|
1471
2083
|
}
|
|
1472
2084
|
const l = e;
|
|
1473
|
-
|
|
1474
|
-
try { out = await repo.local(["cat-file", "blob", l.oid], { maxBuffer: 64 * 1024 }); }
|
|
1475
|
-
catch (error) { throw unreadable(ref, error, { commit, path: l.path }); }
|
|
1476
|
-
const linkTarget = out.stdout.toString("utf8").trim();
|
|
2085
|
+
const linkTarget = (await readBlob(l.oid, l.path, LINK_BUDGET, LINK_BUDGET)).toString("utf8").trim();
|
|
1477
2086
|
const from = dirname(l.path === "" ? "x" : l.path);
|
|
1478
2087
|
const resolvedRel = posixNormalize(from === "." ? linkTarget : `${from}/${linkTarget}`);
|
|
1479
2088
|
if (isAbsolute(linkTarget) || linkTarget.includes("\0") || resolvedRel.startsWith("../") || resolvedRel === "..") {
|