cursedops 0.10.8 → 0.10.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursedops",
3
- "version": "0.10.8",
3
+ "version": "0.10.9",
4
4
  "description": "The build-and-ops answers this generation's apps wrote independently and identically: finding a generation's roots — and printing a command that runs when pasted — without knowing a path, the generation's whole-tree laws run over one repo from a checkout or a worktree, macOS launchd agent install/replace/remove and the live port a job serves, the scaffolding and verdicts of a deployed smoke (origin probe, the smoke's own environment, a network that lies about DNS, a settled version), the static-serving helpers eight apps copied — the path-traversal guard among them — the API floor that keeps an unmatched /api/... from ever being answered with the app shell, the commit and dirty flag a checkout-served process reports, the Cloudflare Worker deploy toolkit four apps copied (the deploy sequence, exact-set secrets over a pipe, origin-first rollback, the curl edge fetch, the row-for-row D1 import proof, the billed-CPU tail check around a deploy's walk and smoke, and each app's worker:secrets and worker:smoke main as one function of its data), the relay a Worker fronts a Mac-bound app with (the Durable Object, the frames, the Mac's dialer and key rotation — lifted from station for roms — and the signed-in stage walk's skeleton and the relay app's whole worker:deploy), the Worker import-graph and await-port checks every Worker app's suite runs over its own source, and the public-surface ratchet three published libraries each carried a forked copy of. Mechanism only — no app knows its name from here. Bun, zero runtime dependencies (typescript is an optional peer, for public-surface only), ships source.",
5
5
  "type": "module",
