Skip to content

Commit 1c33d03

Browse files
author
Guy Suday
committed
sysmon: fix first-sample handling for interval and cpuUsage
1 parent d599717 commit 1c33d03

4 files changed

Lines changed: 131 additions & 44 deletions

File tree

pymobiledevice3/cli/developer/dvt/sysmon/process.py

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -160,12 +160,16 @@ def _duration_elapsed(start_time: float, duration_ms: Optional[int]) -> bool:
160160
return ((asyncio.get_running_loop().time() - start_time) * 1000) >= duration_ms
161161

162162

163-
async def iter_initialized_processes(sysmon: Sysmontap):
164-
sample_index = 0
163+
def _should_skip_first_snapshot(keys: Optional[list[str]]) -> bool:
164+
# The first sample does not contain initialized cpuUsage values
165+
return (keys is None) or ("cpuUsage" in keys)
166+
167+
168+
async def iter_processes(sysmon: Sysmontap, skip_first_snapshot: bool = False):
169+
should_skip_snapshot = skip_first_snapshot
165170
async for process_snapshot in sysmon.iter_processes():
166-
sample_index += 1
167-
# The first sample does not contain initialized cpuUsage values.
168-
if sample_index < 2:
171+
if should_skip_snapshot:
172+
should_skip_snapshot = False
169173
continue
170174
yield process_snapshot
171175

