blob: bb1e77cea13f0977feb502025ca342cf3b5ba898 [file]
// 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 {deferChunkedTask} from '../../../base/chunked_task';
import {uuidv4} from '../../../base/uuid';
import protos from '../../../protos';
import type {App} from '../../../public/app';
import {NUM, NUM_NULL, STR} from '../../../trace_processor/query_result';
import {WasmEngineProxy} from '../../../trace_processor/wasm_engine_proxy';
import type {AdbDevice} from '../../dev.perfetto.RecordTraceV2/adb/adb_device';
import {createAdbTracingSession} from '../../dev.perfetto.RecordTraceV2/adb/adb_tracing_session';
import type {TracingSession} from '../../dev.perfetto.RecordTraceV2/interfaces/tracing_session';
import type {TracedWebsocketTarget} from '../../dev.perfetto.RecordTraceV2/traced_over_websocket/traced_websocket_target';
import type {ConnectionResult} from '../views/connection';
import {download} from '../../../base/download_utils';
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.
const POLLING_INTERVAL_MS = 1_000; // Recording config polling interval for process stats and system stats.
let engineCounter = 0;
export interface TsValue {
ts: number;
value: number;
}
export interface LmkEvent {
ts: number;
pid: number;
processName: string;
oomScoreAdj: number;
}
export interface ProcessInfo {
upid: number;
pid: number;
processName: string;
startTs: number | null;
debuggable: boolean;
}
export interface SnapshotData {
// counterName → sorted array of {ts, value}
systemCounters: Map<string, TsValue[]>;
// upid → counterName → sorted array of {ts, value}
processCountersByUpid: Map<number, Map<string, TsValue[]>>;
// upid → metadata
processInfo: Map<number, ProcessInfo>;
// LMK events sorted by ts.
lmkEvents: LmkEvent[];
// Whether the device is a userdebug build.
isUserDebug: boolean;
// Trace start timestamp from trace_bounds, used as the x=0 origin for all charts.
ts0: number;
// X-axis bounds in seconds relative to ts0, derived from the earliest/latest
// counter sample. Use these as xAxisMin/xAxisMax on all line charts.
xMin: number;
xMax: number;
}
export type OnSnapshotCallback = (data: SnapshotData) => void;
/**
* Connects to a device or Linux host, starts a memory tracing session, and
* polls snapshots on a timer. Each snapshot clones the running trace, loads
* it into a reusable TraceProcessor engine, extracts structured data, and
* notifies registered callbacks.
*/
export class LiveSession {
private readonly app: App;
private session?: TracingSession;
private engine?: WasmEngineProxy;
private readonly sessionName: string;
private readonly device?: AdbDevice;
private readonly linuxTarget?: TracedWebsocketTarget;
private timer?: ReturnType<typeof setTimeout>;
private snapshotInFlight = false;
private isDisposed = false;
private readonly onSnapshotCallbacks: OnSnapshotCallback[] = [];
readonly deviceName: string;
snapshotCount = 0;
status = 'Starting tracing session...';
isPaused = false;
data?: SnapshotData;
lastTraceBuffer?: ArrayBuffer;
// Timing breakdown for the last snapshot (ms).
lastSnapshotMs = 0;
lastSnapshotSizeKb = 0;
lastBufferUsagePct?: number;
lastCloneMs = 0;
lastParseMs = 0;
lastQueryMs = 0;
lastExtractMs = 0;
earliestEventTs = 0;
// True when the last snapshot took longer than the configured interval.
snapshotOverrun = false;
// 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;
this.sessionName = `livemem-${uuidv4().substring(0, 8)}`;
this.startAndPoll();
}
/** Register a callback invoked after each successful snapshot. */
onSnapshot(cb: OnSnapshotCallback): void {
this.onSnapshotCallbacks.push(cb);
}
/** Pauses snapshot polling. */
pause(): void {
if (this.isPaused) return;
this.isPaused = true;
clearTimeout(this.timer);
}
/** Resumes snapshot polling. */
resume(): void {
if (!this.isPaused) return;
this.isPaused = false;
if (this.session && !this.snapshotInFlight) {
this.scheduleSnapshot();
}
}
/** Toggles between paused and running. */
togglePause(): void {
if (this.isPaused) {
this.resume();
} else {
this.pause();
}
}
/** 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`;
this.app.openTraceFromBuffer({
buffer: traceData,
title: fileName,
fileName,
});
}
}
/** Stops the active profile and downloads the trace file. */
async stopAndDownloadProfile(): 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`;
await download({
content: new Uint8Array(traceData),
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
// the engine again, and the finally block doesn't reschedule.
this.isDisposed = true;
clearTimeout(this.timer);
if (this.session) {
await this.session.cancel();
this.session = undefined;
}
if (this.engine) {
this.engine[Symbol.dispose]();
this.engine = undefined;
}
}
private async startAndPoll(): Promise<void> {
const config = createMonitoringConfig(this.sessionName);
const result = this.linuxTarget
? await this.linuxTarget.startTracing(config)
: await createAdbTracingSession(this.device!, config);
if (!result.ok) {
this.status = `Failed to start: ${result.error}`;
return;
}
this.session = result.value;
this.status = 'Session started. Waiting for first snapshot...';
this.scheduleSnapshot(INITIAL_SNAPSHOT_INTERVAL_MS); // Initial delay
}
private scheduleSnapshot(delayMs = 0) {
this.timer = setTimeout(() => this.poll(), Math.max(0, delayMs));
}
private async poll(): Promise<void> {
if (!this.session || this.snapshotInFlight) return;
this.snapshotInFlight = true;
try {
const t0 = performance.now();
const [cloneResult, bufferUsagePct] = await Promise.all([
this.session.snapshot(),
this.session.getBufferUsagePct(),
]);
if (!cloneResult.ok) {
this.status = `Snapshot failed: ${cloneResult.error}`;
return;
}
this.lastTraceBuffer = cloneResult.value.buffer as ArrayBuffer;
const tClone = performance.now();
// dispose() may have been called while we were awaiting the snapshot.
// Bail out before touching the engine — getOrCreateEngine() would
// otherwise resurrect a fresh one that nothing will ever dispose.
if (this.isDisposed) return;
const engine = this.getOrCreateEngine();
await engine.resetTraceProcessor({
tokenizeOnly: false,
cropTrackEvents: false,
ingestFtraceInRawTable: false,
analyzeTraceProtoContent: false,
ftraceDropUntilAllCpusValid: false,
forceFullSort: false,
});
await engine.parse(cloneResult.value);
await engine.notifyEof();
const tParse = performance.now();
const result = await extractSnapshotData(engine);
this.data = result.data;
for (const cb of this.onSnapshotCallbacks) {
cb(this.data);
}
this.lastCloneMs = Math.round(tClone - t0);
this.lastParseMs = Math.round(tParse - tClone);
this.lastQueryMs = result.queryMs;
this.lastExtractMs = result.extractMs;
this.lastSnapshotMs = Math.round(performance.now() - t0);
this.lastSnapshotSizeKb = this.lastTraceBuffer.byteLength / 1024;
this.lastBufferUsagePct = bufferUsagePct;
this.snapshotCount++;
this.status = `Snapshot #${this.snapshotCount} OK (${this.lastSnapshotMs}ms)`;
} catch (e) {
this.status = `Snapshot error: ${e}`;
} finally {
this.snapshotInFlight = false;
if (!this.isPaused && !this.isDisposed) {
const nextDelay = SNAPSHOT_INTERVAL_MS - this.lastSnapshotMs;
this.snapshotOverrun = nextDelay < 0;
this.scheduleSnapshot(nextDelay);
}
}
}
private getOrCreateEngine(): WasmEngineProxy {
if (this.engine === undefined) {
this.engine = new WasmEngineProxy(`livemem-${++engineCounter}`);
}
return this.engine;
}
}
function createMonitoringConfig(
uniqueSessionName: string,
): protos.ITraceConfig {
return {
uniqueSessionName,
buffers: [
{
name: 'initial_stats',
sizeKb: 4 * 1024,
fillPolicy: protos.TraceConfig.BufferConfig.FillPolicy.DISCARD,
},
{
name: 'polled_stats',
sizeKb: 4 * 1024,
fillPolicy: protos.TraceConfig.BufferConfig.FillPolicy.RING_BUFFER,
},
{
name: 'ftrace',
sizeKb: 4 * 1024,
fillPolicy: protos.TraceConfig.BufferConfig.FillPolicy.RING_BUFFER,
},
],
dataSources: [
{
config: {
name: 'linux.process_stats',
targetBufferName: 'initial_stats',
processStatsConfig: {
scanAllProcessesOnStart: true,
recordProcessAge: true,
},
},
},
{
config: {
name: 'android.packages_list',
targetBufferName: 'initial_stats',
},
},
{
config: {
name: 'linux.process_stats',
targetBufferName: 'polled_stats',
processStatsConfig: {
scanAllProcessesOnStart: false,
procStatsPollMs: POLLING_INTERVAL_MS,
recordProcessDmabufRss: true,
},
},
},
{
config: {
name: 'linux.sys_stats',
targetBufferName: 'polled_stats',
sysStatsConfig: {
meminfoPeriodMs: POLLING_INTERVAL_MS,
psiPeriodMs: POLLING_INTERVAL_MS,
meminfoCounters: [
protos.MeminfoCounters.MEMINFO_MEM_TOTAL,
protos.MeminfoCounters.MEMINFO_MEM_FREE,
protos.MeminfoCounters.MEMINFO_MEM_AVAILABLE,
protos.MeminfoCounters.MEMINFO_BUFFERS,
protos.MeminfoCounters.MEMINFO_CACHED,
protos.MeminfoCounters.MEMINFO_SWAP_TOTAL,
protos.MeminfoCounters.MEMINFO_SWAP_FREE,
protos.MeminfoCounters.MEMINFO_SWAP_CACHED,
protos.MeminfoCounters.MEMINFO_SHMEM,
protos.MeminfoCounters.MEMINFO_ACTIVE_ANON,
protos.MeminfoCounters.MEMINFO_INACTIVE_ANON,
protos.MeminfoCounters.MEMINFO_ACTIVE_FILE,
protos.MeminfoCounters.MEMINFO_INACTIVE_FILE,
protos.MeminfoCounters.MEMINFO_ANON_PAGES,
protos.MeminfoCounters.MEMINFO_SLAB,
protos.MeminfoCounters.MEMINFO_KERNEL_STACK,
protos.MeminfoCounters.MEMINFO_PAGE_TABLES,
protos.MeminfoCounters.MEMINFO_ZRAM,
protos.MeminfoCounters.MEMINFO_MAPPED,
protos.MeminfoCounters.MEMINFO_DIRTY,
protos.MeminfoCounters.MEMINFO_WRITEBACK,
],
vmstatPeriodMs: POLLING_INTERVAL_MS,
vmstatCounters: [
protos.VmstatCounters.VMSTAT_PSWPIN,
protos.VmstatCounters.VMSTAT_PSWPOUT,
protos.VmstatCounters.VMSTAT_PGFAULT,
protos.VmstatCounters.VMSTAT_PGMAJFAULT,
protos.VmstatCounters.VMSTAT_WORKINGSET_REFAULT_FILE,
protos.VmstatCounters.VMSTAT_PGSTEAL_FILE,
protos.VmstatCounters.VMSTAT_PGSCAN_FILE,
],
},
},
},
{
config: {
name: 'linux.ftrace',
targetBufferName: 'ftrace',
ftraceConfig: {
ftraceEvents: [
'dmabuf_heap/dma_heap_stat',
'sched/sched_process_exit',
'sched/sched_process_free',
'task/task_newtask',
'task/task_rename',
'lowmemorykiller/lowmemory_kill',
// 'oom/oom_score_adj_update',
],
atraceApps: ['lmkd'],
disableGenericEvents: true,
},
},
},
],
};
}
interface ExtractResult {
data: SnapshotData;
queryMs: number;
extractMs: number;
}
async function extractSnapshotData(
engine: WasmEngineProxy,
): Promise<ExtractResult> {
const tQueryStart = performance.now();
const [
ts0Result,
sysTrackResult,
procTrackResult,
samplesResult,
rateSamplesResult,
metaResult,
lmkResult,
buildResult,
] = await Promise.all([
engine.query(`
SELECT
(SELECT start_ts FROM trace_bounds) as ts0,
MIN(c.ts) as min_ts,
MAX(c.ts) as max_ts
FROM counter c
JOIN counter_track t ON c.track_id = t.id
WHERE t.name = 'MemTotal'
`),
engine.query(`
SELECT id, name
FROM counter_track
WHERE id NOT IN (SELECT id FROM process_counter_track)
`),
engine.query(`
SELECT t.id, p.upid, p.name AS process_name, p.pid,
t.name AS counter_name
FROM process_counter_track t
JOIN process p ON t.upid = p.upid
WHERE p.name IS NOT NULL
`),
// Level counters (meminfo, RSS, oom_score_adj, etc). Pass through as-is.
// Excludes psi/vmstat which are cumulative monotonic counters — those
// are diffed into per-second rates by the next query.
engine.query(`
SELECT c.track_id AS trackId, c.ts, c.value
FROM counter c
JOIN track t ON c.track_id = t.id
WHERE t.type NOT IN ('psi', 'vmstat')
ORDER BY c.ts
`),
// Cumulative monotonic kernel counters (psi, vmstat). Diff each track in
// SQL via LAG so the UI never sees the raw level. Both end up per-second:
// - psi: ns_stall_delta / s_dt → divide by 1e6 to get ms/s.
// - vmstat: count_delta / s_dt → events/s.
// ts is in ns, so dt_s = (ts - prev_ts) / 1e9; the algebra simplifies to
// the expressions below. First-of-track rows (prev_ts IS NULL) are
// dropped.
engine.query(`
WITH diffed AS (
SELECT
c.track_id AS trackId,
c.ts,
t.type AS type,
c.value - LAG(c.value) OVER w AS dvalue,
c.ts - LAG(c.ts) OVER w AS dts
FROM counter c
JOIN track t ON c.track_id = t.id
WHERE t.type IN ('psi', 'vmstat')
WINDOW w AS (PARTITION BY c.track_id ORDER BY c.ts)
)
SELECT trackId, ts,
MAX(0, CASE
WHEN type = 'psi' THEN dvalue * 1e3 / dts
ELSE dvalue * 1e9 / dts
END) AS value
FROM diffed
WHERE dts > 0
ORDER BY ts
`),
engine.query(`
SELECT p.upid, p.name, p.pid, p.start_ts,
COALESCE(pkg.profileable_from_shell, 0) AS debuggable
FROM process p
LEFT JOIN package_list pkg ON p.uid = pkg.uid
WHERE p.name IS NOT NULL
`),
engine.query(`
SELECT ts,
CAST(str_split(s.name, ',', 1) AS INTEGER) AS pid,
CAST(str_split(s.name, ',', 3) AS INTEGER) AS oom_score_adj,
COALESCE(p.name, '') AS process_name
FROM slice s
LEFT JOIN process_track pt ON s.track_id = pt.id
LEFT JOIN process p ON CAST(str_split(s.name, ',', 1) AS INTEGER) = p.pid
AND s.ts >= COALESCE(p.start_ts, 0)
AND s.ts <= COALESCE(p.end_ts, (SELECT MAX(ts) FROM counter))
WHERE s.name GLOB 'lmk,*'
UNION ALL
SELECT c.ts,
CAST(c.value AS INTEGER) AS pid,
0 AS oom_score_adj,
COALESCE(p.name, '') AS process_name
FROM counter c
JOIN counter_track ct ON c.track_id = ct.id
LEFT JOIN process p ON CAST(c.value AS INTEGER) = p.pid
AND c.ts >= COALESCE(p.start_ts, 0)
AND c.ts <= COALESCE(p.end_ts, (SELECT MAX(ts) FROM counter))
WHERE ct.name = 'kill_one_process' AND c.value > 0
ORDER BY ts
`),
engine.query(`
SELECT str_value AS fingerprint
FROM metadata
WHERE name = 'android_build_fingerprint'
LIMIT 1
`),
]);
const tExtractStart = performance.now();
const task = await deferChunkedTask({priority: 'background'});
const YIELD_CHECK_INTERVAL = 64; // Num loops before yield check
const boundsRow = ts0Result.firstRow({ts0: NUM, min_ts: NUM, max_ts: NUM});
const ts0 = boundsRow?.ts0 ?? 0;
const xMin = boundsRow !== undefined ? (boundsRow.min_ts - ts0) / 1e9 : 0;
const xMax = boundsRow !== undefined ? (boundsRow.max_ts - ts0) / 1e9 : 0;
// Build track id → counter name map for system counters.
const sysTrackMap = new Map<number, string>();
const sysTrackIter = sysTrackResult.iter({id: NUM, name: STR});
for (; sysTrackIter.valid(); sysTrackIter.next()) {
sysTrackMap.set(sysTrackIter.id, sysTrackIter.name);
}
// Build track id → {upid, processName, pid, counterName} for process counters.
const procTrackMap = new Map<
number,
{upid: number; processName: string; pid: number; counterName: string}
>();
const procTrackIter = procTrackResult.iter({
id: NUM,
upid: NUM,
process_name: STR,
pid: NUM,
counter_name: STR,
});
for (; procTrackIter.valid(); procTrackIter.next()) {
procTrackMap.set(procTrackIter.id, {
upid: procTrackIter.upid,
processName: procTrackIter.process_name,
pid: procTrackIter.pid,
counterName: procTrackIter.counter_name,
});
}
// Single pass over all counter samples, dispatching by track type.
const systemCounters = new Map<string, TsValue[]>();
const processCountersByUpid = new Map<number, Map<string, TsValue[]>>();
const samplesIter = samplesResult.iter({trackId: NUM, ts: NUM, value: NUM});
for (let i = 0; samplesIter.valid(); samplesIter.next(), ++i) {
if (i % YIELD_CHECK_INTERVAL === 0 && task.shouldYield()) {
await task.yield();
}
const {trackId, ts, value} = samplesIter;
const sysName = sysTrackMap.get(trackId);
if (sysName !== undefined) {
let arr = systemCounters.get(sysName);
if (arr === undefined) {
arr = [];
systemCounters.set(sysName, arr);
}
arr.push({ts, value});
continue;
}
const proc = procTrackMap.get(trackId);
if (proc !== undefined) {
let counters = processCountersByUpid.get(proc.upid);
if (counters === undefined) {
counters = new Map();
processCountersByUpid.set(proc.upid, counters);
}
let arr = counters.get(proc.counterName);
if (arr === undefined) {
arr = [];
counters.set(proc.counterName, arr);
}
arr.push({ts, value});
}
}
// Rate-converted psi/vmstat samples land in systemCounters under the same
// counter names as the levels would have — the UI just sees per-second
// rates (ms/s for psi, events/s for vmstat).
const rateIter = rateSamplesResult.iter({trackId: NUM, ts: NUM, value: NUM});
for (let i = 0; rateIter.valid(); rateIter.next(), ++i) {
if (i % YIELD_CHECK_INTERVAL === 0 && task.shouldYield()) {
await task.yield();
}
const {trackId, ts, value} = rateIter;
const sysName = sysTrackMap.get(trackId);
if (sysName === undefined) continue;
let arr = systemCounters.get(sysName);
if (arr === undefined) {
arr = [];
systemCounters.set(sysName, arr);
}
arr.push({ts, value});
}
// Build process metadata, keyed by upid.
const processInfo = new Map<number, ProcessInfo>();
const metaIter = metaResult.iter({
upid: NUM,
name: STR,
pid: NUM,
start_ts: NUM_NULL,
debuggable: NUM,
});
for (let i = 0; metaIter.valid(); metaIter.next(), ++i) {
if (i % YIELD_CHECK_INTERVAL === 0 && task.shouldYield()) {
await task.yield();
}
processInfo.set(metaIter.upid, {
upid: metaIter.upid,
pid: metaIter.pid,
processName: metaIter.name,
startTs: metaIter.start_ts,
debuggable: metaIter.debuggable !== 0,
});
}
// Build LMK events.
const lmkEvents: LmkEvent[] = [];
const lmkIter = lmkResult.iter({
ts: NUM,
pid: NUM,
oom_score_adj: NUM,
process_name: STR,
});
for (let i = 0; lmkIter.valid(); lmkIter.next(), ++i) {
if (i % YIELD_CHECK_INTERVAL === 0 && task.shouldYield()) {
await task.yield();
}
lmkEvents.push({
ts: lmkIter.ts,
pid: lmkIter.pid,
processName: lmkIter.process_name,
oomScoreAdj: lmkIter.oom_score_adj,
});
}
// Check if device is userdebug.
const buildRow = buildResult.maybeFirstRow({fingerprint: STR});
const isUserDebug = buildRow?.fingerprint?.includes('userdebug') ?? false;
return {
data: {
systemCounters,
processCountersByUpid,
processInfo,
lmkEvents,
isUserDebug,
ts0,
xMin,
xMax,
},
queryMs: Math.round(tExtractStart - tQueryStart),
extractMs: Math.round(performance.now() - tExtractStart),
};
}