blob: e98d46dc96efe200dfb3e03463b3af975227a35b [file]
# Copyright 2026 The Fuchsia Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Unit tests for the binder analysis plugin."""
import unittest
from typing import Any
from unittest.mock import MagicMock, patch
from binder import BinderPlugin
from plugins import PluginArgumentError
class BinderPluginTest(unittest.TestCase):
"""Tests for the binder analysis plugin using mock trace processor queries."""
def test_analyze_no_bottleneck(self) -> None:
"""Tests that when there are no delays, results are empty lists."""
plugin = BinderPlugin()
mock_tp = MagicMock()
mock_tp.get_tables.return_value = {
"thread_state",
"slice",
"thread_track",
"thread",
"process",
"flow",
"args",
"trace_bounds",
}
mock_tp.run_query.return_value = []
with patch("binder.PerfettoTraceProcessor") as mock_tp_class:
mock_tp_class.return_value.__enter__.return_value = mock_tp
results = plugin.analyze(["--threshold-ms", "10.0"], "dummy_trace")
self.assertEqual(len(results), 3)
self.assertEqual(
results[0]["name"], "Missed Wakeups (Wakeup Latencies)"
)
self.assertEqual(results[0]["results"], [])
self.assertEqual(
results[1]["name"], "Binder Delays (Transaction Queue Latencies)"
)
self.assertEqual(results[1]["results"], [])
self.assertEqual(
results[2]["name"], "Spawn Looper Events (Late-Spawned Wakers)"
)
self.assertEqual(results[2]["results"], [])
def test_analyze_normal_waker_delay(self) -> None:
"""Tests identifying wakeups and binder delays without spawning loopers."""
plugin = BinderPlugin()
mock_tp = MagicMock()
mock_tp.get_tables.return_value = {
"thread_state",
"slice",
"thread_track",
"thread",
"process",
"flow",
"args",
"trace_bounds",
}
def run_query_mock(sql: str) -> list[dict[str, Any]]:
if "thread_state" in sql:
return [
{
"wakeup_ts": 1000,
"scheduling_delay_ns": 15000000,
"thread_name": "binder:100",
"tid": 100,
"process_name": "target_proc",
"pid": 50,
"waker_thread_name": "waker_proc",
"waker_tid": 200,
}
]
elif "flow" in sql and "SpawnLooper" not in sql:
return [
{
"flow_id": 1,
"send_ts": 2000,
"recv_ts": 17000,
"queue_latency_ns": 15000000,
"sender_thread": "sender_thread",
"sender_tid": 200,
"receiver_thread": "binder:100",
"receiver_tid": 100,
"status": "Completed",
"cmd": "Transaction { ... }",
}
]
return []
mock_tp.run_query.side_effect = run_query_mock
with patch("binder.PerfettoTraceProcessor") as mock_tp_class:
mock_tp_class.return_value.__enter__.return_value = mock_tp
results = plugin.analyze(["--threshold-ms", "10.0"], "dummy_trace")
self.assertEqual(len(results), 3)
self.assertEqual(len(results[0]["results"]), 1)
self.assertEqual(results[0]["results"][0]["thread_name"], "binder:100")
self.assertEqual(len(results[1]["results"]), 1)
self.assertEqual(results[1]["results"][0]["queue_latency_ns"], 15000000)
self.assertEqual(len(results[2]["results"]), 0)
def test_analyze_late_spawned_waker(self) -> None:
"""Tests that SpawnLooper flows are populated during late-spawned waker events."""
plugin = BinderPlugin()
mock_tp = MagicMock()
mock_tp.get_tables.return_value = {
"thread_state",
"slice",
"thread_track",
"thread",
"process",
"flow",
"args",
"trace_bounds",
}
def run_query_mock(sql: str) -> list[dict[str, Any]]:
if "thread_state" in sql:
return []
elif "flow" in sql and "SpawnLooper" in sql:
return [
{
"flow_id": 2,
"trigger_ts": 2100,
"handle_ts": 51000,
"spawn_latency_ns": 48900000,
"handle_thread": "binder:spawned_101",
"handle_tid": 101,
}
]
elif "flow" in sql:
return [
{
"flow_id": 1,
"send_ts": 2000,
"recv_ts": 52000,
"queue_latency_ns": 50000000,
"sender_thread": "sender_thread",
"sender_tid": 200,
"receiver_thread": "binder:spawned_101",
"receiver_tid": 101,
"status": "Completed",
"cmd": "Transaction { ... }",
}
]
return []
mock_tp.run_query.side_effect = run_query_mock
with patch("binder.PerfettoTraceProcessor") as mock_tp_class:
mock_tp_class.return_value.__enter__.return_value = mock_tp
results = plugin.analyze(["--threshold-ms", "10.0"], "dummy_trace")
self.assertEqual(len(results), 3)
self.assertEqual(len(results[0]["results"]), 0)
self.assertEqual(len(results[1]["results"]), 1)
self.assertEqual(len(results[2]["results"]), 1)
self.assertEqual(results[2]["results"][0]["spawn_latency_ns"], 48900000)
def test_analyze_negative_threshold(self) -> None:
"""Tests that passing a negative threshold raises PluginArgumentError."""
plugin = BinderPlugin()
with self.assertRaisesRegex(
PluginArgumentError, "must be a finite, non-negative number"
):
plugin.analyze(["--threshold-ms", "-5.0"], "dummy_trace")
def test_analyze_overflow_threshold(self) -> None:
"""Tests that passing NaN, infinity or huge values raises PluginArgumentError."""
plugin = BinderPlugin()
for val in ["NaN", "inf", "1e309"]:
with self.subTest(val=val):
with self.assertRaisesRegex(
PluginArgumentError, "must be a finite, non-negative number"
):
plugin.analyze(["--threshold-ms", val], "dummy_trace")
def test_analyze_invalid_threshold_string(self) -> None:
"""Tests that passing an invalid non-float string raises PluginArgumentError (not SystemExit)."""
plugin = BinderPlugin()
with self.assertRaisesRegex(PluginArgumentError, "invalid float value"):
plugin.analyze(["--threshold-ms", "abc"], "dummy_trace")
def test_analyze_missing_schema(self) -> None:
"""Tests that missing schema tables are handled gracefully by returning error entries."""
plugin = BinderPlugin()
mock_tp = MagicMock()
mock_tp.get_tables.return_value = set()
mock_tp.run_query.return_value = []
with patch("binder.PerfettoTraceProcessor") as mock_tp_class:
mock_tp_class.return_value.__enter__.return_value = mock_tp
results = plugin.analyze(["--threshold-ms", "10.0"], "dummy_trace")
self.assertEqual(len(results), 3)
for item in results:
self.assertIn("error", item)
self.assertIn("Required schema tables/views missing", item["error"])
def test_analyze_incomplete_transactions(self) -> None:
"""Tests that incomplete transactions are returned by default and filtered with --complete-only."""
plugin = BinderPlugin()
mock_tp = MagicMock()
mock_tp.get_tables.return_value = {
"thread_state",
"slice",
"thread_track",
"thread",
"process",
"flow",
"args",
"trace_bounds",
}
def run_query_mock(sql: str) -> list[dict[str, Any]]:
if "sqlite_master" in sql:
return [
{"name": name}
for name in [
"thread_state",
"slice",
"thread_track",
"thread",
"process",
"flow",
"args",
"trace_bounds",
]
]
if "thread_state" in sql:
return []
elif "flow" in sql and "SpawnLooper" not in sql:
completed_tx = {
"flow_id": 1,
"send_ts": 2000,
"recv_ts": 17000,
"queue_latency_ns": 15000000,
"sender_thread": "sender_thread",
"sender_tid": 200,
"receiver_thread": "binder:100",
"receiver_tid": 100,
"status": "Completed",
"cmd": "Transaction { ... }",
}
incomplete_tx = {
"flow_id": None,
"send_ts": 3000,
"recv_ts": None,
"queue_latency_ns": 20000000,
"sender_thread": "sender_thread",
"sender_tid": 200,
"receiver_thread": None,
"receiver_tid": None,
"status": "Incomplete",
"cmd": "Transaction { ... }",
}
if "UNION ALL" in sql:
# Default case: returns both
# Query orders by latency DESC, so incomplete (20ms) should be first, then completed (15ms)
return [incomplete_tx, completed_tx]
else:
# --complete-only case
return [completed_tx]
return []
mock_tp.run_query.side_effect = run_query_mock
with patch("binder.PerfettoTraceProcessor") as mock_tp_class:
mock_tp_class.return_value.__enter__.return_value = mock_tp
# Test default (includes incomplete)
results_default = plugin.analyze(
["--threshold-ms", "10.0"], "dummy_trace"
)
self.assertEqual(len(results_default), 3)
delays_default = results_default[1]["results"]
self.assertEqual(len(delays_default), 2)
self.assertEqual(delays_default[0]["status"], "Incomplete")
self.assertEqual(delays_default[1]["status"], "Completed")
# Test --complete-only
results_complete_only = plugin.analyze(
["--threshold-ms", "10.0", "--complete-only"], "dummy_trace"
)
self.assertEqual(len(results_complete_only), 3)
delays_complete_only = results_complete_only[1]["results"]
self.assertEqual(len(delays_complete_only), 1)
self.assertEqual(delays_complete_only[0]["status"], "Completed")
if __name__ == "__main__":
unittest.main()