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
3 changes: 0 additions & 3 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,9 +106,6 @@ The refactor is substantively done; these follow-ups are intentionally deferred,
- Phase 2c — narrow the ~15 remaining `Record`-typed client methods in
`src/client/client-types.ts` to their existing typed contracts (a semver-relevant public-API
narrowing; not yet done).
- b.3 perf sampling body — all four `PlatformPlugin` daemon facets now route through the plugin
(`appLog`, the `perf` support gate, `recording`, and `providers`; the last two via #1007). Only
the `perf` sampling body (`buildPerfResponseData`) still branches in the daemon.
- Strict DAG back-edge inversion — the layering lint prevents target-spine back-edge growth, but
the full zero-back-edge DAG (e.g. `commands` → `cli`/`client`) is not done.
- Legacy alias drops — ~175 LOC of legacy aliases/barrels remain, gated to the next major.
Expand Down
6 changes: 4 additions & 2 deletions src/core/interactors/register-builtins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,10 @@ const androidPlugin = {
// Wraps the Android arm of `resolveLogBackend`: every Android device -> 'android'.
appLog: { resolveBackend: () => 'android' },
// Wraps the Android arm of `supportsPlatformPerfMetrics`: every Android device
// reports perf-metrics support.
perf: { supportsMetrics: () => true },
// reports perf-metrics support. `metricsSamplerTag` wraps the Android arm of the
// former `buildPerfResponseData` sampling branch: every supported Android device
// routes to the Android `perf metrics` sampler.
perf: { supportsMetrics: () => true, metricsSamplerTag: () => 'android' },
// Wraps the Android arm of `resolveRecordingBackendForDevice`: every Android device
// resolves to the android recording backend.
recording: { resolveBackendTag: () => 'android' },
Expand Down
29 changes: 21 additions & 8 deletions src/core/platform-plugin/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { AppError } from '../../kernel/errors.ts';
import type { DeviceInfo, Platform, PlatformSelector } from '../../kernel/device.ts';
import type { LogBackend } from '../../daemon/network-log.ts';
import type { RecordingBackendTag } from '../../daemon/handlers/record-trace-recording-backends.ts';
import type { PerfMetricsSamplerTag } from '../../daemon/handlers/session-perf.ts';
import type { PlatformGatedProviderResolverKey } from '../../daemon/request-platform-providers.ts';
import type { Interactor, RunnerContext } from '../interactor-types.ts';
import type { DeviceInventoryRequest } from '../platform-inventory.ts';
Expand All @@ -28,16 +29,18 @@ import type { CapabilityBucket } from '../platform-descriptor/types.ts';
* {@link PlatformPlugin.appLog} carries the neutral {@link LogBackend} resolver
* (wraps `resolveLogBackend`, pinned by the daemon app-log routing parity test);
* {@link PlatformPlugin.perf} carries the neutral perf-metrics support predicate
* (wraps `supportsPlatformPerfMetrics`, pinned by the daemon perf routing parity
* test); {@link PlatformPlugin.recording} carries the neutral
* (wraps `supportsPlatformPerfMetrics`) plus the neutral {@link PerfMetricsSamplerTag}
* resolver (wraps the per-platform metrics-sampling branch formerly open-coded in
* `buildPerfResponseData`), both pinned by the daemon perf routing parity test;
* {@link PlatformPlugin.recording} carries the neutral
* {@link RecordingBackendTag} resolver (wraps the per-platform branch of
* `resolveRecordingBackendForDevice`, pinned by the recording routing parity test);
* {@link PlatformPlugin.providers} carries the per-family platform-gated request
* provider resolver list (replaces the hand `device.platform === …` gate in
* `request-platform-providers.ts`, pinned by the providers routing parity test). The
* rest of the `perf` facet (the sampling body `buildPerfResponseData` and the
* Android-only native-collector gate) stays on its daemon branch as the source of
* truth until it clears the same gate. See
* remaining perf work (the `perf memory`/`perf frames` bodies and the Android-only
* native-collector gate) stays on its daemon branch as the source of truth until it
* clears the same gate. See
* docs/adr/0009-apple-platform-consolidation.md (tracked in issue #974).
*/
export type PlatformPlugin = {
Expand Down Expand Up @@ -93,12 +96,22 @@ export type PlatformPlugin = {
* families that expose perf metrics (Apple + Android); left `undefined` for
* linux/web, where the hand predicate returned `false` — the daemon lookup
* preserves that fallthrough, and the daemon perf routing parity test pins the
* equivalence. Only the support gate is routed today; the perf sampling body
* (`buildPerfResponseData`) and the Android-only native-collector gate stay on
* their daemon branch until each clears the same gate.
* equivalence.
*
* `metricsSamplerTag` returns the neutral {@link PerfMetricsSamplerTag} naming which
* `perf metrics` sampler a device's family owns (`'apple'` / `'android'`), replacing
* the `device.platform === 'android'` sampling branch formerly open-coded in
* `buildPerfResponseData`. The daemon still OWNS the samplers and maps the tag back to
* them (`PERF_METRICS_SAMPLERS_BY_TAG`), so core/platforms never carry the daemon-owned
* sampling composition — exactly like {@link recording}'s tag. It is only ever consulted
* after `supportsMetrics` gates the platform in, so it is present on the SAME families
* (Apple + Android) and the parity test pins both to a verbatim copy of the former
* branch. The `perf memory`/`perf frames` bodies and the Android-only native-collector
* gate stay on their daemon branch until each clears the same gate.
*/
readonly perf?: {
supportsMetrics(device: DeviceInfo): boolean;
metricsSamplerTag(device: DeviceInfo): PerfMetricsSamplerTag;
};
/**
* The daemon recording facet (issue #974). `resolveBackendTag` wraps the
Expand Down
119 changes: 118 additions & 1 deletion src/daemon/__tests__/perf-plugin-routing-parity.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import assert from 'node:assert/strict';
import { test } from 'vitest';
import { AppError } from '../../kernel/errors.ts';
import {
isIosFamily,
isMacOs,
Expand All @@ -9,6 +10,7 @@ import {
type DeviceKind,
type DeviceTarget,
} from '../../kernel/device.ts';
import type { AndroidAdbExecutor } from '../../platforms/android/adb-executor.ts';
import {
ANDROID_EMULATOR,
ANDROID_TV_DEVICE,
Expand All @@ -22,7 +24,7 @@ import {
} from '../../__tests__/test-utils/index.ts';
import { getPlugin } from '../../core/platform-plugin/plugin.ts';
import { registerBuiltinPlatformPlugins } from '../../core/interactors/register-builtins.ts';
import { buildPerfResponseData } from '../handlers/session-perf.ts';
import { buildPerfResponseData, type PerfMetricsSamplerTag } from '../handlers/session-perf.ts';
import { PERF_UNAVAILABLE_REASON } from '../handlers/session-startup-metrics.ts';

// Phase 3 step b.3 (issue #974) parity gate for the daemon perf facet. The
Expand All @@ -40,6 +42,14 @@ function supportsPlatformPerfMetricsByHand(device: DeviceInfo): boolean {
return device.platform === 'android' || isIosFamily(device) || isMacOs(device);
}

// --- INDEPENDENT verbatim copy of the former `buildPerfResponseData` sampling branch ---
// The old body dispatched `session.device.platform === 'android'` -> the Android sampler,
// else -> the Apple sampler, and was reached ONLY after the support gate above admitted
// the platform. So the sampler selection is defined only for supported devices.
function perfMetricsSamplerTagByHand(device: DeviceInfo): 'apple' | 'android' {
return device.platform === 'android' ? 'android' : 'apple';
}

// --- the exhaustive synthetic device matrix (every platform x kind x target) ---
const DEVICE_KINDS_ALL: DeviceKind[] = ['simulator', 'emulator', 'device'];
const DEVICE_TARGETS_ALL: (DeviceTarget | undefined)[] = [undefined, ...DEVICE_TARGETS];
Expand Down Expand Up @@ -98,6 +108,30 @@ test('only families with perf metrics carry the perf facet', () => {
assert.equal(getPlugin('web').perf, undefined, 'web plugin has no perf');
});

test('perf.metricsSamplerTag facet is byte-identical to the former sampling branch', () => {
// The sampler selection is only reached on SUPPORTED devices, so compare there; the
// unsupported families carry no facet (hence no tag), asserted separately below.
for (const device of SAMPLE_DEVICES.filter((d) => supportsPlatformPerfMetricsByHand(d))) {
assert.equal(
getPlugin(device.platform).perf?.metricsSamplerTag(device),
perfMetricsSamplerTagByHand(device),
`metricsSamplerTag for ${device.id}`,
);
}
});

test('the factless families expose no metricsSamplerTag', () => {
for (const device of SAMPLE_DEVICES.filter(
(d) => d.platform === 'linux' || d.platform === 'web',
)) {
assert.equal(
getPlugin(device.platform).perf?.metricsSamplerTag,
undefined,
`no sampler tag for ${device.id}`,
);
}
});

test('the factless families fall through to the historical `false` default', () => {
for (const device of SAMPLE_DEVICES.filter(
(d) => d.platform === 'linux' || d.platform === 'web',
Expand Down Expand Up @@ -129,3 +163,86 @@ test('buildPerfResponseData routes the support gate through the perf facet', asy
);
}
});

// Shipped-path routing proof for the sampling body (issue #1188). The support-gate test
// above never reaches sampler selection — it uses no `appBundleId`, so execution returns
// through `applyMissingAppPerfMetrics` before `resolvePerfMetricsSampler`. WITH an app
// bundle, execution clears that guard and hits the sampler selection. The Android sampler
// is the ONLY arm that threads `options.androidAdb`, so a scripted adb executor is invoked
// exactly when the Android sampler was selected AND run through the shipped code path.
const SAMPLED_ADB_REASON = 'scripted adb unavailable';
function makeThrowingAdb(): { adb: AndroidAdbExecutor; calls: () => number } {
let calls = 0;
const adb: AndroidAdbExecutor = async () => {
calls += 1;
throw new AppError('COMMAND_FAILED', SAMPLED_ADB_REASON);
};
return { adb, calls: () => calls };
}

test('buildPerfResponseData dispatches the Android sampler selected by the facet', async () => {
for (const device of SAMPLE_DEVICES.filter((d) => d.platform === 'android')) {
const { adb, calls } = makeThrowingAdb();
const session = makeSession(`perf-routed-${device.id}`, {
device,
appBundleId: 'com.example.app',
});
const data = await buildPerfResponseData(session, { androidAdb: adb });

assert.ok(calls() > 0, `Android sampler reached through the shipped path for ${device.id}`);
for (const metric of ['memory', 'cpu', 'fps'] as const) {
const entry = data.metrics[metric] as { available?: boolean; reason?: string };
assert.equal(
entry.available,
false,
`${metric} was sampled (not the base response) for ${device.id}`,
);
assert.equal(
entry.reason,
SAMPLED_ADB_REASON,
`${metric} carries the sampler failure for ${device.id}`,
);
}
}
});

// Facet-vs-platform proof (re-review, issue #1188): the test above cannot distinguish the
// facet lookup from the deleted `device.platform === 'android'` branch, because on a real
// Android device both select the same Android sampler with the same options. Here the
// device is an Apple simulator but its plugin's `metricsSamplerTag` is overridden to
// `'android'`, so ONLY a facet-driven selection routes it to the Android sampler (the
// scripted adb fires). The former platform branch keyed on `device.platform`, so restoring
// it keeps the Apple device on the Apple sampler and this assertion fails — pinning the
// shipped selection to `metricsSamplerTag`, not the device platform.
test('buildPerfResponseData selects the sampler by the facet tag, not the device platform', async () => {
const perf = getPlugin('apple').perf;
assert.ok(perf, 'apple plugin exposes the perf facet');
const mutablePerf = perf as { metricsSamplerTag: (device: DeviceInfo) => PerfMetricsSamplerTag };
const originalTag = mutablePerf.metricsSamplerTag;
mutablePerf.metricsSamplerTag = () => 'android';
try {
const { adb, calls } = makeThrowingAdb();
const session = makeSession('perf-facet-tag', {
device: IOS_SIMULATOR,
appBundleId: 'com.example.app',
});
const data = await buildPerfResponseData(session, { androidAdb: adb });

assert.ok(calls() > 0, 'facet tag routed the Apple device to the Android sampler');
const cpu = data.metrics.cpu as { available?: boolean; reason?: string };
assert.equal(cpu.available, false, 'cpu was sampled through the facet-selected sampler');
assert.equal(cpu.reason, SAMPLED_ADB_REASON, 'cpu carries the scripted adb failure');
} finally {
mutablePerf.metricsSamplerTag = originalTag;
}
});

// The missing-app guard precedes sampler selection, so without an `appBundleId` the facet
// sampler is never consulted and the scripted adb stays untouched.
test('buildPerfResponseData consults the sampler only past the missing-app guard', async () => {
const { adb, calls } = makeThrowingAdb();
await buildPerfResponseData(makeSession('perf-no-bundle', { device: ANDROID_EMULATOR }), {
androidAdb: adb,
});
assert.equal(calls(), 0, 'sampler not consulted without an app bundle');
});
93 changes: 53 additions & 40 deletions src/daemon/handlers/session-perf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,29 @@ type BuildPerfMemoryResponseOptions = BuildPerfResponseOptions & {

const RELATED_PERF_ACTION_LIMIT = 12;

/**
* The daemon-owned `perf metrics` sampler discriminant. A PLATFORM-NEUTRAL string tag
* naming which family owns a device's `perf metrics` sampler; the daemon maps it back to
* the concrete sampler via {@link PERF_METRICS_SAMPLERS_BY_TAG}. The
* {@link PlatformPlugin.perf} facet returns this tag (type-only in the plugin, exactly
* like {@link RecordingBackendTag} for recording), so core/platforms never carry the
* daemon-owned sampling composition. Only families that expose perf metrics carry the tag
* (Apple + Android); it is consulted solely after the support gate admits the platform.
*/
export type PerfMetricsSamplerTag = 'apple' | 'android';

type SampledPerfMetrics = {
memory: SettledMetricResult;
cpu: SettledMetricResult;
fps: SettledMetricResult;
};

type PerfMetricsSampler = (
session: SessionState,
appBundleId: string,
options: BuildPerfResponseOptions,
) => Promise<SampledPerfMetrics>;

function readStartupPerfSamples(actions: SessionAction[]): StartupPerfSample[] {
const samples: StartupPerfSample[] = [];
for (const action of actions) {
Expand Down Expand Up @@ -129,15 +152,25 @@ export async function buildPerfResponseData(
return response;
}

if (session.device.platform === 'android') {
await applyAndroidPerfMetrics(response, session, session.appBundleId, options);
return response;
}

await applyApplePerfMetrics(response, session, session.appBundleId);
const sampler = resolvePerfMetricsSampler(session.device);
if (!sampler) return response;
const results = await sampler(session, session.appBundleId, options);
applySampledPerfMetrics(response, session, results);
return response;
}

// Routes the former `device.platform === 'android'` sampling branch through the
// PlatformPlugin perf facet (issue #1188): Apple/Android carry `perf.metricsSamplerTag`,
// and the daemon maps the neutral tag back to its own sampler. `buildPerfResponseData`
// consults this only after `supportsPlatformPerfMetrics` admits the platform, so the
// facet (hence the tag) is always present; the `undefined` fallthrough preserves the
// former base response for the unreachable unsupported case. The daemon perf routing
// parity test pins the tag to a verbatim copy of the former branch.
function resolvePerfMetricsSampler(device: SessionState['device']): PerfMetricsSampler | undefined {
const tag = tryGetPlugin(device.platform)?.perf?.metricsSamplerTag(device);
return tag ? PERF_METRICS_SAMPLERS_BY_TAG[tag] : undefined;
}

export async function buildPerfFramesResponseData(
session: SessionState,
options: BuildPerfResponseOptions = {},
Expand Down Expand Up @@ -276,33 +309,10 @@ function applyMissingAppPerfMetrics(response: PerfResponseData, session: Session
response.metrics.cpu = { available: false, reason };
}

async function applyAndroidPerfMetrics(
response: PerfResponseData,
session: SessionState,
appBundleId: string,
options: BuildPerfResponseOptions,
): Promise<void> {
const results = await sampleAndroidPerfResults(session, appBundleId, options);
applySampledPerfMetrics(response, session, results);
}

async function applyApplePerfMetrics(
response: PerfResponseData,
session: SessionState,
appBundleId: string,
): Promise<void> {
const results = await sampleApplePerfResultsForSession(session, appBundleId);
applySampledPerfMetrics(response, session, results);
}

function applySampledPerfMetrics(
response: PerfResponseData,
session: SessionState,
results: {
memory: SettledMetricResult;
cpu: SettledMetricResult;
fps: SettledMetricResult;
},
results: SampledPerfMetrics,
): void {
response.metrics.memory = buildMetricResult(results.memory);
response.metrics.cpu = buildMetricResult(results.cpu);
Expand Down Expand Up @@ -419,11 +429,7 @@ async function sampleAndroidPerfResults(
session: SessionState,
appBundleId: string,
options: BuildPerfResponseOptions,
): Promise<{
memory: SettledMetricResult;
cpu: SettledMetricResult;
fps: SettledMetricResult;
}> {
): Promise<SampledPerfMetrics> {
const androidPerfOptions = { adb: options.androidAdb };
const [memory, cpu, fps] = await Promise.allSettled([
sampleAndroidMemoryPerf(session.device, appBundleId, androidPerfOptions),
Expand All @@ -436,11 +442,7 @@ async function sampleAndroidPerfResults(
async function sampleApplePerfResultsForSession(
session: SessionState,
appBundleId: string,
): Promise<{
memory: SettledMetricResult;
cpu: SettledMetricResult;
fps: SettledMetricResult;
}> {
): Promise<SampledPerfMetrics> {
const fps = await settleMetric(sampleAppleFramePerf(session.device, appBundleId));
const processSample = await settleMetric(sampleApplePerfMetrics(session.device, appBundleId));
if (processSample.status === 'fulfilled') {
Expand All @@ -461,6 +463,17 @@ async function sampleApplePerfResultsForSession(
};
}

// Maps the neutral {@link PerfMetricsSamplerTag} the plugin facet returns back to the
// daemon-owned `perf metrics` sampler. Exhaustive over the tag union (a compile error if
// a tag is added without a sampler), so `resolvePerfMetricsSampler` is a pure data lookup
// with no platform branch of its own. The Apple sampler ignores the trailing options bag
// (only Android threads a request-scoped adb executor), so its narrower arity is
// assignable to {@link PerfMetricsSampler}.
const PERF_METRICS_SAMPLERS_BY_TAG: Record<PerfMetricsSamplerTag, PerfMetricsSampler> = {
android: sampleAndroidPerfResults,
apple: sampleApplePerfResultsForSession,
};

async function buildMemorySampleMetric(
session: SessionState,
options: BuildPerfResponseOptions,
Expand Down
6 changes: 4 additions & 2 deletions src/platforms/apple/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,8 +156,10 @@ export const applePlugin = {
isMacOs(device) ? 'macos' : device.kind === 'device' ? 'ios-device' : 'ios-simulator',
},
// Wraps the Apple arm of `supportsPlatformPerfMetrics`: every Apple device
// (ios/macos, any kind/target) reports perf-metrics support.
perf: { supportsMetrics: () => true },
// (ios/macos, any kind/target) reports perf-metrics support. `metricsSamplerTag`
// wraps the else-arm of the former `buildPerfResponseData` sampling branch: every
// supported Apple device routes to the Apple `perf metrics` sampler.
perf: { supportsMetrics: () => true, metricsSamplerTag: () => 'apple' },
// Wraps the Apple arm of `resolveRecordingBackendForDevice` verbatim: macOS ->
// 'macos'; an iOS `device` -> 'ios-device'; every other iOS kind (simulator, incl.
// tvOS/iPadOS/visionOS) -> 'ios-simulator'. Mirrors the appLog resolveBackend shape.
Expand Down
Loading