From 3b269f70c93c5538d1cfda6a2ea62a6fcecb4629 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 3 Aug 2026 06:35:43 +0000 Subject: [PATCH] fix(local): warn when the event stream cannot be attached MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit consumeSSE() gave up silently when the very first connection attempt failed. /health can answer 200 while /stream does not — an unrelated service squatting on the port, or the server going away between the two requests — and in that case nothing was ever printed again. That is worst in `local run`, which keeps the child process alive: the user saw 'Connected to existing server', then no events for the rest of the session with no explanation. Emit a warning before giving up, and log the 200-with-no-body case that was also returning silently. Addresses the review comment on #1342. Co-authored-by: Aditya Mathur --- packages/cli/src/commands/local/server.ts | 5 +++ packages/cli/test/commands/local/run.test.ts | 45 ++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/packages/cli/src/commands/local/server.ts b/packages/cli/src/commands/local/server.ts index 997887b52..ab2209181 100644 --- a/packages/cli/src/commands/local/server.ts +++ b/packages/cli/src/commands/local/server.ts @@ -507,6 +507,10 @@ export async function consumeSSE(opts: ConsumeSSEOptions): Promise { // After a previous successful connection, retry on any failure since // the server may be restarting. if (!hasConnectedBefore && result !== "connected-then-lost") { + // `/health` answered but `/stream` never opened, so nothing will ever + // be printed. Say so — `local run` keeps the child process going, and + // without this it just looks like events stopped arriving. + logger.warn(`Could not attach to the event stream at ${url}/stream`); return; } // Reset backoff after a successful connection that later dropped, @@ -604,6 +608,7 @@ async function consumeSSEOnce(opts: ConsumeSSEOnceOptions): Promise { return false; } if (!res.body) { + logger.debug("SSE stream returned 200 with no body"); return false; } // Signal that we have a live connection — the caller uses this to diff --git a/packages/cli/test/commands/local/run.test.ts b/packages/cli/test/commands/local/run.test.ts index fe534105d..18439cd31 100644 --- a/packages/cli/test/commands/local/run.test.ts +++ b/packages/cli/test/commands/local/run.test.ts @@ -8,6 +8,7 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { createSpotlightBuffer } from "@spotlightjs/spotlight/sdk"; +import { Hono } from "hono"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { CLIENT_SPOTLIGHT_PREFIXES, @@ -323,6 +324,50 @@ describe("sentry local run", () => { const output = writes.join(""); expect(output).toContain("Connected to existing server"); expect(output).toContain("Hello from the server!"); + // A healthy attach must not emit the give-up warning. + expect(output).not.toContain("Could not attach to the event stream"); + }); + + test("warns when the existing server's stream cannot be attached", async () => { + // `/health` answers but `/stream` does not — e.g. an unrelated service + // squatting on the port. Attaching fails, and since `run` keeps the child + // alive the user would otherwise get no hint that events are missing. + const brokenApp = new Hono(); + brokenApp.get("/health", (c) => c.text("OK")); + const { server, port } = await tryListen(brokenApp, 0, "127.0.0.1"); + + const savedFetch = globalThis.fetch; + const realFetch = (globalThis as { __originalFetch?: typeof fetch }) + .__originalFetch; + if (realFetch) { + globalThis.fetch = realFetch; + } + + const writes: string[] = []; + const spy = vi + .spyOn(process.stderr, "write") + .mockImplementation((chunk: string | Uint8Array) => { + writes.push(chunk.toString()); + return true; + }); + + try { + const func = (await runCommand.loader()) as unknown as RunFunc; + // The child has to outlive the failed connection attempt; an + // instant-exit command would abort the tail before it ever reports. + await func.call( + makeContext(), + { port, host: "127.0.0.1", verify: false, timeout: 0 }, + "sleep", + "1" + ); + } finally { + spy.mockRestore(); + globalThis.fetch = savedFetch; + await shutdownServer(server); + } + + expect(writes.join("")).toContain("Could not attach to the event stream"); }); test("injects spotlight URL under every framework client prefix", async () => {