@gjsify/cli 0.46.0 → 0.47.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.
@@ -0,0 +1,273 @@
1
+ // Post-PUT read-back for `gjsify publish`: does the registry actually RESOLVE
2
+ // the version we just uploaded?
3
+ //
4
+ // THE INCIDENT (v0.46.0, run 33735989472). The job `Publish
5
+ // @gjsify/node-runtime-darwin-arm64 (bundled Node)` reported SUCCESS and
6
+ // published NOTHING. Its own log, with GJSIFY_PUBLISH_DEBUG=1:
7
+ //
8
+ // gjsify publish: PUT https://registry.npmjs.org/@gjsify%2fnode-runtime-darwin-arm64
9
+ // payload size: 53863410 bytes
10
+ // + @gjsify/node-runtime-darwin-arm64@0.46.0
11
+ //
12
+ // The job was green. Minutes later the registry had NEITHER half: the version
13
+ // endpoint 404'd, the tarball URL 404'd, `npm view … version` still answered
14
+ // 0.45.0, and the packument's `time` map had no 0.46.0 entry at all. Only a
15
+ // manual single-job rerun made it land (registry time 09:49:07.419Z). Payload
16
+ // size was NOT the cause — darwin-x64 at 55189094 bytes, i.e. LARGER, published
17
+ // fine in the same rerun. `res.ok` was checked and npm's answer really was 2xx,
18
+ // so the plain `+` success path is exactly what the code was written to print.
19
+ //
20
+ // WHAT THE 2xx MEANS, MEASURED. A 2xx from npm is an ACCEPTED write, not a
21
+ // durable one. Correlating all 199 `+ <name>@0.46.0` lines of that release's
22
+ // serial sweep with each packument's own `time["0.46.0"]`:
23
+ //
24
+ // 180 / 199 (90.5%) registry time is 0.4-2.4 s BEFORE our success line (median
25
+ // 0.6 s) — the version doc existed before the response came back
26
+ // 19 / 199 (9.5%) registry time is 56.2 s - 251.7 s AFTER the 2xx; the
27
+ // extreme is @gjsify/child_process, PUT 09:25:08.7, `+`
28
+ // printed 09:25:10.3, registry time 09:29:21.996Z
29
+ // (+4m11s). THREE of the 19 were recorded after the
30
+ // publish job's own completed_at (09:28:35Z).
31
+ // 1 not recorded until a manual rerun — the incident above.
32
+ //
33
+ // So "not there" and "not there YET" are the same observation for up to ~4.2
34
+ // minutes, and no client-side signal separates them. That is what this module
35
+ // is for: it turns "npm said 2xx" into "the registry serves it", and when it
36
+ // cannot, it says which of the two it saw and for how long it looked.
37
+ //
38
+ // THE MECHANISM, and why the lag is a WRITE and not a read cache. `dist.signatures`
39
+ // correlates 1:1 with the sign of that lag across all 199: every one of the 19
40
+ // late packages carries TWO signatures (same keyid, different sig), every one of
41
+ // the 180 carries ONE, and the client sent exactly one PUT per package (199
42
+ // distinct names, no retry, no 5xx in the log). So the late version document was
43
+ // written a second time server-side, and `time[version]` records that second
44
+ // write. Corroborated client-side rather than inferred: @gjsify/child_process's
45
+ // +251.7 s is exactly the package that failed all three attempt-1 node-runtime
46
+ // jobs at 09:29:05 with `ETARGET / No matching version found for
47
+ // @gjsify/child_process@^0.46.0` — 15 s BEFORE its recorded time and 4 min after
48
+ // its `+`. A lag of this kind therefore does not merely delay a report; it
49
+ // cascades into every job that installs the release.
50
+ //
51
+ // WHY THE PACKUMENT AND NOT THE TARBALL URL. One abbreviated-packument GET
52
+ // answers both halves that #1407 split apart (npm had stored the tarball and no
53
+ // packument there): the version record carries `dist.tarball`, so its presence
54
+ // is checked from the same response with no second request. It proves the
55
+ // registry ADVERTISES a tarball, not that the bytes are fetchable — a HEAD on
56
+ // the tarball would be a second round trip per package and is deliberately not
57
+ // done here. Same oracle and same headers as
58
+ // `scripts/verify-published-closure.mjs`, so the per-package check and the
59
+ // end-of-release closure check cannot disagree about what "published" means —
60
+ // an equality that had to be MADE: the closure probe asked for the version key
61
+ // alone, so the tarball half of every publish the sweep defers to it was checked
62
+ // by nothing. It now reads `dist.tarball` from the same document.
63
+ import { escapePackageName } from './publish-headers.js';
64
+ /**
65
+ * Backoff between probes, in ms; the last value repeats until the budget is out.
66
+ *
67
+ * Shaped by the distribution in the header, not picked for roundness: 90.5% of
68
+ * writes are already committed when the 2xx arrives, so the FIRST probe is
69
+ * immediate and costs one ~0.5 s GET for nine packages in ten. The tail is
70
+ * minutes long and coarse, so probing it finely would only add requests — after
71
+ * 30 s the interval stops shrinking.
72
+ */
73
+ const BACKOFF_MS = [2_000, 4_000, 8_000, 15_000, 30_000];
74
+ /** Delay before probe `round + 2`. Past the table's end the last value repeats. */
75
+ export function backoffFor(round) {
76
+ return BACKOFF_MS[Math.min(Math.max(round, 0), BACKOFF_MS.length - 1)] ?? 30_000;
77
+ }
78
+ /**
79
+ * The smallest request timeout a probe is ever given.
80
+ *
81
+ * A probe cut below the time an answer takes reports `error` — "we do not know" —
82
+ * where it would have read a 404, so the decisive LAST probe of a short budget
83
+ * would lose the verdict the whole read-back exists to produce. Measured against
84
+ * registry.npmjs.org from a European workstation, 21 abbreviated-packument GETs
85
+ * ran 0.48-0.62 s (median 0.57), so 2 s is ~3.5x the median and above every
86
+ * observation — the floor is what lets a probe answer, not a guess at latency.
87
+ */
88
+ const MIN_PROBE_TIMEOUT_MS = 2_000;
89
+ /**
90
+ * A probe's request timeout: what was configured, never more than the budget has
91
+ * left to spend — but never less than a probe needs to answer.
92
+ *
93
+ * Configured and budget are otherwise independent, and that makes
94
+ * `--verify-timeout 5` — what the 199-package sweep passes — a promise the code
95
+ * does not keep: one unanswered request may block for the 30 s default, six times
96
+ * the budget it was given, and 199 of those cost ~100 min against the ~11% the
97
+ * deferral is argued on. Clamping bounds the wall clock at
98
+ * `budget + MIN_PROBE_TIMEOUT_MS` instead. The floor is the other half of the
99
+ * same honesty: clamping all the way to zero traded a 30 s overshoot for an
100
+ * `error` verdict on a probe that was about to return the 404 (measured — it
101
+ * turned the incident's own e2e row from `absent: 404` into `request failed
102
+ * (This operation was aborted)`).
103
+ */
104
+ export function probeTimeoutFor(configuredMs, remainingMs) {
105
+ return Math.min(configuredMs, Math.max(remainingMs, MIN_PROBE_TIMEOUT_MS));
106
+ }
107
+ /**
108
+ * Default polling budget: 300 s.
109
+ *
110
+ * The measured maximum lag between a 2xx and the registry recording the version
111
+ * was 251.7 s (@gjsify/child_process, v0.46.0), so a budget below ~4.2 minutes
112
+ * turns npm's normal queueing into a false red — and a false red at release time
113
+ * costs a manual re-run of a workflow that did its job. 300 s clears that
114
+ * maximum and is still short enough that a genuinely lost write is reported by
115
+ * the job that lost it rather than by a later sweep.
116
+ *
117
+ * THAT MAXIMUM IS A SAMPLE MAXIMUM, and the sample does not look truncated by
118
+ * nature. The 19 lags fall in buckets ~20-30 s apart — 56(2), 76(3), 96(3),
119
+ * 127(4), 157(3), 189(1), 251(3) — and the TOP bucket is as populated as the
120
+ * middle ones, which is the shape of a distribution cut off by the observation
121
+ * rather than one decaying to zero. One release is one sample, so a lag past
122
+ * 300 s is not ruled out; what makes 300 s affordable anyway is that the
123
+ * remediation is verified rather than assumed. The re-run this reports lands on
124
+ * the 409 path, and that path READS BACK too (see the conflict branch in
125
+ * `commands/publish.ts`), so a window that turns out to be too short costs one
126
+ * re-run that confirms — it does not hand back an unverified success.
127
+ */
128
+ export const DEFAULT_VERIFY_BUDGET_MS = 300_000;
129
+ /**
130
+ * Poll the registry until `name@version` resolves, or the budget runs out.
131
+ *
132
+ * No side effects, no exits, no printing beyond the injected `log` — the caller
133
+ * owns presentation, same contract as {@link import('./publish-diagnose.js')}.
134
+ */
135
+ export async function verifyPublishedVersion(input) {
136
+ const { registry, name, version, budgetMs } = input;
137
+ const doFetch = input.fetchImpl ?? ((...args) => globalThis.fetch(...args));
138
+ const sleep = input.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
139
+ const now = input.now ?? (() => Date.now());
140
+ const probeTimeoutMs = input.probeTimeoutMs ?? 30_000;
141
+ const registryClean = registry.endsWith('/') ? registry.slice(0, -1) : registry;
142
+ const url = `${registryClean}/${escapePackageName(name)}`;
143
+ const started = now();
144
+ let attempts = 0;
145
+ let last = { state: 'error', detail: 'no probe was sent' };
146
+ for (let round = 0;; round++) {
147
+ attempts++;
148
+ last = await probeOnce({
149
+ url,
150
+ version,
151
+ doFetch,
152
+ probeTimeoutMs: probeTimeoutFor(probeTimeoutMs, budgetMs - (now() - started)),
153
+ authorization: input.authorization,
154
+ });
155
+ input.log?.(`gjsify publish: read-back probe ${attempts} of ${name}@${version} → ${last.state}` +
156
+ (last.state === 'present' ? '' : ` (${last.detail})`));
157
+ if (last.state === 'present')
158
+ break;
159
+ const remaining = budgetMs - (now() - started);
160
+ if (remaining <= 0)
161
+ break;
162
+ await sleep(Math.min(backoffFor(round), remaining));
163
+ }
164
+ return { confirmed: last.state === 'present', attempts, elapsedMs: now() - started, url, last };
165
+ }
166
+ async function probeOnce(input) {
167
+ const { url, version, doFetch, probeTimeoutMs } = input;
168
+ // `AbortController` + a timer rather than `AbortSignal.timeout`, which the
169
+ // CLI does not use anywhere and which this file must not be the first to
170
+ // require of a GJS host.
171
+ const controller = new AbortController();
172
+ const timer = setTimeout(() => controller.abort(), probeTimeoutMs);
173
+ let res;
174
+ try {
175
+ res = await doFetch(url, {
176
+ headers: {
177
+ // Abbreviated packument: version keys + `dist`, a fraction of the
178
+ // bytes of the full document. `no-cache` because a CDN edge will
179
+ // otherwise answer with a document minted BEFORE the publish we
180
+ // are asking about — which is the whole question here.
181
+ accept: 'application/vnd.npm.install-v1+json',
182
+ 'cache-control': 'no-cache',
183
+ ...(input.authorization ? { authorization: input.authorization } : {}),
184
+ },
185
+ signal: controller.signal,
186
+ });
187
+ }
188
+ catch (err) {
189
+ // A real throw path: network failure, DNS, or our own abort above.
190
+ const msg = err instanceof Error ? err.message : String(err);
191
+ return { state: 'error', detail: `request failed (${msg})` };
192
+ }
193
+ finally {
194
+ clearTimeout(timer);
195
+ }
196
+ if (res.status === 404) {
197
+ return { state: 'absent', status: 404, detail: '404 — the registry serves no packument for this name' };
198
+ }
199
+ if (!res.ok) {
200
+ return { state: 'error', status: res.status, detail: `${res.status} ${res.statusText}` };
201
+ }
202
+ let doc;
203
+ try {
204
+ doc = await res.json();
205
+ }
206
+ catch (err) {
207
+ // A 200 whose body is not a packument answers nothing; `absent` would be
208
+ // a verdict we did not earn.
209
+ const msg = err instanceof Error ? err.message : String(err);
210
+ return { state: 'error', status: res.status, detail: `packument is not JSON (${msg})` };
211
+ }
212
+ const versions = doc?.versions;
213
+ const known = versions && typeof versions === 'object' ? Object.keys(versions) : [];
214
+ const entry = versions && typeof versions === 'object' ? versions[version] : undefined;
215
+ if (!entry) {
216
+ const newest = known.length > 0 ? known[known.length - 1] : '(none)';
217
+ return {
218
+ state: 'absent',
219
+ status: res.status,
220
+ detail: `packument carries ${known.length} version(s), newest ${newest} — ${version} is not among them`,
221
+ };
222
+ }
223
+ const tarball = entry.dist?.tarball;
224
+ if (typeof tarball !== 'string' || tarball.length === 0) {
225
+ // The #1407 half: a version record with no tarball to install.
226
+ return {
227
+ state: 'absent',
228
+ status: res.status,
229
+ detail: `packument has ${version} but its \`dist.tarball\` is missing`,
230
+ };
231
+ }
232
+ return { state: 'present', status: res.status, tarball };
233
+ }
234
+ /**
235
+ * The unconfirmed-publish diagnostic, ready for stderr.
236
+ *
237
+ * The message has to answer three questions the incident log could not, which is
238
+ * why it needed the registry to reconstruct at all: what was PUT, what was asked,
239
+ * and what came back.
240
+ */
241
+ export function formatUnconfirmedPublish(opts) {
242
+ const { name, version, putUrl, putStatus, putStatusText, payloadBytes, readback } = opts;
243
+ const claim = opts.claim ?? 'accepted';
244
+ const seconds = (readback.elapsedMs / 1000).toFixed(1);
245
+ // Only ever called on an UNCONFIRMED read-back, so `last` is `absent` or
246
+ // `error` — both carry a `detail`; `present` has nothing to explain.
247
+ const answered = readback.last.state === 'present' ? 'served' : readback.last.detail;
248
+ const headline = claim === 'already-published'
249
+ ? `npm says ${version} is ALREADY PUBLISHED but the registry does not serve it.`
250
+ : 'npm ACCEPTED the upload but the registry does not serve it.';
251
+ const remedy = claim === 'already-published'
252
+ ? [
253
+ ' A 409 is npm refusing to overwrite a version it holds, which is not the same as serving it: in',
254
+ ' the v0.46.0 recovery @gjsify/node-runtime-darwin-arm64 was answered 409 at 09:48:49 and the',
255
+ ' registry recorded 0.46.0 at 09:49:07, 18s later. Re-running answers the same 409, so it cannot',
256
+ ' fix this — wait and re-read (`npm view <name>@<version>`), or check https://status.npmjs.org/.',
257
+ ]
258
+ : [
259
+ ' A 2xx from npm is an ACCEPTED write, not a durable one. In the v0.46.0 release 19 of 199',
260
+ ' packages were recorded by the registry 56-252s after their 2xx, and ONE was never recorded',
261
+ ' at all while its job stayed green — that is the failure this check exists to name.',
262
+ ` Re-run this publish (\`--tolerate-republish\` no-ops if it landed meanwhile). If it keeps`,
263
+ ' failing, the write was rejected downstream of the 2xx: check https://status.npmjs.org/.',
264
+ ];
265
+ return [
266
+ `gjsify publish: ${name}@${version} — ${headline}`,
267
+ ` PUT ${putUrl} (${payloadBytes} bytes) → ${putStatus} ${putStatusText}`,
268
+ ` read-back GET ${readback.url} (accept: application/vnd.npm.install-v1+json, cache-control: no-cache)`,
269
+ ` answered ${readback.last.state}: ${answered}`,
270
+ ` after ${readback.attempts} probe(s) over ${seconds}s`,
271
+ ...remedy,
272
+ ].join('\n');
273
+ }
@@ -125,7 +125,9 @@ export const LAYOUTS = {
125
125
  'macOS is CoreText-backed and GTK is not built against fontconfig there. That the activation ' +
126
126
  "reaches Pango's CoreText font map is UNVERIFIED: no leg in this repository runs a `.app`. " +
127
127
  'Confirm with `PangoCairo.FontMap.get_default().list_families()` in the shipped bundle, and ' +
128
- '`PANGOCAIRO_BACKEND=bogus` to print which backend it was built with.',
128
+ '`PANGOCAIRO_BACKEND=bogus` to print which backend it was built with. An app that calls ' +
129
+ '`initFonts()` from `@gjsify/gtk-host/fonts` for the Windows row needs no branch around it: the ' +
130
+ 'CoreText map answers G_IO_ERROR_NOT_SUPPORTED and that call deliberately does nothing here.',
129
131
  launcherExt: '',
130
132
  root: appBundleDir,
131
133
  // Apple's, all four. `Contents/MacOS` holds executables, `Contents/Resources`
@@ -230,9 +232,11 @@ export const LAYOUTS = {
230
232
  'W1-W5): a FONTCONFIG_FILE naming the staged directory moves the default font map by nothing ' +
231
233
  'even when it is the ONLY configuration present — that map does not read fontconfig, and exposes ' +
232
234
  'no config_changed or set_config with which to make it — while add_font_file on the same map a ' +
233
- 'Gtk.Label renders through does register the family. So register them at startup: ' +
234
- '`PangoCairo.FontMap.get_default().add_font_file(path)` (Pango 1.56+, in the typelib), or the ' +
235
- 'app renders in a substituted family with no error.',
235
+ 'Gtk.Label renders through does register the family. So register them at startup, with one call ' +
236
+ "from the GTK host layer: `import { initFonts } from '@gjsify/gtk-host/fonts'`, then `initFonts()` " +
237
+ 'before the first window — it reads GJSIFY_FONT_DIR itself, registers every face it holds, and ' +
238
+ 'no-ops on the operating systems that already activated them. Without it the app renders in a ' +
239
+ 'substituted family with no error.',
236
240
  // `.cmd`, not `.bat`: the two differ in whether a failing built-in (`set`,
237
241
  // `path`, `append`) sets ERRORLEVEL, and `.cmd` is the one where it does.
238
242
  launcherExt: '.cmd',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjsify/cli",
3
- "version": "0.46.0",
3
+ "version": "0.47.0",
4
4
  "description": "CLI for Gjsify",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -142,21 +142,21 @@
142
142
  "cli"
143
143
  ],
144
144
  "dependencies": {
145
- "@gjsify/buffer": "^0.46.0",
146
- "@gjsify/create-app": "^0.46.0",
147
- "@gjsify/node-globals": "^0.46.0",
148
- "@gjsify/node-polyfills": "^0.46.0",
149
- "@gjsify/npm-registry": "^0.46.0",
150
- "@gjsify/resolve-npm": "^0.46.0",
151
- "@gjsify/rolldown-plugin-gjsify": "^0.46.0",
152
- "@gjsify/vite-plugin-blueprint": "^0.46.0",
153
- "@gjsify/rolldown-plugin-pnp": "^0.46.0",
154
- "@gjsify/semver": "^0.46.0",
155
- "@gjsify/tar": "^0.46.0",
156
- "@gjsify/tsc": "^0.46.0",
157
- "@gjsify/utils": "^0.46.0",
158
- "@gjsify/web-polyfills": "^0.46.0",
159
- "@gjsify/workspace": "^0.46.0",
145
+ "@gjsify/buffer": "^0.47.0",
146
+ "@gjsify/create-app": "^0.47.0",
147
+ "@gjsify/node-globals": "^0.47.0",
148
+ "@gjsify/node-polyfills": "^0.47.0",
149
+ "@gjsify/npm-registry": "^0.47.0",
150
+ "@gjsify/resolve-npm": "^0.47.0",
151
+ "@gjsify/rolldown-plugin-gjsify": "^0.47.0",
152
+ "@gjsify/vite-plugin-blueprint": "^0.47.0",
153
+ "@gjsify/rolldown-plugin-pnp": "^0.47.0",
154
+ "@gjsify/semver": "^0.47.0",
155
+ "@gjsify/tar": "^0.47.0",
156
+ "@gjsify/tsc": "^0.47.0",
157
+ "@gjsify/utils": "^0.47.0",
158
+ "@gjsify/web-polyfills": "^0.47.0",
159
+ "@gjsify/workspace": "^0.47.0",
160
160
  "acorn": "^8.17.0",
161
161
  "cosmiconfig": "^9.0.2",
162
162
  "get-tsconfig": "^4.14.0",
@@ -165,13 +165,13 @@
165
165
  "yargs": "^18.0.0"
166
166
  },
167
167
  "devDependencies": {
168
- "@gjsify/unit": "^0.46.0",
168
+ "@gjsify/unit": "^0.47.0",
169
169
  "@types/yargs": "^17.0.35",
170
170
  "typescript": "^6.0.3"
171
171
  },
172
172
  "peerDependencies": {
173
- "@gjsify/oxfmt-native": "^0.46.0",
174
- "@gjsify/rolldown-native": "^0.46.0"
173
+ "@gjsify/oxfmt-native": "^0.47.0",
174
+ "@gjsify/rolldown-native": "^0.47.0"
175
175
  },
176
176
  "peerDependenciesMeta": {
177
177
  "@gjsify/oxfmt-native": {