perf: reduce per-step iteration time (~3.9s to ~2.3s) (#29)

* perf(sidecar): use exec-out + tmpfs for hierarchy dump

Avoids FUSE overhead on /sdcard and shell startup cost by using
exec-out with /data/local/tmp. Saves ~100ms per hierarchy fetch.

* perf(sidecar): replace Thread.sleep waitForIdle with real idle detection

Poll `dumpsys window -a` for mAnimating=true every 50ms instead of
blindly sleeping. Breaks early when device is idle, saving 500-800ms
per step since most settle in <200ms after an action.

* test(sidecar): add idle detection parsing tests

* perf(runner): parallelize hierarchy, metrics, and logs fetch

Run fetchHierarchy, captureMetrics, and collectLogs concurrently via
errgroup so metrics+logs (~150ms) hide behind the hierarchy fetch
(~2s) instead of running serially.

* perf(runner): pipeline post-action screenshot with next step

Defer the post-action screenshot from step N and run it concurrently
with step N+1's hierarchy/metrics/logs fetch. Saves ~335ms per step
by hiding screenshot latency behind the hierarchy fetch.

* test(runner): add tests for parallel fetch and pipelined screenshots

Verify that hierarchy, metrics, and logs are all called per step.
Verify post-action screenshots are written with correct step indices
when pipelined, including the final flush after the loop.

* perf(sidecar): grep mAnimating on-device instead of pulling full dump

The full `dumpsys window -a` output is ~88KB per poll. Running
grep on-device transfers only a count byte, cutting per-poll
overhead from ~63ms to ~56ms and eliminating 88KB of ADB transfer.
This commit is contained in:
pj authored and GitHub committed 2026-04-22 17:43:27 +07:00
1 parent faebfe379d
commit b667abbbca
6 files changed
+205 -17

No files matched your search

+102
View File
@@ -5,6 +5,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net"
"os"
@@ -16,6 +17,7 @@ import (
"time"
"github.com/priyanshujain/sanderling/internal/agent"
"github.com/priyanshujain/sanderling/internal/driver"
mockdriver "github.com/priyanshujain/sanderling/internal/driver/mock"
"github.com/priyanshujain/sanderling/internal/trace"
"github.com/priyanshujain/sanderling/internal/verifier"
@@ -423,6 +425,106 @@ func TestApplyAction_InputTextSurfacesFocusTapError(t *testing.T) {
})
}
func TestRunner_ParallelFetchCallsAllDriverMethods(t *testing.T) {
snapshots := []map[string]json.RawMessage{
{"screen": json.RawMessage(`"home"`), "balance": json.RawMessage(`100`)},
}
state := newHarness(t, snapshots)
state.mock.MetricsData = driver.Metrics{CPUPercent: 5.0, HeapBytes: 1024, TotalMemoryBytes: 4096}
state.mock.LogEntries = []driver.LogEntry{
{UnixMillis: 1000, Level: "E", Tag: "test", Message: "boom"},
}
state.startSDK(t)
state.acceptConnection(t)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err := Run(ctx, Options{
Duration: 100 * time.Millisecond,
SnapshotTimeout: 2 * time.Second,
IdleTimeout: 50 * time.Millisecond,
BundleID: "com.fixture",
Connection: state.conn,
Driver: state.mock,
Verifier: state.verifier,
TraceWriter: state.writer,
})
if err != nil {
t.Fatalf("Run: %v", err)
}
actions := state.mock.Actions()
var hasHierarchy, hasMetrics, hasLogs bool
for _, a := range actions {
switch a.Kind {
case mockdriver.ActionHierarchy:
hasHierarchy = true
case mockdriver.ActionMetrics:
hasMetrics = true
case mockdriver.ActionRecentLogs:
hasLogs = true
}
}
if !hasHierarchy {
t.Error("expected Hierarchy call in mock actions")
}
if !hasMetrics {
t.Error("expected Metrics call in mock actions")
}
if !hasLogs {
t.Error("expected RecentLogs call in mock actions")
}
}
func TestRunner_PipelinedPostScreenshotWritten(t *testing.T) {
snapshots := []map[string]json.RawMessage{
{"screen": json.RawMessage(`"home"`), "balance": json.RawMessage(`100`)},
{"screen": json.RawMessage(`"home"`), "balance": json.RawMessage(`200`)},
{"screen": json.RawMessage(`"home"`), "balance": json.RawMessage(`300`)},
}
state := newHarness(t, snapshots)
state.mock.ImageData = driver.Image{PNG: []byte("fakepng"), Width: 100, Height: 200}
state.startSDK(t)
state.acceptConnection(t)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
summary, err := Run(ctx, Options{
Duration: 200 * time.Millisecond,
SnapshotTimeout: 2 * time.Second,
IdleTimeout: 50 * time.Millisecond,
Connection: state.conn,
Driver: state.mock,
Verifier: state.verifier,
TraceWriter: state.writer,
})
if err != nil {
t.Fatalf("Run: %v", err)
}
if summary.Steps < 2 {
t.Fatalf("need at least 2 steps for pipelining test, got %d", summary.Steps)
}
screenshotDir := filepath.Join(state.writer.Directory(), "screenshots")
preFile := filepath.Join(screenshotDir, "step-00001.png")
if _, err := os.Stat(preFile); os.IsNotExist(err) {
t.Errorf("expected pre-screenshot for step 1: %s", preFile)
}
// Step 1's post-screenshot is pipelined into step 2's errgroup
postFile := filepath.Join(screenshotDir, "step-00001-after.png")
if _, err := os.Stat(postFile); os.IsNotExist(err) {
t.Errorf("expected pipelined post-screenshot for step 1: %s", postFile)
}
// Last step's post-screenshot is flushed after the loop
lastAfter := filepath.Join(screenshotDir, fmt.Sprintf("step-%05d-after.png", summary.Steps))
if _, err := os.Stat(lastAfter); os.IsNotExist(err) {
t.Errorf("expected flushed post-screenshot for last step %d: %s", summary.Steps, lastAfter)
}
}
func mustNewVerifier(t *testing.T) *verifier.Verifier {
t.Helper()
verifierInstance, err := verifier.New()