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
51 changes: 51 additions & 0 deletions ui/src/plugins/dev.perfetto.Memscope/components/progress_bar.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// Copyright (C) 2026 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

.pf-progress {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 10px;
font-size: var(--pf-font-size-s);
color: var(--pf-color-text-muted);
}

.pf-progress__label {
font-weight: 600;
}

.pf-progress__track {
height: 6px;
border-radius: 3px;
background: var(--pf-color-background);
overflow: hidden;
}

.pf-progress__fill {
height: 100%;
background: var(--pf-color-primary);
transition: width 0.3s ease;
}

.pf-progress__value {
font-variant-numeric: tabular-nums;
font-weight: 600;
color: var(--pf-color-text);
white-space: nowrap;
}

.pf-progress__suffix {
color: var(--pf-color-text-muted);
font-weight: 400;
}
48 changes: 48 additions & 0 deletions ui/src/plugins/dev.perfetto.Memscope/components/progress_bar.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// Copyright (C) 2026 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import m from 'mithril';
import {clamp} from '../../../base/math_utils';

export interface ProgressBarAttrs {
// Percentage 0..100. Values outside the range are clamped.
readonly pct: number;
// Optional label shown to the left of the track.
readonly label?: m.Children;
// Optional value shown to the right. If omitted, "<pct>%" is shown.
readonly value?: m.Children;
// Optional muted suffix appended after the value (e.g. " / 640 MB").
readonly suffix?: m.Children;
}