@@ -240,7 +244,9 @@ async def _select_process_from_sysmon(
240244
dvt, parsed_filters: dict[str, list[str]], keys: Optional[list[str]], selection_mode: ProcessSelectionMode
241245
) -> dict:
242246
async with await Sysmontap.create(dvt) as selection_sysmon:
243-
async for process_snapshot in iter_initialized_processes(selection_sysmon):
247+
async for process_snapshot in iter_processes(
248+
selection_sysmon, skip_first_snapshot=_should_skip_first_snapshot(keys)
249+
):
244250
# All process entries in a sysmon sample share the same schema, so validating one entry is sufficient.
245251
_validate_process_keys(process_snapshot[0], keys or [])
246252
return _select_process_from_snapshot(process_snapshot, parsed_filters, selection_mode)
@@ -304,7 +310,7 @@ async def sysmon_process_single_task(
304310
DeviceInfo(dvt) as device_info,
305311
await Sysmontap.create(dvt) as sysmon,
306312
):
307-
async for process_snapshot in iter_initialized_processes(sysmon):
313+
async for process_snapshot in iter_processes(sysmon, skip_first_snapshot=_should_skip_first_snapshot(keys)):
308314
if process_snapshot and parsed_filters:
309315
# All process entries in a sysmon sample share the same schema, so validating one entry is sufficient.
310316
_validate_process_keys(process_snapshot[0], list(parsed_filters))
@@ -398,7 +404,7 @@ async def sysmon_process_monitor_process(
398404
"-i",
399405
help="Minimum interval in milliseconds between outputs (optional)",
400406
),
401-
] = Sysmontap.DEFAULT_INTERVAL,
407+
] = Sysmontap.DEFAULT_INTERVAL_MS,
402408
duration: Annotated[
403409
Optional[int],
404410
typer.Option(
@@ -456,7 +462,7 @@ async def sysmon_process_monitor_threshold_task(
456462
start_time = None
457463

458464
async with DvtProvider(service_provider) as dvt, await Sysmontap.create(dvt) as sysmon:
459-
async for process_snapshot in iter_initialized_processes(sysmon):
465+
async for process_snapshot in iter_processes(sysmon, skip_first_snapshot=True):
460466
if start_time is None:
461467
start_time = asyncio.get_running_loop().time()
462468

@@ -481,7 +487,7 @@ async def sysmon_process_monitor_threshold_task(
481487
async def sysmon_process_monitor_process_task(
482488
service_provider: ServiceProviderDep,
483489
filter_expressions: Optional[list[str]] = None,
484-
interval: int = Sysmontap.DEFAULT_INTERVAL,
490+
interval: int = Sysmontap.DEFAULT_INTERVAL_MS,
485491
duration: Optional[int] = None,
486492
choose: ProcessSelectionMode = ProcessSelectionMode.PROMPT,
487493
keys: Optional[list[str]] = None,
@@ -501,7 +507,9 @@ async def sysmon_process_monitor_process_task(
501507

502508
monitoring_start_time = None
503509
async with await Sysmontap.create(dvt, interval=interval) as monitor_sysmon:
504-
async for process_snapshot in iter_initialized_processes(monitor_sysmon):
510+
async for process_snapshot in iter_processes(
511+
monitor_sysmon, skip_first_snapshot=_should_skip_first_snapshot(keys)
512+
):
505513
if monitoring_start_time is None:
506514
monitoring_start_time = asyncio.get_running_loop().time()
507515

pymobiledevice3/services/dvt/instruments/sysmontap.py

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,32 +6,37 @@
66

77
class Sysmontap(Tap):
88
IDENTIFIER = "com.apple.instruments.server.services.sysmontap"
9-
DEFAULT_INTERVAL = 500
9+
DEFAULT_INTERVAL_MS = 500
10+
MINIMUM_INTERVAL_MS = 1
1011

1112
def __init__(
12-
self, dvt, process_attributes: list[str], system_attributes: list[str], interval: int = DEFAULT_INTERVAL
13+
self,
14+
dvt,
15+
process_attributes: list[str],
16+
system_attributes: list[str],
17+
interval_ms: int = DEFAULT_INTERVAL_MS,
1318
) -> None:
1419
self.process_attributes_cls = dataclasses.make_dataclass("SysmonProcessAttributes", process_attributes)
1520
self.system_attributes_cls = dataclasses.make_dataclass("SysmonSystemAttributes", system_attributes)
1621

1722
config = {
18-
"ur": interval, # Output frequency ms
23+
"ur": Sysmontap.MINIMUM_INTERVAL_MS, # Output frequency ms
1924
"bm": 0,
2025
"procAttrs": process_attributes,
2126
"sysAttrs": system_attributes,
2227
"cpuUsage": True,
2328
"physFootprint": True, # memory value
24-
"sampleInterval": interval * 1000000,
29+
"sampleInterval": interval_ms * 1_000_000,
2530
}
2631

2732
super().__init__(dvt, self.IDENTIFIER, config)
2833

2934
@classmethod
30-
async def create(cls, dvt, interval: int = DEFAULT_INTERVAL) -> "Sysmontap":
35+
async def create(cls, dvt, interval: int = DEFAULT_INTERVAL_MS) -> "Sysmontap":
3136
async with DeviceInfo(dvt) as device_info:
3237
process_attributes = list(await device_info.sysmon_process_attributes())
3338
system_attributes = list(await device_info.sysmon_system_attributes())
34-
return cls(dvt, process_attributes, system_attributes, interval=interval)
39+
return cls(dvt, process_attributes, system_attributes, interval_ms=interval)
3540

3641
async def iter_processes(self):
3742
async for row in self:

tests/cli/developer/dvt/sysmon/test_process.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
ProcessSelectionMode,
99
_process_sort_key,
1010
_select_process_from_snapshot,
11-
iter_initialized_processes,
11+
iter_processes,
1212
sysmon_process_monitor_threshold_task,
1313
sysmon_process_single_task,
1414
)
@@ -18,14 +18,14 @@
1818
@pytest_asyncio.fixture
1919
async def process_snapshot(dvt) -> list[dict]:
2020
async with await Sysmontap.create(dvt) as sysmon:
21-
async for process_snapshot in iter_initialized_processes(sysmon):
21+
async for process_snapshot in iter_processes(sysmon):
2222
return process_snapshot
2323

2424
pytest.fail("failed to collect an initialized process snapshot")
2525

2626

2727
@pytest.mark.asyncio
28-
async def test_iter_initialized_processes_yields_process_dicts(process_snapshot) -> None:
28+
async def test_iter_processes_yields_process_dicts(process_snapshot) -> None:
2929
assert len(process_snapshot) > 0
3030
assert isinstance(process_snapshot[0], dict)
3131
assert process_snapshot[0].get("pid")

tests/cli/developer/dvt/sysmon/test_process_unit.py

Lines changed: 98 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,41 @@
2222
_select_process_from_sysmon,
2323
_select_process_output_keys,
2424
_serialize_process,
25+
_should_skip_first_snapshot,
2526
_validate_process_keys,
2627
_write_json,
2728
_write_process,
28-
iter_initialized_processes,
29+
iter_processes,
30+
sysmon_process_monitor_threshold_task,
2931
)
3032

3133

34+
class _FakeSysmontap:
35+
def __init__(self, snapshots):
36+
self._snapshots = snapshots
37+
38+
async def __aenter__(self):
39+
return self
40+
41+
async def __aexit__(self, exc_type, exc, tb):
42+
return None
43+
44+
async def iter_processes(self):
45+
for snapshot in self._snapshots:
46+
yield snapshot
47+
48+
49+
class _FakeDvtProvider:
50+
def __init__(self, service_provider):
51+
self.service_provider = service_provider
52+
53+
async def __aenter__(self):
54+
return object()
55+
56+
async def __aexit__(self, exc_type, exc, tb):
57+
return None
58+
59+
3260
def test_parse_process_filters_groups_values_by_key():
3361
assert _parse_process_filters(["name=abc", "name=def", "pid=7"]) == {
3462
"name": ["abc", "def"],
@@ -180,46 +208,75 @@ def test_duration_elapsed(monkeypatch, start_time, duration_ms, current_time, ex
180208
assert _duration_elapsed(start_time, duration_ms) is expected
181209

182210

183-
class _FakeSysmontap:
184-
def __init__(self, snapshots):
185-
self._snapshots = snapshots
186-
187-
async def __aenter__(self):
188-
return self
189-
190-
async def __aexit__(self, exc_type, exc, tb):
191-
return None
192-
193-
async def iter_processes(self):
194-
for snapshot in self._snapshots:
195-
yield snapshot
211+
@pytest.mark.parametrize(
212+
("keys", "expected"),
213+
[
214+
(None, True),
215+
(["pid"], False),
216+
(["pid", "cpuUsage"], True),
217+
],
218+
)
219+
def test_should_skip_first_snapshot(keys, expected):
220+
assert _should_skip_first_snapshot(keys) is expected
196221

197222

198223
@pytest.mark.asyncio
199-
async def test_iter_initialized_processes_skips_first_snapshot():
200-
sysmon = _FakeSysmontap([
224+
async def test_iter_processes_skips_first_snapshot_when_requested():
225+
snapshots = [
201226
[{"pid": 10, "ppid": 1, "name": "first-snapshot"}],
202227
[{"pid": 20, "ppid": 2, "name": "second-snapshot"}],
203228
[{"pid": 30, "ppid": 3, "name": "third-snapshot"}],
204-
])
229+
]
230+
sysmon = _FakeSysmontap(snapshots)
205231

206-
snapshots = [snapshot async for snapshot in iter_initialized_processes(sysmon)]
232+
iterated_snapshots = [snapshot async for snapshot in iter_processes(sysmon, skip_first_snapshot=True)]
207233

208-
assert snapshots == [
209-
[{"pid": 20, "ppid": 2, "name": "second-snapshot"}],
210-
[{"pid": 30, "ppid": 3, "name": "third-snapshot"}],
211-
]
234+
assert iterated_snapshots == snapshots[1:]
212235

213236

214237
@pytest.mark.asyncio
215-
async def test_iter_initialized_processes_empty_when_only_warmup_snapshot_exists():
238+
async def test_iter_processes_empty_when_only_warmup_snapshot_exists():
216239
sysmon = _FakeSysmontap([[{"pid": 10, "ppid": 1, "name": "first-snapshot"}]])
217240

218-
snapshots = [snapshot async for snapshot in iter_initialized_processes(sysmon)]
241+
snapshots = [snapshot async for snapshot in iter_processes(sysmon, skip_first_snapshot=True)]
219242

220243
assert snapshots == []
221244

222245

246+
@pytest.mark.asyncio
247+
async def test_iter_processes_keeps_first_snapshot_when_cpu_usage_not_required():
248+
snapshots = [
249+
[{"pid": 10, "ppid": 1, "name": "first-snapshot"}],
250+
[{"pid": 20, "ppid": 2, "name": "second-snapshot"}],
251+
]
252+
253+
sysmon = _FakeSysmontap(snapshots)
254+
255+
iterated_snapshots = [snapshot async for snapshot in iter_processes(sysmon, skip_first_snapshot=False)]
256+
257+
assert iterated_snapshots == snapshots
258+
259+
260+
@pytest.mark.asyncio
261+
async def test_sysmon_process_monitor_threshold_task_skips_first_snapshot(monkeypatch):
262+
async def fake_create(_dvt):
263+
return _FakeSysmontap([
264+
[{"pid": 10, "cpuUsage": 99.0}],
265+
[{"pid": 20, "cpuUsage": 1.5}],
266+
])
267+
268+
monkeypatch.setattr(process_module, "DvtProvider", _FakeDvtProvider)
269+
monkeypatch.setattr(process_module.Sysmontap, "create", fake_create)
270+
271+
out = StringIO()
272+
273+
await sysmon_process_monitor_threshold_task(object(), threshold=0.0, keys=["pid"], out=out, duration=1)
274+
275+
lines = [json.loads(line) for line in out.getvalue().splitlines() if line.strip()]
276+
assert len(lines) == 1
277+
assert lines[0]["pid"] == 20
278+
279+
223280
def test_process_sort_key_normalizes_missing_values():
224281
assert _process_sort_key({"name": "abc"}) == (-1, -1, "abc")
225282

@@ -337,6 +394,23 @@ async def fake_create(_dvt):
337394
assert selected == {"pid": 20, "ppid": 2, "name": "second-snapshot"}
338395

339396

397+
@pytest.mark.asyncio
398+
async def test_select_process_from_sysmon_doesnt_skip_first_snapshot_when_cpu_usage_not_requested(monkeypatch):
399+
async def fake_create(_dvt):
400+
return _FakeSysmontap([
401+
[{"pid": 10, "ppid": 1, "name": "first-snapshot"}],
402+
[{"pid": 20, "ppid": 2, "name": "second-snapshot"}],
403+
])
404+
405+
monkeypatch.setattr(process_module.Sysmontap, "create", fake_create)
406+
407+
selected = await _select_process_from_sysmon(
408+
object(), {"name": ["first-snapshot"]}, ["pid", "name"], ProcessSelectionMode.FIRST
409+
)
410+
411+
assert selected == {"pid": 10, "ppid": 1, "name": "first-snapshot"}
412+
413+
340414
@pytest.mark.asyncio
341415
async def test_select_process_from_sysmon_raises_when_no_usable_snapshot(monkeypatch):
342416
async def fake_create(_dvt):

0 commit comments

Comments
 (0)