amicus 4.9.5 → 4.9.6
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/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +86 -0
- package/README.md +1 -1
- package/docs/ROADMAP.md +4 -4
- package/docs/architecture-map.md +31 -7
- package/docs/configuration.md +91 -11
- package/docs/troubleshooting.md +96 -3
- package/docs/usage.md +1 -1
- package/package.json +2 -1
- package/scripts/postinstall.js +24 -3
- package/src/sidecar/electron-cache.js +41 -1
- package/src/sidecar/electron-custody.js +180 -0
- package/src/sidecar/electron-ensure.js +25 -4
- package/src/sidecar/electron-env-scrub.js +233 -0
- package/src/sidecar/electron-install.js +115 -125
- package/src/sidecar/electron-layout.js +300 -0
- package/src/sidecar/electron-native-plan.js +157 -0
- package/src/sidecar/electron-native-rescue.js +231 -0
- package/src/sidecar/electron-provision.js +148 -95
- package/src/sidecar/electron-refuse.js +253 -0
- package/src/sidecar/electron-repair-cache.js +212 -0
- package/src/sidecar/electron-rescue-notice.js +78 -0
- package/src/sidecar/electron-trust.js +45 -118
- package/src/sidecar/unzip.js +13 -3
- package/src/sidecar/zip-entry-write.js +268 -0
- package/src/sidecar/zip-from-buffer.js +220 -0
- package/src/sidecar/zip-name-scan.js +141 -0
- package/src/sidecar/zip-stall-bound.js +144 -0
- package/src/utils/doctor-electron-mcp-check.js +9 -2
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHEN AMICUS GIVES UP ON AN IN-MEMORY EXTRACTION, and how it stops the work.
|
|
3
|
+
*
|
|
4
|
+
* SPLIT OUT of zip-from-buffer.js in the v4.9.6 FOURTH council round, when the
|
|
5
|
+
* repair below pushed that file back over the repo's 300-line gate. The seam is
|
|
6
|
+
* the one the last three rounds have all been about: this module owns the
|
|
7
|
+
* give-up POLICY — the two windows, the bounded wait for an aborted write, and
|
|
8
|
+
* the classified failure that is raised — while zip-from-buffer.js owns the
|
|
9
|
+
* yauzl event loop that the policy is applied to. Nothing here knows what a zip
|
|
10
|
+
* entry is; nothing there decides how long to wait.
|
|
11
|
+
*
|
|
12
|
+
* @module sidecar/zip-stall-bound
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
'use strict';
|
|
16
|
+
|
|
17
|
+
const { failure } = require('./zip-entry-write');
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* THE STALL BOUND, and what did and did not come back with it.
|
|
21
|
+
*
|
|
22
|
+
* unzip.js exists for a field bug that was never root-caused: "on some Node 24
|
|
23
|
+
* boxes extract-zip@2.0.1 STALLS mid-extract — its promise never resolves AND
|
|
24
|
+
* never rejects", so the awaiting self-heal let the event loop drain and Node
|
|
25
|
+
* exited 0 with a partial extract and no message. It answered that with three
|
|
26
|
+
* layers: (1) an idle timer + a hard cap, whose LIVE handle is what stops the
|
|
27
|
+
* process exiting mid-stall, (2) a native OS unzip fallback, (3) a
|
|
28
|
+
* files-actually-landed check. All three went with unzip.js when the electron
|
|
29
|
+
* artifact moved onto `extractZipBuffer` and nothing replaced them: it
|
|
30
|
+
* had no timer of any kind, so a stalled write (a network volume, a hung AV
|
|
31
|
+
* filter, an `openReadStream` callback that never arrives) hung `ensureElectron`
|
|
32
|
+
* forever — and in `scripts/postinstall.js` the loop drained and Node exited 0.
|
|
33
|
+
*
|
|
34
|
+
* LAYER 1 IS BACK, with unzip.js's own numbers (30 s idle, 240 s hard) and
|
|
35
|
+
* unzip.js itself untouched: `IDLE_MS`/`MAX_MS` below.
|
|
36
|
+
*
|
|
37
|
+
* ── THE FIRST CUT WAS WRONG IN BOTH DIRECTIONS (round 3) ──────────────────
|
|
38
|
+
* Seat A1: "the advertised idle timeout fires during legitimate active writes
|
|
39
|
+
* and does not actually stop extraction." Seat B2: "can false-fire on a single
|
|
40
|
+
* slow entry write, failing a valid repair on slow storage." Two seats, opposite
|
|
41
|
+
* directions, both true of code that re-armed on ENTRY COMPLETION and settled
|
|
42
|
+
* its promise without stopping anything.
|
|
43
|
+
*
|
|
44
|
+
* ARMED AGAINST BYTES, NOT ENTRIES. The real artifact contains a 225 MB
|
|
45
|
+
* `electron.exe`, which is ONE entry: on storage slower than 7.5 MB/s that
|
|
46
|
+
* entry alone exceeds a 30 s window while writing perfectly well. Progress is
|
|
47
|
+
* now `written` — reported by `zip-entry-write.writeEntry`'s CRC transform,
|
|
48
|
+
* the stage immediately upstream of the sink, so it counts bytes the
|
|
49
|
+
* DESTINATION accepted. The entry count is kept only as a SECOND progress
|
|
50
|
+
* term, because an archive of empty files legitimately writes zero bytes.
|
|
51
|
+
*
|
|
52
|
+
* AND IT STOPS THE WORK. `fail()` aborts an `AbortController` BEFORE it
|
|
53
|
+
* rejects; `writeEntry` hands that signal to `pipeline`, which destroys the
|
|
54
|
+
* source and the sink. MEASURED (Node 24.18.0): after the abort not one
|
|
55
|
+
* further byte is accepted by the sink. `placeEntry` refuses to start a new
|
|
56
|
+
* entry once the signal is aborted, and `writeSymlink` re-checks it before
|
|
57
|
+
* `symlinkSync` (a link target is read through `collect`, not a pipeline).
|
|
58
|
+
*
|
|
59
|
+
* A WATCHDOG, NOT A RE-ARM PER CHUNK. The idle timer re-arms ITSELF: when it
|
|
60
|
+
* fires it compares bytes and entries against the mark it took and fails only
|
|
61
|
+
* when neither moved. One timer per window instead of one per 64 KiB chunk,
|
|
62
|
+
* at the cost of detecting a stall between one and two idle windows after it
|
|
63
|
+
* starts — irrelevant at 30 s, and stated rather than left to be discovered.
|
|
64
|
+
*
|
|
65
|
+
* ── AND THAT REMEDY REINTRODUCED THE HANG IT REMOVED (round 4) ────────────
|
|
66
|
+
* The round-3 repair also made the `finally` `await inFlight` — so the caller's
|
|
67
|
+
* cleanup could not race a live descriptor — AFTER `cancelTimers()` had cleared
|
|
68
|
+
* both handles. On the one shape the bound exists for, that await CANNOT settle:
|
|
69
|
+
* `pipeline` waits for every stream to close, and yauzl@2.10.0 replaces its
|
|
70
|
+
* endpoint stream's `destroy` with a no-arg function that emits neither 'error'
|
|
71
|
+
* nor 'close' (node_modules/yauzl/index.js, lines 566-573 and 698-713).
|
|
72
|
+
* MEASURED on the shipped code, Node 24.18.0, real yauzl and a real `pipeline`:
|
|
73
|
+
* one 1 MiB entry into a sink whose `_write` never calls back, `idleMs: 300` —
|
|
74
|
+
* the bound fired, the sink accepted nothing further, and the promise was STILL
|
|
75
|
+
* PENDING at 6 s; in a bare process with no other handle the loop drained and
|
|
76
|
+
* Node exited 0 having printed nothing. The original field bug, verbatim, from
|
|
77
|
+
* the commit whose message claimed to prevent it.
|
|
78
|
+
*
|
|
79
|
+
* SO THE UNWIND IS ITSELF BOUNDED. `awaitUnwind` races the in-flight write
|
|
80
|
+
* against `UNWIND_MS` on a LIVE timer: the wait keeps the loop alive, and
|
|
81
|
+
* then it ENDS. A write that comes apart normally is still awaited in full,
|
|
82
|
+
* which is all the round-3 repair actually wanted; one that never does costs
|
|
83
|
+
* `unwindMs` and the classified failure is thrown regardless. What that
|
|
84
|
+
* trades away is the descriptor race — whose cost is LITTER, not damage:
|
|
85
|
+
* `electron-layout.extractBytesToDist` removes the incoming tree best-effort
|
|
86
|
+
* (`try { rmSync } catch {}`) and `sweepPromoteLitter` takes what an EPERM
|
|
87
|
+
* leaves. A bound that cannot end is not a bound.
|
|
88
|
+
*
|
|
89
|
+
* LAYER 2 IS NOT THE DEFAULT AND NEVER CAN BE. Every native strategy (`tar`,
|
|
90
|
+
* `Expand-Archive`, `ditto`, `unzip`) takes a PATH, and a path is what the
|
|
91
|
+
* custody finding is about: handing one an artifact would extract bytes amicus
|
|
92
|
+
* did not hash, and writing our hashed Buffer to a temp file for it would
|
|
93
|
+
* rebuild the staged copy the council deleted. The two properties cannot both
|
|
94
|
+
* hold on one run.
|
|
95
|
+
*
|
|
96
|
+
* WHAT THAT COST, AND WHAT C2 BOUGHT BACK. This paragraph used to end "an archive
|
|
97
|
+
* yauzl cannot parse but a native extractor could is a failed repair plus a
|
|
98
|
+
* re-download" — which on an air-gapped machine is a permanent no-rescue failure,
|
|
99
|
+
* and a council seat filed it as such. Layer 2 is now reachable again as a
|
|
100
|
+
* RESCUE, for that ONE failure class (`UNZIP_BUFFER_FAILED`) and only when
|
|
101
|
+
* `AMICUS_ALLOW_UNVERIFIED_ELECTRON=1` was already set. `electron-native-rescue.js`
|
|
102
|
+
* owns the whole boundary and the custody it spends; nothing here changed, and a
|
|
103
|
+
* stall in particular is still NOT a rescue trigger — `UNZIP_BUFFER_STALLED` is
|
|
104
|
+
* a verdict about nothing, this bound exists to STOP work rather than hand it to
|
|
105
|
+
* someone else, and it is still never an eviction (see `electron-repair-cache.js`).
|
|
106
|
+
* LAYER 3 lives upstream and always did:
|
|
107
|
+
* `electron-quarantine.verifyExtractOutcome` stats the exe after a non-throwing
|
|
108
|
+
* extract.
|
|
109
|
+
*/
|
|
110
|
+
/** No-progress window, then the hard cap. unzip.js's IDLE_MS / MAX_MS, to the ms. */
|
|
111
|
+
const IDLE_MS = 30_000;
|
|
112
|
+
const MAX_MS = 240_000;
|
|
113
|
+
/** How long an ABORTED write is given to come apart before it is abandoned. */
|
|
114
|
+
const UNWIND_MS = 5_000;
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* @returns {Error} the extraction made no progress. NOT an archive verdict and
|
|
118
|
+
* NOT a destination verdict — nobody learned anything about either — so it must
|
|
119
|
+
* never be the code that evicts a user's cached artifact.
|
|
120
|
+
*/
|
|
121
|
+
const stalled = (message) => failure('UNZIP_BUFFER_STALLED', `the in-memory extraction stalled: ${message}`);
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Wait for an ABORTED entry to come apart — bounded, because it may never.
|
|
125
|
+
*
|
|
126
|
+
* yauzl's endpoint stream has a `destroy` that emits nothing, so `pipeline`'s
|
|
127
|
+
* promise can stay pending forever after the abort (see the module docblock's
|
|
128
|
+
* round-4 section above, where an unbounded wait was measured to hang the whole
|
|
129
|
+
* extractor). The timer is a LIVE handle for the length of the wait, which is
|
|
130
|
+
* what stops the process draining mid-stall, and it ends the wait when it fires.
|
|
131
|
+
*
|
|
132
|
+
* @returns {Promise<boolean>} true if the write unwound, false if it was abandoned
|
|
133
|
+
*/
|
|
134
|
+
function awaitUnwind(inFlight, ms, setTimer, clearTimer) {
|
|
135
|
+
return new Promise((resolve) => {
|
|
136
|
+
const t = setTimer(() => resolve(false), ms);
|
|
137
|
+
// The rejection is already handled by the driver; this only observes settling.
|
|
138
|
+
inFlight.then(() => {}, () => {}).then(() => { clearTimer(t); resolve(true); });
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
module.exports = {
|
|
143
|
+
IDLE_MS, MAX_MS, UNWIND_MS, stalled, awaitUnwind,
|
|
144
|
+
};
|
|
@@ -138,11 +138,18 @@ async function evaluateElectronMcp(d) {
|
|
|
138
138
|
const after = evaluateElectronInstalls(d); // fresh scan reflects the repairs
|
|
139
139
|
if (after.status === 'ok') {
|
|
140
140
|
const n = results.length;
|
|
141
|
+
// A2/B3: `unverified` used to be written by repairElectron and read by
|
|
142
|
+
// NOTHING. A repair that no published digest could vouch for is exactly the
|
|
143
|
+
// thing a `doctor --fix` report exists to say out loud.
|
|
144
|
+
const unverified = results.filter((r) => r.repaired && r.unverified).length;
|
|
145
|
+
const mark = unverified > 0
|
|
146
|
+
? `, ${unverified} UNVERIFIED (no published sha256 covered the artifact, or its sha256`
|
|
147
|
+
+ ' contradicted the published one and the hatch accepted it)' : '';
|
|
141
148
|
return {
|
|
142
149
|
...after,
|
|
143
|
-
message: `${after.message} (self-healed ${n} npx-cache ${plural(n, 'copy', 'copies')})`,
|
|
150
|
+
message: `${after.message} (self-healed ${n} npx-cache ${plural(n, 'copy', 'copies')}${mark})`,
|
|
144
151
|
fixed: true,
|
|
145
|
-
fixDetail: `self-healed ${n} npx-cache ${plural(n, 'copy', 'copies')}`,
|
|
152
|
+
fixDetail: `self-healed ${n} npx-cache ${plural(n, 'copy', 'copies')}${mark}`,
|
|
146
153
|
};
|
|
147
154
|
}
|
|
148
155
|
const failed = results.filter((r) => !r.repaired)
|