export class ProgressBar implements m.ClassComponent<ProgressBarAttrs> {
view({attrs}: m.Vnode<ProgressBarAttrs>) {
const clamped = clamp(attrs.pct, 0, 100);
const value = attrs.value ?? `${clamped.toFixed(1)}%`;
return m(
'.pf-progress',
attrs.label !== undefined && m('.pf-progress__label', attrs.label),
m(
'.pf-progress__track',
m('.pf-progress__fill', {style: {width: `${clamped}%`}}),
),
m(
'.pf-progress__value',
value,
attrs.suffix !== undefined &&
m('span.pf-progress__suffix', attrs.suffix),
),
);
}
}
2 changes: 1 addition & 1 deletion ui/src/plugins/dev.perfetto.Memscope/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ export default class implements PerfettoPlugin {
} else {
return m(ConnectionPage, {
onConnected: (result) => {
session = new LiveSession(result);
session = new LiveSession(app, result);
session.onSnapshot(() => m.redraw());
},
});
Expand Down
67 changes: 66 additions & 1 deletion ui/src/plugins/dev.perfetto.Memscope/sessions/live_session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,18 @@ import {AdbDevice} from '../../dev.perfetto.RecordTraceV2/adb/adb_device';
import {createAdbTracingSession} from '../../dev.perfetto.RecordTraceV2/adb/adb_tracing_session';
import {TracingSession} from '../../dev.perfetto.RecordTraceV2/interfaces/tracing_session';
import {TracedWebsocketTarget} from '../../dev.perfetto.RecordTraceV2/traced_over_websocket/traced_websocket_target';
import {App} from '../../../public/app';
import {ConnectionResult} from '../views/connection';
import {ProfileSession, type ProfileState} from './profile_session';

export interface ProfileView {
readonly pid: number;
readonly processName: string;
readonly state: ProfileState;
readonly bufferUsagePct?: number;
/** Wall-clock timestamp (ms) at which profiling started. */
readonly startMs: number;
}

const SNAPSHOT_INTERVAL_MS = 3_000; // How over to take a snapshot of the runnign trace and extract data.
const INITIAL_SNAPSHOT_INTERVAL_MS = 1_000; // Use a shorter interval for the first snapshot to get data on screen faster.
Expand Down Expand Up @@ -83,6 +94,7 @@ export type OnSnapshotCallback = (data: SnapshotData) => void;
* notifies registered callbacks.
*/
export class LiveSession {
private readonly app: App;
private session?: TracingSession;
private engine?: WasmEngineProxy;
private readonly sessionName: string;
Expand Down Expand Up @@ -113,7 +125,23 @@ export class LiveSession {
// True when the last snapshot took longer than the configured interval.
snapshotOverrun = false;

constructor(conn: ConnectionResult) {
// Active process profile (if any). Undefined when no profile is running.
private profileImpl?: {session: ProfileSession; startMs: number};

get profile(): ProfileView | undefined {
const p = this.profileImpl;
if (p === undefined) return undefined;
return {
pid: p.session.pid,
processName: p.session.processName,
state: p.session.state,
bufferUsagePct: p.session.bufferUsagePct,
startMs: p.startMs,
};
}

constructor(app: App, conn: ConnectionResult) {
this.app = app;
this.device = conn.device;
this.linuxTarget = conn.linuxTarget;
this.deviceName = conn.deviceName;
Expand Down Expand Up @@ -151,6 +179,43 @@ export class LiveSession {
}
}

/** Starts a heap profiling session for a single process. */
async startProfile(pid: number, processName: string): Promise<void> {
if (this.profileImpl) {
await this.profileImpl.session.cancel();
}
const session = await ProfileSession.start(
this.linuxTarget ?? this.device!,
pid,
processName,
this.data?.xMax ?? 0,
);
this.profileImpl = {session, startMs: Date.now()};
}

/** Stops the active profile and opens the trace in the main UI. */
async stopAndOpenProfile(): Promise<void> {
const profile = this.profileImpl?.session;
if (!profile) return;
const processName = profile.processName;
const pid = profile.pid;
await profile.stop();
const traceData = profile.getTraceData();
this.profileImpl = undefined;
if (traceData) {
const fileName = `heap-${processName}-${pid}.perfetto-trace`;
const buffer = traceData.buffer as ArrayBuffer;
this.app.openTraceFromBuffer({buffer, title: fileName, fileName});
}
}

/** Cancels the active profile and discards data. */
async cancelProfile(): Promise<void> {
if (!this.profileImpl) return;
await this.profileImpl.session.cancel();
this.profileImpl = undefined;
}

/** Stops the tracing session, polling, and disposes of the engine. */
async dispose(): Promise<void> {
// Mark disposed first so any in-flight poll() bails out before touching
Expand Down
188 changes: 188 additions & 0 deletions ui/src/plugins/dev.perfetto.Memscope/sessions/profile_session.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
// Copyright (C) 2026 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import protos from '../../../protos';
import {AdbDevice} from '../../dev.perfetto.RecordTraceV2/adb/adb_device';
import {createAdbTracingSession} from '../../dev.perfetto.RecordTraceV2/adb/adb_tracing_session';
import {TracingSession} from '../../dev.perfetto.RecordTraceV2/interfaces/tracing_session';
import {TracedWebsocketTarget} from '../../dev.perfetto.RecordTraceV2/traced_over_websocket/traced_websocket_target';

const DUMP_INTERVAL_MS = 10_000;
const PROC_STATS_BUFFER_SIZE_KB = 4 * 1024;
const HEAPPROFD_BUFFER_SIZE_KB = 128 * 1024;
const JAVA_HPROF_BUFFER_SIZE_KB = 256 * 1024;
const STATS_POLL_INTERVAL_MS = 3000;

export type ProfileState = 'recording' | 'stopping' | 'finished' | 'error';

export class ProfileSession {
readonly pid: number;
readonly processName: string;
readonly startX: number;

private inner?: TracingSession;
private intervalHandle?: ReturnType<typeof setInterval>;
private _state: ProfileState = 'recording';
private _error?: string;
private _bufferUsagePct?: number;

private constructor(pid: number, processName: string, startX: number) {
this.pid = pid;
this.processName = processName;
this.startX = startX;
}

static async start(
targetOrDevice: TracedWebsocketTarget | AdbDevice,
pid: number,
processName: string,
startX: number,
): Promise<ProfileSession> {
const self = new ProfileSession(pid, processName, startX);
const config = buildProcessProfileConfig(pid);
const result =
targetOrDevice instanceof TracedWebsocketTarget
? await targetOrDevice.startTracing(config)
: await createAdbTracingSession(targetOrDevice, config);
if (!result.ok) {
self._state = 'error';
self._error = `Failed to start profile: ${result.error}`;
return self;
}
self.inner = result.value;
self.intervalHandle = setInterval(async () => {
self._bufferUsagePct = await self.inner!.getBufferUsagePct();
}, STATS_POLL_INTERVAL_MS);
self.inner.onSessionUpdate.addListener(() => {
const s = self.inner!.state;
if (s === 'FINISHED') {
self._state = 'finished';
} else if (s === 'ERRORED') {
self._state = 'error';
self._error = self
.inner!.logs.filter((l) => l.isError)
.map((l) => l.message)
.join('; ');
}
});
return self;
}

get state(): ProfileState {
return this._state;
}

get error(): string | undefined {
return this._error;
}

get bufferUsagePct(): number | undefined {
return this._bufferUsagePct;
}

/** Stops recording and waits for the trace data to be ready. */
async stop(): Promise<void> {
if (this._state !== 'recording' || this.inner === undefined) return;
clearInterval(this.intervalHandle);
this._state = 'stopping';
await this.inner.stop();
if (this.inner.state !== 'FINISHED') {
await new Promise<void>((resolve) => {
const sub = this.inner!.onSessionUpdate.addListener(() => {
const s = this.inner!.state;
if (s === 'FINISHED' || s === 'ERRORED') {
sub[Symbol.dispose]();
resolve();
}
});
});
}
this._state = this.inner.state === 'FINISHED' ? 'finished' : 'error';
}

/** Cancels recording and discards trace data. */
async cancel(): Promise<void> {
if (this._state !== 'recording' || this.inner === undefined) return;
clearInterval(this.intervalHandle);
this._state = 'error';
await this.inner.cancel();
}

/** Returns the trace buffer once state is 'finished'. */
getTraceData(): Uint8Array | undefined {
return this.inner?.getTraceData();
}
}

function buildProcessProfileConfig(pid: number): protos.ITraceConfig {
return {
compressionType:
protos.TraceConfig.CompressionType.COMPRESSION_TYPE_DEFLATE,
buffers: [
{
name: 'process_stats',
sizeKb: PROC_STATS_BUFFER_SIZE_KB,
fillPolicy: protos.TraceConfig.BufferConfig.FillPolicy.DISCARD,
},
{
name: 'heapprofd',
sizeKb: HEAPPROFD_BUFFER_SIZE_KB,
fillPolicy: protos.TraceConfig.BufferConfig.FillPolicy.RING_BUFFER,
},
{
name: 'java_hprof',
sizeKb: JAVA_HPROF_BUFFER_SIZE_KB,
fillPolicy: protos.TraceConfig.BufferConfig.FillPolicy.RING_BUFFER,
},
],
dataSources: [
{
config: {
name: 'linux.process_stats',
targetBufferName: 'process_stats',
processStatsConfig: {
scanAllProcessesOnStart: true, // Necessary for track names.
},
},
},
{
config: {
name: 'android.heapprofd',
targetBufferName: 'heapprofd',
heapprofdConfig: {
pid: [pid],
samplingIntervalBytes: 32 * 1024, // Slightly larger than default to reduce overhead.
shmemSizeBytes: 16 * 1024 * 1024, // Arbitrary, could use default.
blockClient: true, // Important for trace integrity.
continuousDumpConfig: {
dumpIntervalMs: DUMP_INTERVAL_MS, // Important for getting regular heap snapshots to see how memory usage evolves over time.
},
},
},
},
{
config: {
name: 'android.java_hprof',
targetBufferName: 'java_hprof',
javaHprofConfig: {
pid: [pid],
continuousDumpConfig: {
dumpIntervalMs: DUMP_INTERVAL_MS, // Required for Java profiles.
},
},
},
},
],
};
}
Loading
Loading