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
1 change: 1 addition & 0 deletions packages/cli/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,7 @@ takes:
| Issue as a flag value (`issue merge --into`) | `parseIssueReference` | what `ISSUE_TARGET` completes |
| Git ref, commit, or repository (`--base-ref`, `--head-sha`, `--repo`, `--commit REPO@SHA`, `set-commits --from`) | `parseVcsRef` (branch and tag names as the remote knows them), `parseGitRef` (refs git resolves locally), `parseGitCommit`, `parseGitRepo` (`owner/repo` of the remotes), `parseRepoCommitList` | read from git in the shell's directory; nothing outside a work tree |
| A structured value with its own grammar (`issue archive --until`, `api <endpoint>`) | `parseArchiveUntil`, `parseApiEndpoint` | the grammar: `forever`, `auto`, `10x`, `10x/5m`, …; API schema paths one segment at a time, with cached orgs and projects for their placeholders |
| Local session (`local session view`, `local event list`, …) | `parseLocalSession` | the running Local daemon's session IDs, described by label and state, then labels only one session has (none with whitespace). Read from `local-sessions.json`, an owner-only snapshot the daemon rewrites when a session is created, closed, or expires and removes on shutdown; it holds IDs, labels, states, and expiries, never capabilities, and is ignored once its daemon's PID has exited. `<session envelope>` and `<session event>` complete only the session: envelope and event IDs exist only in the daemon's memory |
| Stored account | `parseAccountSelector` | stored accounts (`auth switch`, `auth logout`, `org list --account`) |
| CLI command path | `parseCommandPath` | route names, one segment per word (`help`) |
| URL | `parseUrl` (the command validates it) | nothing |
Expand Down
31 changes: 26 additions & 5 deletions packages/cli/src/commands/local/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import type { Server } from "node:http";
import { join } from "node:path";
import { isSea } from "../../lib/react-native/wrap-call.js";
import { setTimeout as sleep } from "node:timers/promises";
import { getConfigDir } from "../../lib/db/index.js";
import { ensureConfigDir, getConfigDir } from "../../lib/db/index.js";
import { formatLocalServerUrl } from "./ui.js";
import { logger } from "../../lib/logger.js";
import {
Expand All @@ -34,6 +34,11 @@ import {
PORT_RETRY_TIMEOUT_MS,
tryListen,
} from "./server.js";
import {
removeLocalSessionSnapshot,
writeLocalSessionSnapshot,
} from "./session-snapshot.js";
import { createLocalSessionStore, type LocalSession } from "./session-store.js";

export const LOCAL_DAEMON_STATE_FILENAME = "local-control-plane.json";
export const LOCAL_DAEMON_PROTOCOL_VERSION = 1;
Expand Down Expand Up @@ -424,8 +429,7 @@ export async function ensureLocalDaemon({
throw new Error("The Local control-plane port must be non-zero.");
const running = await getRunningLocalDaemon();
if (running) return running;
const directory = getConfigDir();
await mkdir(directory, { recursive: true, mode: 0o700 });
const directory = ensureConfigDir();
let lock: Awaited<ReturnType<typeof open>>;
try {
lock = await open(startupLockPath(directory), "wx", 0o600);
Expand Down Expand Up @@ -510,11 +514,17 @@ export async function runLocalDaemonForeground({
if (port === 0) {
throw new Error("The Local control-plane port must be non-zero.");
}
const directory = getConfigDir();
await mkdir(directory, { recursive: true, mode: 0o700 });
const directory = ensureConfigDir();
const releaseOwnership = await claimDaemonOwnership(directory);
let listening: Awaited<ReturnType<typeof tryListen>> | undefined;
let ownedState: LocalDaemonState | undefined;
const snapshotSessions = (sessions: readonly LocalSession[]) => {
try {
writeLocalSessionSnapshot(process.pid, sessions);
} catch (error) {
logger.debug("Could not write the Local session snapshot", error);
}
};
try {
const token = randomBytes(32).toString("base64url");
let stop: (() => void) | undefined;
Expand All @@ -526,6 +536,8 @@ export async function runLocalDaemonForeground({
host,
port,
onStop: () => stop?.(),
// For shell completion, which cannot ask the daemon
sessionStore: createLocalSessionStore({ onChange: snapshotSessions }),
});
listening = await tryListen(controlPlane.app, port, host);
const state: LocalDaemonState = {
Expand All @@ -537,6 +549,8 @@ export async function runLocalDaemonForeground({
};
ownedState = state;
await writeLocalDaemonState(directory, state);
// Replaces the sessions of a daemon that exited without removing them
snapshotSessions(controlPlane.sessionStore.list());

let idleTimer: ReturnType<typeof setTimeout> | undefined;
const pruneSessions = () => {
Expand Down Expand Up @@ -582,6 +596,13 @@ export async function runLocalDaemonForeground({
) {
await removeLocalDaemonState(directory);
}
if (ownedState) {
try {
removeLocalSessionSnapshot();
} catch (error) {
logger.debug("Could not remove the Local session snapshot", error);
}
}
} finally {
await releaseOwnership();
}
Expand Down
5 changes: 3 additions & 2 deletions packages/cli/src/commands/local/management.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { SentryContext } from "../../context.js";
import { buildCommand } from "../../lib/command.js";
import {
choiceParser,
parseLocalSession,
parseText,
textParser,
} from "../../lib/completion-target.js";
Expand Down Expand Up @@ -76,7 +77,7 @@ const SESSION_REFERENCE_POSITIONAL = {
kind: "array",
parameter: {
brief: "Session ID or unique name",
parse: parseText,
parse: parseLocalSession,
placeholder: "session",
},
} as const;
Expand Down Expand Up @@ -301,7 +302,7 @@ function buildTelemetryViewCommand(
kind: "array",
parameter: {
brief: `Session and ${kind} ID`,
parse: parseText,
parse: parseLocalSession,
placeholder: `session ${kind}`,
},
},
Expand Down
133 changes: 133 additions & 0 deletions packages/cli/src/commands/local/session-snapshot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/**
* The running daemon's sessions, as shell completion reads them.
*
* Sessions live only in the daemon's memory, and `sentry __complete` makes
* no network requests, so the daemon mirrors each session's ID, label, and
* state to an owner-only file in the CLI's config directory, next to its
* discovery state and `cli.db`. The file holds no
* capabilities and no telemetry. It is ignored once the daemon that wrote it
* has exited, and a stale entry only suggests a session the command then
* reports as not found.
*
* Shell completion imports this module on every TAB press, so it loads
* nothing the completion fast path has not loaded already.
*/

import {
chmodSync,
readFileSync,
renameSync,
rmSync,
writeFileSync,
} from "node:fs";
import { join } from "node:path";
import { ensureConfigDir, getConfigDir } from "../../lib/db/index.js";
import { logger } from "../../lib/logger.js";
import type { LocalSession } from "./session-store.js";

export const LOCAL_SESSION_SNAPSHOT_FILENAME = "local-sessions.json";

/** What completion knows of a session. */
export type LocalSessionSnapshotEntry = Pick<
LocalSession,
"id" | "label" | "state" | "expiresAt"
>;

function snapshotPath(directory = getConfigDir()): string {
return join(directory, LOCAL_SESSION_SNAPSHOT_FILENAME);
}

/**
* Atomically replace the snapshot with `sessions`, written by daemon `pid`,
* with owner-only access. Only the fields of
* {@link LocalSessionSnapshotEntry} are written. Synchronous, so each write
* lands before the next starts: the file is small, and sessions change
* only when one is created, closed, or expires.
*/
export function writeLocalSessionSnapshot(
pid: number,
sessions: readonly LocalSession[],
): void {
const snapshot = {
pid,
sessions: sessions.map(({ id, label, state, expiresAt }) => ({
id,
label,
state,
expiresAt,
})),
};
const path = snapshotPath(ensureConfigDir());
const temporaryPath = `${path}.${pid}.tmp`;
writeFileSync(temporaryPath, `${JSON.stringify(snapshot)}\n`, {
encoding: "utf8",
mode: 0o600,
});
// `mode` does not apply to a file left over from an interrupted write
chmodSync(temporaryPath, 0o600);
renameSync(temporaryPath, path);
}

/** Remove the snapshot when the daemon shuts down. */
export function removeLocalSessionSnapshot(): void {
rmSync(snapshotPath(), { force: true });
}

/** Whether `pid` may name a running process: only `ESRCH` rules it out. */
function isProcessRunning(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (error) {
return (error as NodeJS.ErrnoException).code !== "ESRCH";
}
}

function isEntry(value: unknown): value is LocalSessionSnapshotEntry {
if (typeof value !== "object" || value === null) {
return false;
}
const { id, label, state, expiresAt } = value as Record<string, unknown>;
return (
typeof id === "string" &&
id !== "" &&
typeof label === "string" &&
(state === "open" || state === "retained") &&
(expiresAt === undefined || typeof expiresAt === "number")
);
}

/**
* The sessions of the snapshot, if the daemon that wrote it is still
* running, leaving out retained sessions past their expiry and malformed
* entries. An absent, unreadable, or malformed file has none.
*/
export function readLocalSessionSnapshot(
now = Date.now(),
): LocalSessionSnapshotEntry[] {
let parsed: unknown;
try {
parsed = JSON.parse(readFileSync(snapshotPath(), "utf8"));
} catch (error) {
logger.debug("Could not read the Local session snapshot", error);
return [];
}
if (typeof parsed !== "object" || parsed === null) {
return [];
}
const { pid, sessions } = parsed as Record<string, unknown>;
if (
!(Number.isInteger(pid) && (pid as number) > 0 && Array.isArray(sessions))
) {
return [];
}
if (!isProcessRunning(pid as number)) {
return [];
}
return sessions
.filter(isEntry)
.filter(
({ state, expiresAt }) =>
state === "open" || expiresAt === undefined || expiresAt > now,
);
}
26 changes: 20 additions & 6 deletions packages/cli/src/commands/local/session-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,11 +92,18 @@ function toPublicSession(session: StoredSession): LocalSession {
return publicSession;
}

/** Build a session store with injectable time for deterministic lifecycle tests. */
/**
* Build a session store with injectable time for deterministic lifecycle tests.
*
* @param onChange - Called with every session after one is created, closed,
* or expires; not on activity, which every ingested envelope records
*/
export function createLocalSessionStore({
now = Date.now,
onChange,
}: {
now?: () => number;
onChange?: (sessions: LocalSession[]) => void;
} = {}): LocalSessionStore {
const sessions = new Map<string, StoredSession>();
const clientSessions = new Map<string, string>();
Expand All @@ -105,6 +112,12 @@ export function createLocalSessionStore({
for (const listener of listeners.get(id) ?? []) listener(event);
};

const list = () =>
Array.from(sessions.values(), toPublicSession).sort(
(left, right) => right.lastActivityAt - left.lastActivityAt,
);
const changed = () => onChange?.(list());

const resolveStored = (reference: string): StoredSession => {
const byId = sessions.get(reference);
if (byId) {
Expand Down Expand Up @@ -164,6 +177,7 @@ export function createLocalSessionStore({
if (clientId) {
clientSessions.set(clientId, session.id);
}
changed();
return toPublicSession(session);
},
getRetention(reference) {
Expand All @@ -184,6 +198,7 @@ export function createLocalSessionStore({
expiresAt: timestamp + SESSION_RETENTION_MS,
});
notify(session.id, { state: "retained", expiresAt: closed.expiresAt });
changed();
return toPublicSession(closed);
},
reset(reference) {
Expand Down Expand Up @@ -218,11 +233,7 @@ export function createLocalSessionStore({
stream: session.streamCapability,
};
},
list() {
return [...sessions.values()]
.map(toPublicSession)
.sort((left, right) => right.lastActivityAt - left.lastActivityAt);
},
list,
pruneExpired() {
const timestamp = now();
const expired = [...sessions.values()]
Expand All @@ -241,6 +252,9 @@ export function createLocalSessionStore({
sessions.delete(id);
listeners.delete(id);
}
if (expired.length > 0) {
changed();
}
return expired;
},
recordActivity(reference) {
Expand Down
40 changes: 40 additions & 0 deletions packages/cli/src/lib/complete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,12 @@ const COMPLETERS = Object.freeze({
dsn: flagEntityCompleter("dsn"),
/** Loopback hosts, every interface, then the addresses of local adapters. */
"bind-host": (partial) => completeBindHosts(partial),
/**
* The running Local daemon's sessions, in the first word: in
* `<session> <envelope>`, the second word is an ID only the daemon knows.
*/
"local-session": (partial, slot) =>
slot.positionals.length > 0 ? [] : completeLocalSessions(partial),
/** Cached tag keys of issues, described by their labels. */
"tag-key": flagEntityCompleter("tag-key"),
/** Servers of stored accounts. */
Expand Down Expand Up @@ -893,6 +899,40 @@ function completeBindHosts(partial: string): Completion[] {
.map(([value, description]) => ({ value, description }));
}

/**
* Sessions of the running Local daemon, from the snapshot it keeps for
* completion: their IDs, described by label and state, then the labels
* only one session has, which the commands take too. A label with
* whitespace is left out, since it would not stay one word.
*/
async function completeLocalSessions(partial: string): Promise<Completion[]> {
const { readLocalSessionSnapshot } =
await import("../commands/local/session-snapshot.js");
const sessions = readLocalSessionSnapshot();
const labelCounts = new Map<string, number>();
for (const { label } of sessions) {
labelCounts.set(label, (labelCounts.get(label) ?? 0) + 1);
}
const completions = new Map<string, string>();
for (const { id, label, state } of sessions) {
completions.set(id, label ? `${label} (${state})` : state);
}
for (const { id, label, state } of sessions) {
if (
label &&
labelCounts.get(label) === 1 &&
!WHITESPACE_RE.test(label) &&
!completions.has(label)
) {
completions.set(label, `${state} session ${id}`);
}
}
return Array.from(completions, ([value, description]) => ({
value,
description,
})).filter(({ value }) => value.startsWith(partial));
}

/** Servers of stored accounts, described by the accounts on each. */
function completeAccountServers(partial: string): Completion[] {
const accounts = new Map<string, string[]>();
Expand Down
9 changes: 9 additions & 0 deletions packages/cli/src/lib/completion-target.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,9 @@ export const ARCHIVE_UNTIL_TARGET = "archive-until" satisfies CompletionTarget;
/** Addresses a Local server can bind: loopback, then this machine's adapters. */
export const BIND_HOST_TARGET = "bind-host" satisfies CompletionTarget;

/** The running Local daemon's session IDs and unique labels. */
export const LOCAL_SESSION_TARGET = "local-session" satisfies CompletionTarget;

/** API paths relative to `/api/0/`, from the API schema. */
export const API_ENDPOINT_TARGET = "api-endpoint" satisfies CompletionTarget;

Expand Down Expand Up @@ -292,6 +295,12 @@ export const parseArchiveUntil = identityParser(ARCHIVE_UNTIL_TARGET);
*/
export const parseBindHost = identityParser(BIND_HOST_TARGET);

/**
* Parses a Local session ID or label, which the daemon resolves; completes
* the running daemon's sessions.
*/
export const parseLocalSession = identityParser(LOCAL_SESSION_TARGET);

/** Parses an `api` endpoint; completes API schema paths. */
export const parseApiEndpoint = identityParser(API_ENDPOINT_TARGET);

Expand Down
Loading
Loading