Skip to content
Open
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
14 changes: 12 additions & 2 deletions profiler-cli/guide.txt
Original file line number Diff line number Diff line change
Expand Up @@ -408,7 +408,9 @@ SCRIPTING

When using profiler-cli in scripts or pipelines:

Always use a named session for isolation:
Always use a named session for isolation, and set PROFILER_CLI_SESSION_OWNER
so "stop" refuses sessions that are not yours:
export PROFILER_CLI_SESSION_OWNER=my-script
profiler-cli load profile.json.gz --session my-analysis
profiler-cli profile info --session my-analysis
profiler-cli thread select t-0 --session my-analysis
Expand Down Expand Up @@ -442,11 +444,19 @@ SESSION MANAGEMENT
profiler-cli session use <id> Switch the current session
profiler-cli stop Stop the current session
profiler-cli stop <id> Stop a specific session
profiler-cli stop --all Stop all sessions
profiler-cli stop --all Stop all sessions you own
profiler-cli stop <id> --force Stop a session owned by someone else
profiler-cli load profile.json.gz --session my-session Named session
profiler-cli load profile.json.gz --symbol-server <url> Override symbol server (else ?symbolServer= URL param or Mozilla default)
profiler-cli thread info --session my-session Query a specific session

Sessions in one directory are shared by every profiler-cli process, so each
records its creator and "stop" refuses sessions that are not yours (exit 1,
naming the owner); --force overrides. Set PROFILER_CLI_SESSION_OWNER to name
yourself, otherwise the owner is the parent process id -- so a "stop" from a
different live shell is refused. A pid owner is released once that process
exits, since detached daemons outlive the shell that started them; a named
owner is never released. See "profiler-cli stop --help".

ERROR HANDLING

Expand Down
62 changes: 60 additions & 2 deletions profiler-cli/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,22 @@ import type {
ClientMessage,
ServerResponse,
CommandResult,
SessionMetadata,
} from './protocol';
import {
cleanupIfDaemonGone,
cleanupSession,
generateSessionId,
getCurrentSessionId,
getCurrentSocketPath,
describeSessionOwner,
getLogPath,
getLogSize,
getSessionAge,
getSessionOwner,
getSocketPath,
ownsSession,
SESSION_OWNER_ENV_VAR,
getStartupErrorPath,
isDaemonReachable,
loadSessionMetadata,
Expand Down Expand Up @@ -508,7 +514,13 @@ export async function startNewDaemon(
{
detached: true,
stdio: 'ignore', // Don't pipe stdin/stdout/stderr
env: { ...process.env, PROFILER_CLI_SESSION_DIR: sessionDir }, // Pass sessionDir via env
env: {
...process.env,
PROFILER_CLI_SESSION_DIR: sessionDir, // Pass sessionDir via env
// Resolved here, not in the daemon: the fallback is the parent pid, and
// the daemon's parent is this process, which is about to exit.
[SESSION_OWNER_ENV_VAR]: getSessionOwner(),
},
}
);

Expand Down Expand Up @@ -678,16 +690,53 @@ export async function startNewDaemon(
);
}

/** One line describing a session about to be stopped: which, whose, how old. */
export function describeSessionForStop(metadata: SessionMetadata): string {
const age = getSessionAge(metadata);
return [
`${metadata.id}`,
`owner ${describeSessionOwner(metadata)}`,
`daemon pid ${metadata.pid}`,
...(age === null ? [] : [`age ${age}`]),
].join(', ');
}

/** Thrown when `stop` is asked to stop somebody else's session. */
export class SessionNotOwnedError extends Error {
readonly metadata: SessionMetadata;

constructor(metadata: SessionMetadata, owner: string) {
const recorded = describeSessionOwner(metadata);
// A refused pid owner is still running, so pointing at it beats telling the
// caller to adopt a live process's identity by guessing its number.
const advice = recorded.startsWith('pid:')
? `The owning process (${recorded.slice('pid:'.length)}) still appears to be running. Pass --force to stop the session anyway.`
: `Pass --force to stop it anyway, or set ${SESSION_OWNER_ENV_VAR}=${recorded} if these sessions are yours.`;
super(
[
`Session ${metadata.id} belongs to ${recorded}, not to you (${owner}), so it was not stopped.`,
` ${describeSessionForStop(metadata)}`,
advice,
].join('\n')
);
this.name = 'SessionNotOwnedError';
this.metadata = metadata;
}
}

