Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/suppress-unsubscription-noise.md
Original file line number Diff line number Diff line change
@@ -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.
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,21 @@ Flags:

When forking, you're prompted for the repo name after picking an app; the default is `<slug>-<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 `[+<seconds>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
Expand Down
73 changes: 73 additions & 0 deletions src/utils/process-guard.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
48 changes: 48 additions & 0 deletions src/utils/process-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
}

/**
Expand Down
Loading