From e27c1be545184faa086549ca5d300a5ace3ddb03 Mon Sep 17 00:00:00 2001 From: UtkarshBhardwaj007 Date: Sun, 19 Apr 2026 21:35:03 +0100 Subject: [PATCH 1/2] Filter benign UnsubscriptionError noise during dot deploy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit polkadot-api's `client.destroy()` tears down a still-live chainHead follow subscription whose finalizer calls a cancel RPC on the socket we just closed. The cancel throws "Not connected" (by design — the socket is gone on purpose), rxjs wraps the collected errors in UnsubscriptionError, and depending on whether the finalizer runs from a microtask or a sync path the whole thing surfaces as either `unhandledRejection` or `uncaughtException`. Today the former is tearing the deploy down with a 40-line stack trace printed right under `Required status: ProofOfPersonhoodFull`, which reads as if personhood caused the crash — it didn't. `isBenignUnsubscriptionError` narrowly matches the known-benign shape (name === "UnsubscriptionError" AND every inner error message matches `/not connected/i` AND the array is non-empty). `installSignalHandlers` now swallows matches from BOTH rejection and exception handlers; anything else still escalates via `runAllCleanupAndExit(1)` as before. Verbose mode (`DOT_DEPLOY_VERBOSE=1`) prints a one-line note when a filter fires so diagnostic runs still see something. Also adds a Troubleshooting section to the README pointing users at `DOT_MEMORY_TRACE=1 DOT_DEPLOY_VERBOSE=1` for memory / OOM bug reports — the watchdog worker samples RSS on its own event loop and the verbose interceptor timestamps every bulletin-deploy log line, so attaching the combined output correlates growth with chunk / retry events. --- .changeset/suppress-unsubscription-noise.md | 5 ++ README.md | 15 ++++ src/utils/process-guard.test.ts | 77 +++++++++++++++++++++ src/utils/process-guard.ts | 48 +++++++++++++ 4 files changed, 145 insertions(+) create mode 100644 .changeset/suppress-unsubscription-noise.md create mode 100644 src/utils/process-guard.test.ts 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..3784e0b --- /dev/null +++ b/src/utils/process-guard.test.ts @@ -0,0 +1,77 @@ +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); + }); } /** From 8d07eafc53061a88f84be79f0dae7ccc89019bcd Mon Sep 17 00:00:00 2001 From: UtkarshBhardwaj007 Date: Sun, 19 Apr 2026 21:39:25 +0100 Subject: [PATCH 2/2] chore: biome format process-guard.test.ts --- src/utils/process-guard.test.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/utils/process-guard.test.ts b/src/utils/process-guard.test.ts index 3784e0b..c5ee0ae 100644 --- a/src/utils/process-guard.test.ts +++ b/src/utils/process-guard.test.ts @@ -31,18 +31,14 @@ describe("isBenignUnsubscriptionError", () => { }); it("is case-insensitive on the inner message", () => { - expect( - isBenignUnsubscriptionError(makeUnsubscriptionError(["NOT CONNECTED"])), - ).toBe(true); + 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"]), - ), + isBenignUnsubscriptionError(makeUnsubscriptionError(["Not connected", "ECONNREFUSED"])), ).toBe(false); });