/**
* Stop a running daemon.
*
* Only reports success once the daemon is known to be gone. One that merely
* cannot be reached may still be running, and saying it stopped would leave
* the user with a process no command can find.
*
* Refuses sessions created by a different owner unless `force` is set.
*/
export async function stopDaemon(
sessionDir: string,
sessionId?: string
sessionId?: string,
options: { force?: boolean; announce?: boolean } = {}
): Promise<void> {
const resolvedSessionId = sessionId || getCurrentSessionId(sessionDir);

Expand All @@ -702,6 +751,15 @@ export async function stopDaemon(
return;
}

const owner = getSessionOwner();
if (!options.force && !ownsSession(metadata, owner)) {
throw new SessionNotOwnedError(metadata, owner);
}

if (options.announce) {
console.log(`Stopping session ${describeSessionForStop(metadata)}`);
}

try {
await sendMessage(sessionDir, { type: 'shutdown' }, resolvedSessionId);
} catch (error) {
Expand Down
28 changes: 24 additions & 4 deletions profiler-cli/src/commands/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,13 @@ import { wasExplicit } from './shared';
import {
cleanupIfDaemonGone,
cleanupSession,
describeSessionOwner,
getCurrentSessionId,
getSessionAge,
getSessionOwner,
listSessions,
loadSessionMetadata,
ownsSession,
setCurrentSession,
validateSession,
} from '../session';
Expand All @@ -35,7 +39,7 @@ export function registerSessionCommand(

session
.command('list', { isDefault: true })
.description('List all running daemon sessions')
.description('List all running daemon sessions, with their owner and age')
.action(async () => {
const sessionIds = listSessions(sessionDir);
let numCleaned = 0;
Expand Down Expand Up @@ -90,12 +94,15 @@ export function registerSessionCommand(
);

const currentSessionId = getCurrentSessionId(sessionDir);
const owner = getSessionOwner();
console.log(`Found ${runningSessionMetadata.length} running sessions:`);
for (const metadata of runningSessionMetadata) {
const isCurrent = metadata.id === currentSessionId;
const marker = isCurrent ? '* ' : ' ';
const age = getSessionAge(metadata);
const mine = ownsSession(metadata, owner) ? ' (yours)' : '';
console.log(
`${marker}${metadata.id}, created at ${metadata.createdAt} [daemon pid: ${metadata.pid}]`
`${marker}${metadata.id}, created at ${metadata.createdAt}${age === null ? '' : ` (${age} ago)`} [owner: ${describeSessionOwner(metadata)}${mine}, daemon pid: ${metadata.pid}]`
);
}

Expand All @@ -106,7 +113,7 @@ export function registerSessionCommand(
);
for (const { metadata, error } of unreachableSessions) {
console.log(
` ${metadata.id} [daemon pid: ${metadata.pid}]: ${toErrorMessage(error)}`
` ${metadata.id} [owner: ${describeSessionOwner(metadata)}, daemon pid: ${metadata.pid}]: ${toErrorMessage(error)}`
);
}
if (unreachableSessions.some(({ error }) => isPermissionErrno(error))) {
Expand All @@ -124,7 +131,9 @@ export function registerSessionCommand(

session
.command('use <id>')
.description('Switch the current session')
.description(
'Switch the current session (shared with every caller of this session directory)'
)
.action(async (sessionId: string) => {
const metadata = await validateSession(sessionDir, sessionId);
if (metadata === null) {
Expand All @@ -135,5 +144,16 @@ export function registerSessionCommand(
}
setCurrentSession(sessionDir, sessionId);
console.log(`Switched to session ${sessionId}`);
// Switching this pointer also redirects other callers' unqualified
// commands, so warn rather than doing it silently.
const owner = getSessionOwner();
if (!ownsSession(metadata, owner)) {
console.log(
`Note: session ${sessionId} is owned by ${describeSessionOwner(metadata)}, not you (${owner}).`
);
}
console.log(
'Note: the current session is shared state for this session directory. Pass --session <id> instead to avoid affecting other callers.'
);
});
}
15 changes: 12 additions & 3 deletions profiler-cli/src/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import type {
} from './protocol';
import {
generateSessionId,
getSessionOwner,
getSocketPath,
getLogPath,
saveSessionMetadata,
Expand Down Expand Up @@ -78,17 +79,22 @@ export class Daemon {
private profileLoadError: string | null = null;
private isListening: boolean = false;
private hasPublishedMetadata: boolean = false;
private owner: string;

constructor(
sessionDir: string,
profilePath: string,
sessionId?: string,
symbolServerUrl?: string
symbolServerUrl?: string,
owner?: string
) {
this.sessionDir = sessionDir;
this.profilePath = profilePath;
this.sessionId = sessionId || generateSessionId();
this.symbolServerUrl = symbolServerUrl;
// The client passes the owner in the spawn environment; the fallback is
// only for a daemon started by hand with --daemon.
this.owner = owner ?? getSessionOwner();
this.socketPath = getSocketPath(sessionDir, this.sessionId);
this.logPath = getLogPath(sessionDir, this.sessionId);

Expand Down Expand Up @@ -226,6 +232,7 @@ export class Daemon {
profilePath: this.profilePath,
createdAt: new Date().toISOString(),
buildHash: BUILD_HASH,
owner: this.owner,
};
try {
saveSessionMetadata(this.sessionDir, metadata);
Expand Down Expand Up @@ -601,13 +608,15 @@ export async function startDaemon(
sessionDir: string,
profilePath: string,
sessionId?: string,
symbolServerUrl?: string
symbolServerUrl?: string,
owner?: string
): Promise<void> {
const daemon = new Daemon(
sessionDir,
profilePath,
sessionId,
symbolServerUrl
symbolServerUrl,
owner
);
await daemon.start();
}
Loading
Loading