From cfcc556815ceb0dae46412f3b7951bc8a378e7c5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 3 Aug 2026 06:17:19 +0000 Subject: [PATCH] fix(local): tail events in `run` when a server already owns the port MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `local run` only printed events when it started the background server itself. When something else was already listening — most often the Spotlight desktop app's own sidecar — it injected SENTRY_SPOTLIGHT, spawned the child, and then stayed silent for the whole session: no logs, no traces, even though envelopes were flowing to that server. Attach as an SSE consumer in that case, which is what `local serve` already does, so the terminal tail works regardless of who owns the port. The subscription is torn down when the child exits. This became much more visible after #1341: now that the CLI server advertises itself as a Spotlight sidecar, the desktop app keeps its own sidecar on 8969 instead of losing the race, so `run` hits the attach path far more often. Co-authored-by: Aditya Mathur --- apps/cli-docs/src/fragments/commands/local.md | 2 + packages/cli/src/commands/local/run.ts | 65 +++++++++++++++---- packages/cli/src/commands/local/server.ts | 4 +- packages/cli/test/commands/local/run.test.ts | 55 ++++++++++++++++ 4 files changed, 110 insertions(+), 16 deletions(-) diff --git a/apps/cli-docs/src/fragments/commands/local.md b/apps/cli-docs/src/fragments/commands/local.md index 9badf81d9a..5d4b735e06 100644 --- a/apps/cli-docs/src/fragments/commands/local.md +++ b/apps/cli-docs/src/fragments/commands/local.md @@ -30,6 +30,8 @@ sentry local --quiet Runs a command with `SENTRY_SPOTLIGHT` injected into the environment. The Sentry SDK automatically detects this variable and sends envelopes to the local server. No code changes needed. +If nothing is listening on the port, a server is started in the background and shut down when your command exits. If something already is — the Spotlight desktop app's own sidecar, or a `sentry local serve` in another terminal — the command attaches to it as an SSE consumer, so events still tail to your terminal either way. + Env vars injected into the child process: | Variable | Value | diff --git a/packages/cli/src/commands/local/run.ts b/packages/cli/src/commands/local/run.ts index 00af884fa7..c63bc7be37 100644 --- a/packages/cli/src/commands/local/run.ts +++ b/packages/cli/src/commands/local/run.ts @@ -9,6 +9,8 @@ * * If no server is already running on the target port, one is started * automatically in the background and shut down when the child exits. + * If one is already running, this command attaches to it as an SSE consumer + * so events still tail to the terminal. */ import { type ChildProcess, spawn } from "node:child_process"; @@ -24,6 +26,7 @@ import { formatEnvelopeLines } from "../../lib/formatters/local.js"; import { logger, printLine } from "../../lib/logger.js"; import { buildApp, + consumeSSE, DEFAULT_PORT, isServerRunning, parsePort, @@ -94,12 +97,48 @@ function isPackageJsonSource(source: string): boolean { return source.startsWith("package.json"); } -/** State for a background server started by `local run`. */ -type BackgroundServer = { +/** + * A live event tail for the duration of the child process, plus the teardown + * to run once it exits. Produced either by {@link startBackgroundServer} (we + * own the server) or {@link attachToExistingServer} (someone else does). + */ +type EventTail = { url: string; cleanup: () => Promise; }; +/** + * Tail events from a server this command did not start. + * + * Something else already owns the port — most often the Spotlight desktop + * app's own sidecar, or a `sentry local serve` in another terminal. Without + * this, `run` stayed completely silent for the whole session: envelopes + * reached the other server, but nothing was ever printed here. Attaching as + * an SSE consumer is what `sentry local serve` does in the same situation. + */ +function attachToExistingServer(url: string): EventTail { + const ac = new AbortController(); + const tail = consumeSSE({ + url, + activeFilters: new Set(), + signal: ac.signal, + }).catch((err: unknown) => { + if (!ac.signal.aborted) { + logger.debug( + `Event tail stopped: ${err instanceof Error ? err.message : String(err)}` + ); + } + }); + + return { + url, + cleanup: async () => { + ac.abort(); + await tail; + }, + }; +} + /** * Start a background dev server and subscribe to its buffer so incoming * envelopes are printed inline, matching the behavior of `sentry local serve`. @@ -107,7 +146,7 @@ type BackgroundServer = { async function startBackgroundServer( port: number, host: string -): Promise { +): Promise { const buffer = createSpotlightBuffer(BUFFER_SIZE); const app = buildApp(buffer); const { server, port: boundPort } = await tryListen(app, port, host); @@ -271,13 +310,15 @@ export const runCommand = buildCommand({ } let url = `http://${flags.host}:${flags.port}`; - let bg: BackgroundServer | undefined; + let tail: EventTail; - const alreadyRunning = await isServerRunning(url); - if (!alreadyRunning) { + if (await isServerRunning(url)) { + logger.info(`Connected to existing server at ${bold(url)}`); + tail = attachToExistingServer(url); + } else { logger.info("No server detected, starting one in the background..."); - bg = await startBackgroundServer(flags.port, flags.host); - url = bg.url; + tail = await startBackgroundServer(flags.port, flags.host); + url = tail.url; logger.info(`Background server listening on ${bold(url)}`); } @@ -296,9 +337,7 @@ export const runCommand = buildCommand({ stdio: "inherit", }); } catch (err) { - if (bg) { - await bg.cleanup(); - } + await tail.cleanup(); throw new CliError( `Failed to start "${args[0]}": ${err instanceof Error ? err.message : String(err)}`, EXIT.GENERAL @@ -342,9 +381,7 @@ export const runCommand = buildCommand({ } process.removeListener("SIGINT", onSigint); process.removeListener("SIGTERM", onSigterm); - if (bg) { - await bg.cleanup(); - } + await tail.cleanup(); } if (exitCode !== 0) { diff --git a/packages/cli/src/commands/local/server.ts b/packages/cli/src/commands/local/server.ts index 78ccb853c5..997887b52c 100644 --- a/packages/cli/src/commands/local/server.ts +++ b/packages/cli/src/commands/local/server.ts @@ -434,7 +434,7 @@ const SSE_INITIAL_RETRY_MS = 1000; const SSE_MAX_RETRY_MS = 30_000; /** Options for consuming an SSE stream. */ -type ConsumeSSEOptions = { +export type ConsumeSSEOptions = { url: string; activeFilters: ReadonlySet; signal: AbortSignal; @@ -471,7 +471,7 @@ async function sleepUnlessAborted( * Reconnects automatically on connection loss with exponential backoff, * using `Last-Event-ID` to resume from where the stream left off. */ -async function consumeSSE(opts: ConsumeSSEOptions): Promise { +export async function consumeSSE(opts: ConsumeSSEOptions): Promise { const { url, activeFilters, diff --git a/packages/cli/test/commands/local/run.test.ts b/packages/cli/test/commands/local/run.test.ts index 00614e8cf0..fe534105d8 100644 --- a/packages/cli/test/commands/local/run.test.ts +++ b/packages/cli/test/commands/local/run.test.ts @@ -7,12 +7,16 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; +import { createSpotlightBuffer } from "@spotlightjs/spotlight/sdk"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { CLIENT_SPOTLIGHT_PREFIXES, runCommand, + shutdownServer, } from "../../../src/commands/local/run.js"; +import { buildApp, tryListen } from "../../../src/commands/local/server.js"; import { CliError, ValidationError } from "../../../src/lib/errors.js"; +import { SENTRY_CONTENT_TYPE } from "../../../src/lib/formatters/local.js"; import { TEST_TMP_DIR } from "../../constants.js"; /** @@ -270,6 +274,57 @@ describe("sentry local run", () => { ); }); + test("tails events from a server it did not start", async () => { + // Reproduces the case where the Spotlight desktop app (or another + // `sentry local serve`) already owns the port: `run` must attach as an SSE + // consumer instead of going silent for the whole session. + const buffer = createSpotlightBuffer(10); + const { server, port } = await tryListen(buildApp(buffer), 0, "127.0.0.1"); + + // preload.ts mocks fetch to block external calls; this test talks to a + // loopback server it just started, so it needs the real implementation. + 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 { + // Buffered before we attach — SSE subscribers replay from the buffer + // head, so this arrives regardless of connection timing. + await fetch(`http://127.0.0.1:${port}/stream`, { + method: "POST", + headers: { "Content-Type": SENTRY_CONTENT_TYPE }, + body: '{"sdk":{"name":"sentry.javascript.node"}}\n{"type":"log","item_count":1,"content_type":"application/vnd.sentry.items.log+json"}\n{"items":[{"timestamp":1750000000,"level":"info","body":"Hello from the server!","attributes":{}}]}', + }); + + const func = (await runCommand.loader()) as unknown as RunFunc; + 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); + } + + const output = writes.join(""); + expect(output).toContain("Connected to existing server"); + expect(output).toContain("Hello from the server!"); + }); + test("injects spotlight URL under every framework client prefix", async () => { const func = (await runCommand.loader()) as unknown as RunFunc; const ctx = makeContext();