6
6
  "scripts": {
package/src/edgeFetch.ts CHANGED
@@ -242,3 +242,35 @@ export function fleetEdgeFetch(
242
242
  if (!state) throw new Error(`no generation state root above ${from}: set $FORGE_STATE or run inside a checkout with forge.env`);
243
243
  return createStageEdgeFetch(join(state, "secrets", "cloudflare-access.env"), options);
244
244
  }
245
+
246
+ /** The status {@link settledFetch} answers when the fetch THREW — outside HTTP's range on purpose, so no leg can mistake it for an answer. */
247
+ export const NO_ANSWER_STATUS = 599;
248
+
249
+ /**
250
+ * A fetch that never throws: a rejection (curl's timeout, DNS, a reset) becomes a
251
+ * {@link NO_ANSWER_STATUS} `Response` whose body and `x-edge-error` header carry the reason, and
252
+ * `onThrow` hears it first so the smoke's ledger can name it.
253
+ *
254
+ * 🔴 Why (2026-09-25): roms' `worker:smoke` asked a relayed path while the Mac's link redialled,
255
+ * `edgeFetch` threw on curl exit 28, and the process died with a stack trace and no ledger — so a
256
+ * healthy release exited 1. Every leg of a smoke is supposed to be RECORDED: a throw is a failed
257
+ * leg naming its path, never the end of the run.
258
+ */
259
+ export function settledFetch(
260
+ fetcher: (url: string, init?: RequestInit) => Promise<Response>,
261
+ onThrow: (url: string, error: Error) => void = () => {},
262
+ ): (url: string, init?: RequestInit) => Promise<Response> {
263
+ return async (url, init) => {
264
+ try {
265
+ return await fetcher(url, init);
266
+ } catch (thrown) {
267
+ const error = thrown instanceof Error ? thrown : new Error(String(thrown));
268
+ onThrow(url, error);
269
+ const reason = error.message.replace(/[^\x20-\x7e]+/g, " ").slice(0, 300);
270
+ return new Response(`no answer: ${reason}`, {
271
+ status: NO_ANSWER_STATUS,
272
+ headers: { "content-type": "text/plain", "x-edge-error": reason },
273
+ });
274
+ }
275
+ };
276
+ }
package/src/relay.ts CHANGED
@@ -316,6 +316,20 @@ const json = (status: number, body: unknown) =>
316
316
  * the edge has noticed its old socket died, and a relay that sent to the dead one would time every
317
317
  * request out for as long as that took.
318
318
  *
319
+ * 🔴 **Never a hang across a redial (2026-09-25).** roms' post-deploy smoke died on curl's timeout
320
+ * asking a relayed path while the Mac's link reconnected twice in one minute. Two holes, both shut:
321
+ *
322
+ * · a replaced socket is closed by US, so no `webSocketClose` ever fires for it — its pending
323
+ * requests waited out {@link DEFAULT_RELAY_TIMEOUT_MS}. `accept` now answers each the link-down
324
+ * 503 before closing it;
325
+ * · workerd keeps listing a closed socket until its close handshake completes, which a dead Mac
326
+ * never finishes — and it was `getWebSockets()[0]`, ahead of the new one. {@link RelayLink.live}
327
+ * picks the NEWEST link not marked replaced.
328
+ *
329
+ * A request caught by either answers the same JSON 503 (`link: "down"`) as a Mac away, which is
330
+ * what every smoke already accepts. `relay.test.ts` holds both paths against a state that lists
331
+ * closed sockets the way workerd does.
332
+ *
319
333
  * 🔴 A plain class with a `fetch` method, NOT `extends DurableObject`: importing
320
334
  * `cloudflare:workers` leaks Worker globals over a Bun host's type graph. The hibernation handlers
321
335
  * are found by name.
@@ -344,14 +358,43 @@ export class RelayLink {
344
358
  return this.relay(request);
345
359
  }
346
360
 
361
+ /**
362
+ * The link requests go to: the newest by `since` that `accept` has not marked replaced. Never
363
+ * `getWebSockets()[0]` — see the class header.
364
+ */
365
+ protected live(): LinkSocket | undefined {
366
+ let best: LinkSocket | undefined;
367
+ let bestSince = "";
368
+ for (const socket of this.state.getWebSockets()) {
369
+ const attachment = socket.deserializeAttachment() as { since?: string; replaced?: boolean } | null | undefined;
370
+ if (attachment?.replaced) continue;
371
+ const since = attachment?.since ?? "";
372
+ if (best === undefined || since >= bestSince) {
373
+ best = socket;
374
+ bestSince = since;
375
+ }
376
+ }
377
+ return best;
378
+ }
379
+
347
380
  status(): LinkStatus {
348
- const socket = this.state.getWebSockets()[0];
381
+ const socket = this.live();
349
382
  const attachment = socket?.deserializeAttachment() as { since?: string } | null | undefined;
350
383
  return { connected: socket !== undefined, since: attachment?.since ?? null };
351
384
  }
352
385
 
353
386
  private accept(): Response {
354
- for (const old of this.state.getWebSockets()) old.close(4000, "replaced by a newer link");
387
+ for (const old of this.state.getWebSockets()) {
388
+ // Closed by us, so no `webSocketClose` fires for it: fail what it holds NOW, or each waits
389
+ // out the timeout. Marked first, because workerd goes on listing it until the handshake ends.
390
+ try {
391
+ old.serializeAttachment({ ...((old.deserializeAttachment() as object | null) ?? {}), replaced: true });
392
+ } catch {}
393
+ this.failPending(old, "replaced by a newer link");
394
+ try {
395
+ old.close(4000, "replaced by a newer link");
396
+ } catch {}
397
+ }
355
398
  const { client, server } = this.makePair();
356
399
  this.state.acceptWebSocket(server);
357
400
  server.serializeAttachment({ since: new Date().toISOString() });
@@ -362,17 +405,27 @@ export class RelayLink {
362
405
  return json(503, { error: this.options.offlineMessage, link: "down" });
363
406
  }
364
407
 
408
+ /** Answer every request waiting on `socket` with the link-down 503 — it will never be answered. */
409
+ private failPending(socket: LinkSocket, why: string): void {
410
+ for (const [id, waiting] of this.pending) {
411
+ if (waiting.socket !== socket) continue;
412
+ clearTimeout(waiting.timer);
413
+ this.pending.delete(id);
414
+ waiting.resolve(json(503, { error: `${this.options.app}'s link went down mid-request (${why}) — ask again`, link: "down" }));
415
+ }
416
+ }
417
+
365
418
  /** Relay, and keep or drop what the answer says about the cookie that asked — see {@link OfflineCache}. */
366
419
  private async relay(request: Request): Promise<Response> {
367
420
  const cache = this.options.offline;
368
- if (!cache) return this.state.getWebSockets()[0] ? this.send(request) : this.offline();
421
+ if (!cache) return this.live() ? this.send(request) : this.offline();
369
422
  const url = new URL(request.url);
370
423
  const cookie = cookieValue(request.headers.get("cookie"), cache.cookie);
371
424
  const owner = cookie ? await sha256Hex(cookie) : null;
372
425
  const keyed = `${url.pathname}${url.search}`;
373
426
  const cacheable = owner !== null && request.method === "GET" && url.pathname.startsWith("/api/");
374
427
  if (owner && cache.signOut.includes(url.pathname)) await this.forget(owner);
375
- if (!this.state.getWebSockets()[0]) {
428
+ if (!this.live()) {
376
429
  if (cacheable) {
377
430
  const kept = await this.state.storage.get<Kept>(`c:${owner}:${keyed}`);
378
431
  if (kept && this.now() - kept.at < cache.ttlMs) {
@@ -413,13 +466,13 @@ export class RelayLink {
413
466
  const warm = await this.state.storage.get<{ cookie: string; at: number }>("warm");
414
467
  if (!warm || this.now() - warm.at >= cache.ttlMs) return;
415
468
  for (const path of paths) {
416
- if (!this.state.getWebSockets()[0]) return;
469
+ if (!this.live()) return;
417
470
  await this.relay(new Request(`https://relay.link${path}`, { headers: { cookie: `${cache.cookie}=${warm.cookie}` } }));
418
471
  }
419
472
  }
420
473
 
421
474
  private async send(request: Request): Promise<Response> {
422
- const socket = this.state.getWebSockets()[0];
475
+ const socket = this.live();
423
476
  if (!socket) return this.offline();
424
477
  const tooBig = () => json(413, { error: `request body over the link's ${this.maxBodyBytes} bytes` });
425
478
  const declared = Number(request.headers.get("content-length") ?? 0);
@@ -460,12 +513,7 @@ export class RelayLink {
460
513
  }
461
514
 
462
515
  webSocketClose(socket: LinkSocket, code: number, reason: string): void {
463
- for (const [id, waiting] of this.pending) {
464
- if (waiting.socket !== socket) continue;
465
- clearTimeout(waiting.timer);
466
- this.pending.delete(id);
467
- waiting.resolve(json(502, { error: `${this.options.app}'s link closed mid-request (${code}${reason ? ` ${reason}` : ""})` }));
468
- }
516
+ this.failPending(socket, `closed ${code}${reason ? ` ${reason}` : ""}`);
469
517
  try {
470
518
  socket.close(code, reason);
471
519
  } catch {}
@@ -492,3 +540,23 @@ export async function acceptLink(
492
540
  if (!(await linkKeyMatches(request.headers.get("authorization"), digest))) return json(401, { error: `not ${names.app}'s Mac` });
493
541
  return link.fetch(new Request(`${new URL(request.url).origin}/__link`, request));
494
542
  }
543
+
544
+ /**
545
+ * The Worker's call into the link object, with a THROW turned into the link-down 503.
546
+ *
547
+ * A Worker upload resets every Durable Object, and a request in flight in the old one rejects
548
+ * ("Durable Object reset because its code was updated"). Unhandled, that is Cloudflare's HTML
549
+ * error page — not JSON, not a 503, and a smoke's leg reads it as a broken release. The same
550
+ * answer as a Mac away is the honest one: ask again in a second.
551
+ */
552
+ export async function askLink(
553
+ link: { fetch(request: Request): Promise<Response> },
554
+ request: Request,
555
+ app: string,
556
+ ): Promise<Response> {
557
+ try {
558
+ return await link.fetch(request);
559
+ } catch (error) {
560
+ return json(503, { error: `${app}'s link object restarted mid-request (${(error as Error).message}) — ask again`, link: "down" });
561
+ }
562
+ }