diff --git a/.changeset/suppress-unsubscription-noise.md b/.changeset/suppress-unsubscription-noise.md new file mode 100644 index 0000000..2d6b75d --- /dev/null +++ b/.changeset/suppress-unsubscription-noise.md @@ -0,0 +1,5 @@ +--- +"playground-cli": patch +--- + +Suppress the cosmetic `UnsubscriptionError: Not connected` stack trace that appeared during `dot deploy`'s domain-availability check. It came from polkadot-api tearing down its chainHead follow subscription after `dotns.disconnect()` had already closed the WebSocket — expected, benign, and surfaced as either an `unhandledRejection` or `uncaughtException` depending on the runtime. The process now filters that specific rxjs error (UnsubscriptionError whose inner errors are all "Not connected") instead of logging a 40-line stack trace and tearing the deploy down. Unrelated rejections and exceptions still escalate as before; run with `DOT_DEPLOY_VERBOSE=1` to get a one-line note when a filter fires. Also adds a Troubleshooting section to the README pointing users at `DOT_MEMORY_TRACE=1` + `DOT_DEPLOY_VERBOSE=1` for memory / OOM bug reports. diff --git a/README.md b/README.md index da8e0cd..9a3bd64 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,21 @@ Flags: When forking, you're prompted for the repo name after picking an app; the default is `-<6 hex chars>` and Enter keeps it. Pass `--repo-name` or `-y` to run non-interactively. +## Troubleshooting + +### Reporting a memory issue + +If `dot deploy` gets killed with `✖ Memory use exceeded 4 GB` (the watchdog's abort) or you see RSS climb unexpectedly, re-run with both of: + +```bash +DOT_MEMORY_TRACE=1 DOT_DEPLOY_VERBOSE=1 dot deploy ... +``` + +- `DOT_MEMORY_TRACE=1` streams a per-second `rss / heap / external / peak` sample to stderr from the watchdog worker. The worker has its own event loop, so samples keep firing even while the main thread is busy — perfect for capturing the timeline of a leak. +- `DOT_DEPLOY_VERBOSE=1` prefixes every `bulletin-deploy` log line with `[+s]` so you can line the memory samples up with the exact chunk / retry / reconnect that preceded each spike. + +Attach the combined output to the bug report along with the site size and roughly how many chunks the deploy was into when the spike started — it's dramatically more useful than a stack trace alone. + ## Contributing ### Setup diff --git a/src/utils/process-guard.test.ts b/src/utils/process-guard.test.ts new file mode 100644 index 0000000..c5ee0ae --- /dev/null +++ b/src/utils/process-guard.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; +import { isBenignUnsubscriptionError } from "./process-guard.js"; + +// Construct an object shaped like rxjs's `UnsubscriptionError` — rxjs builds +// it via `createErrorClass`, so in practice all we rely on is (a) `name === +// "UnsubscriptionError"` and (b) an `errors` array. Mirror that here rather +// than import rxjs (the CLI doesn't depend on it directly; bulletin-deploy +// does, transitively). +function makeUnsubscriptionError(innerMessages: string[]): Error { + const err = new Error( + `${innerMessages.length} errors occurred during unsubscription:\n${innerMessages + .map((m, i) => `${i + 1}) Error: ${m}`) + .join("\n ")}`, + ); + err.name = "UnsubscriptionError"; + (err as Error & { errors: unknown[] }).errors = innerMessages.map((m) => new Error(m)); + return err; +} + +describe("isBenignUnsubscriptionError", () => { + it("matches a single-error Not connected UnsubscriptionError (the dotns.disconnect() case)", () => { + expect(isBenignUnsubscriptionError(makeUnsubscriptionError(["Not connected"]))).toBe(true); + }); + + it("matches multi-error payloads where every inner error is Not connected", () => { + expect( + isBenignUnsubscriptionError( + makeUnsubscriptionError(["Not connected", "Not connected"]), + ), + ).toBe(true); + }); + + it("is case-insensitive on the inner message", () => { + expect(isBenignUnsubscriptionError(makeUnsubscriptionError(["NOT CONNECTED"]))).toBe(true); + }); + + it("rejects UnsubscriptionError with at least one non-Not-connected inner error", () => { + // A real RPC error mixed in means something genuinely went wrong mid + // teardown — don't swallow it. + expect( + isBenignUnsubscriptionError(makeUnsubscriptionError(["Not connected", "ECONNREFUSED"])), + ).toBe(false); + }); + + it("rejects UnsubscriptionError with an empty errors array", () => { + // Empty array has no signal of what went wrong — don't assume benign. + const err = new Error("empty"); + err.name = "UnsubscriptionError"; + (err as Error & { errors: unknown[] }).errors = []; + expect(isBenignUnsubscriptionError(err)).toBe(false); + }); + + it("rejects non-UnsubscriptionError errors with Not connected message", () => { + // A bare `Error("Not connected")` is usually a real network failure + // from an active request path, not the teardown-race we're filtering. + const err = new Error("Not connected"); + expect(isBenignUnsubscriptionError(err)).toBe(false); + }); + + it("rejects non-Error inputs", () => { + expect(isBenignUnsubscriptionError(null)).toBe(false); + expect(isBenignUnsubscriptionError(undefined)).toBe(false); + expect(isBenignUnsubscriptionError("Not connected")).toBe(false); + expect(isBenignUnsubscriptionError({ name: "UnsubscriptionError" })).toBe(false); + }); + + it("accepts string entries inside the errors array (rxjs permits them)", () => { + const err = new Error("x"); + err.name = "UnsubscriptionError"; + (err as Error & { errors: unknown[] }).errors = ["Not connected"]; + expect(isBenignUnsubscriptionError(err)).toBe(true); + }); +}); diff --git a/src/utils/process-guard.ts b/src/utils/process-guard.ts index 8997124..cba1172 100644 --- a/src/utils/process-guard.ts +++ b/src/utils/process-guard.ts @@ -87,9 +87,57 @@ export function installSignalHandlers(): void { // Unhandled rejections should not silently keep the event loop alive. process.on("unhandledRejection", (reason) => { + if (isBenignUnsubscriptionError(reason)) { + if (process.env.DOT_DEPLOY_VERBOSE === "1") { + process.stderr.write( + "(suppressed benign post-destroy UnsubscriptionError: Not connected)\n", + ); + } + return; + } process.stderr.write(`\nUnhandled promise rejection: ${String(reason)}\n`); runAllCleanupAndExit(1); }); + + // Mirror the same filter for sync uncaught exceptions. polkadot-api's + // `client.destroy()` schedules subscription teardown that can surface as + // either an `unhandledRejection` or an `uncaughtException` depending on + // whether the finalizer throws from a microtask or a sync path — handling + // both removes the "naked stack trace" the user sees today right under + // `Required status: ProofOfPersonhoodFull`. + process.on("uncaughtException", (err) => { + if (isBenignUnsubscriptionError(err)) { + if (process.env.DOT_DEPLOY_VERBOSE === "1") { + process.stderr.write( + "(suppressed benign post-destroy UnsubscriptionError: Not connected)\n", + ); + } + return; + } + process.stderr.write(`\nUncaught exception: ${err?.stack ?? String(err)}\n`); + runAllCleanupAndExit(1); + }); +} + +/** + * True for the specific rxjs `UnsubscriptionError` we see on `client.destroy()` + * when a still-live chainHead (or similar) subscription's teardown tries to + * send a cancel RPC and the WS has already closed — by design, because we + * just destroyed the client. The symptom is a giant stack trace wrapping + * `Error: Not connected`, which looks terrifying but is already the expected + * outcome. Keeping the match narrow (UnsubscriptionError + every inner error + * is "Not connected") so a genuinely new rxjs failure still escalates. + */ +export function isBenignUnsubscriptionError(reason: unknown): boolean { + if (!(reason instanceof Error) || reason.name !== "UnsubscriptionError") { + return false; + } + const errors = (reason as Error & { errors?: unknown }).errors; + if (!Array.isArray(errors) || errors.length === 0) return false; + return errors.every((e) => { + const msg = e instanceof Error ? e.message : typeof e === "string" ? e : String(e ?? ""); + return /not connected/i.test(msg); + }); } /**