Skip to content

Commit 9c08c1d

Browse files
authored
fix: stop a single transport drop cascading into a full-run failure (#237); release 0.12.0 (#238)
Two real bugs found by tracing issue #237's exact failure signature through the executor: 1. The post-reconnect retry switch had silently drifted out of sync with the main step-dispatch switch -- it was missing RecipeCall, ConditionalStep, LoopStep, RetryStep, and HTTPCallStep. A connection error surfacing from any of those could reconnect successfully but never re-run the step: the switch matched nothing, err stayed the stale pre-reconnect error, and the loop burned every remaining attempt re-closing the connection tryReconnect had just re-established. Both paths now share a single dispatchStep helper and cannot drift again. 2. if X appears treated a dead connection as 'condition not visible' and silently took the else branch -- misrouting navigation mid-recipe -- instead of propagating the connection error for reconnect the way tap ... if visible already did. Also ruled out empirically (negative results kept in the evidence folder): neither the agent's ws.pingInterval nor the CLI's ping/read-deadline machinery kills a busy-but-alive connection, across four load shapes. The underlying WS drop trigger remains open in #237; these fixes change its blast radius from 'rest of the run' to 'one visible reconnect cycle'. Release 0.12.0: version bump across VERSION, pubspec, agent_version, vscode, wiki; CHANGELOGs sectioned; MCP write_test tool description updated with this cycle's new syntax (retry/optional, native UI, add media, deep links, element-scoped visual regression).
1 parent 0fa4751 commit 9c08c1d

13 files changed

Lines changed: 603 additions & 45 deletions

File tree

CHANGELOG.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
66

77
## [Unreleased]
88

9+
## [0.12.0] - 2026-08-15
10+
911
### Added
1012
- **`retry N times` block.** Wraps an indented block of steps: on failure, re-runs the whole
1113
block from the top, up to N total attempts, stopping at the first success. Distinct from
@@ -48,6 +50,21 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
4850
See `docs/evidence/n1-native-ui-android-2026-08-15/`.
4951

5052
### Fixed
53+
- **A single Android transport drop no longer cascades into failing every remaining test
54+
(issue #237).** Two real bugs in the connection-error recovery path, found by tracing #237's
55+
exact failure signature through the executor: (1) the post-reconnect retry switch had silently
56+
drifted out of sync with the main step-dispatch switch — it was missing recipe calls,
57+
conditionals, loops, retry blocks, and HTTP calls, so a connection error surfacing from any of
58+
those could reconnect successfully but never re-run the step, burning the whole retry budget
59+
re-closing the connection each attempt had just re-established (both paths now share one
60+
dispatch function and cannot drift again); (2) `if X appears` treated a dead connection as
61+
"condition not visible" and silently took the else branch — misrouting navigation mid-recipe —
62+
instead of propagating the connection error for reconnect the way `tap ... if visible` already
63+
did. The underlying WS drop trigger from #237 remains under investigation (needs the reporting
64+
project's live Firebase load); these fixes change its blast radius from "rest of the run" to
65+
"one visible reconnect cycle". Also ruled out empirically: neither side's ping/keepalive
66+
machinery kills a busy-but-alive connection (four load-shape experiments, kept in the evidence
67+
folder). See `docs/evidence/i237-ws-drop-investigation-2026-08-15/`.
5168
- **`probe migrate maestro` hardened against 2.x syntax and two real bugs (G-3).** Audited the
5269
converter against nect-flutter's real 76-flow suite: `setPermissions`, `retry` (with recursive
5370
nested-command conversion, also fixed for `repeat`), and `assertScreenshot` are now supported,

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.11.0
1+
0.12.0
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
# Issue #237 investigation — Android WS drop cascade: two real bugs found and fixed, drop trigger itself still open
2+
3+
Issue #237 reports a deterministic Android WS connection death at a specific file-to-file
4+
transition (7 real Firebase sign-in/sign-out cycles → next file's `goto feed`), after which
5+
**every remaining test fails** — 76/79 failures in one run from a single transport event, with
6+
auto-reconnect never recovering.
7+
8+
This investigation did not (yet) reproduce the underlying drop itself — that genuinely needs the
9+
reporting project's Firebase-loaded app running live (see "Still open" below). What it did find,
10+
by tracing the exact reported failure signature (`probelink: write: ... use of closed network
11+
connection`, `Connection lost — attempt 1` repeating) through the CLI source, is that **the
12+
cascade after the drop — the part that turns one transport blip into 76 failures — was caused by
13+
two real, confirmed CLI bugs**, both now fixed.
14+
15+
## Bug 1 — the post-reconnect retry switch had drifted out of sync with the dispatch switch
16+
17+
`internal/runner/executor.go`'s `runStep` had two hand-maintained copies of the step-dispatch
18+
switch: the initial attempt (11 step kinds) and the post-reconnect retry (6 step kinds). The
19+
retry copy was missing `parser.RecipeCall`, `ConditionalStep`, `LoopStep`, `RetryStep`, and
20+
`HTTPCallStep`.
21+
22+
Consequence, matching #237's report exactly: a connection error surfacing from a recipe call
23+
(`goto feed` is one) enters the retry loop, `tryReconnect` succeeds — **and then the retry switch
24+
matches nothing**. `err` silently keeps its stale pre-reconnect value, `isConnectionError(err)`
25+
stays true, and the loop calls `tryReconnect` again — whose first action is `e.client.Close()`,
26+
killing the connection it *just* established the attempt before. The entire retry budget burns
27+
without the step ever being re-run, and the connection is left in whatever state the final
28+
attempt happened to produce.
29+
30+
**Fix**: both paths now call a single shared `dispatchStep` helper — the two switches are
31+
structurally the same switch and cannot drift apart again. Locked in by
32+
`internal/runner/dispatch_step_test.go`'s `TestDispatchStep_*` (each previously-missing step kind
33+
asserted to produce its observable side effect through the shared dispatcher, not just a nil
34+
error).
35+
36+
## Bug 2 — conditionals misread a dead connection as "condition not visible"
37+
38+
`runConditional` (the `if X appears` executor) treated **any** `See` failure as "not visible" and
39+
took the else branch — including connection errors. With a dead connection mid-recipe, every
40+
`if X appears` block silently misroutes: `goto feed`'s `otherwise: go back / go back` navigates
41+
*away* from a perfectly healthy screen because the visibility check couldn't be asked, not
42+
because the answer was no. Nothing ever reached the reconnect machinery from the check itself.
43+
44+
`runAction`'s `if visible` suffix pre-check already handled this exact case correctly
45+
("propagate connection errors for auto-reconnect") — the ConditionalStep path just never got the
46+
same treatment.
47+
48+
**Fix**: connection errors from the visibility check now propagate (so runStep's reconnect fires
49+
and the condition is re-evaluated against a live connection); ordinary not-found still takes the
50+
else branch. Locked in by `TestRunConditional_ConnectionErrorPropagates` and
51+
`TestRunConditional_OrdinaryNotFoundStillTakesElse`.
52+
53+
## Ruled out along the way (negative results, kept honestly)
54+
55+
The first hypothesis was the Dart agent's `ws.pingInterval = 5s` (server.dart:249 — added for
56+
iOS/iproxy idle-drop prevention, applied unconditionally to all connections): if the app's
57+
isolate is too busy (Firebase auth/listener churn) to service ping/pong for >5s, does the agent
58+
kill a healthy connection? **Empirically no**, across four load shapes (`ping_repro.dart`,
59+
`ping_repro2.dart`, both in this folder — runnable with plain `dart run`):
60+
61+
- one continuous synchronous CPU block 4× the ping interval — connection survived;
62+
- a dense microtask flood (195M chained microtasks over 4× the interval) — survived;
63+
- repeated 200ms sync bursts with 50ms yields — survived;
64+
- and separately, the **real Go client** (`probelink.DialWithOptions` + its actual
65+
pingLoop/read-deadline code) against a server that refused to service its read loop for 20s
66+
(4× the CLI's ping interval, exceeding its 15s pong-refresh deadline) — the client kept the
67+
connection open and `Connected()` true for 25+ s. (Temporary in-repo test, deleted after the
68+
result was recorded — the code path it exercised is unchanged.)
69+
70+
So neither side's keepalive machinery kills a busy-but-alive connection on a healthy transport.
71+
This makes an emulator/adb-forward transport-layer event (or something Android-specific not
72+
reproducible on a macOS host loopback) the stronger suspect for the original drop.
73+
74+
## Still open — the drop trigger itself
75+
76+
What actually kills the socket at that specific file transition remains unconfirmed. It needs a
77+
live instrumented run against the reporting project's app (real Firebase Auth + Firestore churn —
78+
the trigger conditions this repo's own test apps can't produce, per PT-25's earlier resolution
79+
note). With these two fixes, the *impact* of the next occurrence changes shape entirely: the
80+
first reconnect that succeeds now actually re-runs the failed step (recipe calls included), and
81+
conditionals no longer misroute on a dead connection — so a drop should now cost one visible
82+
reconnect cycle, not the rest of the run. That also makes the next live repro far more
83+
informative: whatever still fails after these fixes is the drop itself, not the cascade.
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
// Minimal, isolated repro for issue #237's leading hypothesis:
2+
// does a `ws.pingInterval`-configured dart:io WebSocket server close the
3+
// connection when the isolate's event loop is starved for longer than the
4+
// ping interval — even though the client and the underlying TCP socket are
5+
// both perfectly healthy? This mirrors probe_agent/lib/src/server.dart's
6+
// exact setup (ws.pingInterval = Duration(seconds: N)) at a shortened
7+
// interval so the test runs in seconds, not minutes.
8+
//
9+
// Two scenarios:
10+
// A) baseline: server idle, no isolate starvation -> connection should stay up.
11+
// B) server isolate busy (CPU-bound work occupying the event loop) for
12+
// longer than pingInterval -> does the connection get force-closed?
13+
14+
import 'dart:async';
15+
import 'dart:io';
16+
17+
const pingInterval = Duration(seconds: 2);
18+
19+
Future<void> main() async {
20+
await scenario('A) baseline (idle server, no starvation)', starve: false);
21+
await scenario('B) server isolate busy > pingInterval', starve: true);
22+
}
23+
24+
Future<void> scenario(String label, {required bool starve}) async {
25+
print('\n=== $label ===');
26+
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
27+
final port = server.port;
28+
29+
WebSocket? serverSocket;
30+
final serverClosedCompleter = Completer<void>();
31+
var serverGotDone = false;
32+
33+
server.listen((req) async {
34+
if (!WebSocketTransformer.isUpgradeRequest(req)) return;
35+
final ws = await WebSocketTransformer.upgrade(req);
36+
serverSocket = ws;
37+
ws.pingInterval = pingInterval; // exact same line as ProbeServer._handleConnection
38+
ws.listen(
39+
(data) {},
40+
onDone: () {
41+
serverGotDone = true;
42+
print(' [server] onDone fired (connection closed) at ${DateTime.now()}');
43+
if (!serverClosedCompleter.isCompleted) serverClosedCompleter.complete();
44+
},
45+
onError: (e) => print(' [server] onError: $e'),
46+
cancelOnError: false,
47+
);
48+
});
49+
50+
final client = await WebSocket.connect('ws://127.0.0.1:$port/probe');
51+
var clientGotDone = false;
52+
client.listen(
53+
(data) {},
54+
onDone: () {
55+
clientGotDone = true;
56+
print(' [client] onDone fired (connection closed), closeCode=${client.closeCode}, closeReason=${client.closeReason}');
57+
},
58+
onError: (e) => print(' [client] onError: $e'),
59+
);
60+
61+
await Future<void>.delayed(const Duration(milliseconds: 300)); // let the upgrade settle
62+
print(' connection established, pingInterval=$pingInterval');
63+
64+
if (starve) {
65+
final blockFor = pingInterval * 4; // comfortably longer than one interval
66+
print(' blocking the SERVER isolate synchronously for ${blockFor.inSeconds}s (simulating heavy sync/microtask work)...');
67+
final stopwatch = Stopwatch()..start();
68+
// Tight CPU-bound loop -- occupies the isolate, event loop cannot run
69+
// timers or process I/O callbacks (including the WS ping timer/pong
70+
// handling) until this returns.
71+
var x = 0;
72+
while (stopwatch.elapsed < blockFor) {
73+
x = (x + 1) % 1000000007;
74+
}
75+
print(' ...unblocked after ${stopwatch.elapsed} (junk=$x)');
76+
} else {
77+
await Future<void>.delayed(pingInterval * 4);
78+
}
79+
80+
// Give the event loop a moment to process anything queued during/after
81+
// the block (e.g. a deferred close).
82+
await Future.any([
83+
serverClosedCompleter.future,
84+
Future<void>.delayed(const Duration(seconds: 3)),
85+
]);
86+
87+
print(' RESULT: serverGotDone=$serverGotDone clientGotDone=$clientGotDone');
88+
if (starve && serverGotDone) {
89+
print(' ==> CONFIRMED: isolate starvation past pingInterval closes an otherwise-healthy connection.');
90+
} else if (starve && !serverGotDone) {
91+
print(' ==> NOT confirmed this way: connection survived the starvation window.');
92+
}
93+
94+
await client.close();
95+
if (serverSocket != null && !serverGotDone) {
96+
await serverSocket!.close();
97+
}
98+
await server.close(force: true);
99+
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
// Variant of ping_repro.dart: instead of one long synchronous CPU-bound
2+
// block, flood the event loop with a dense chain of microtasks/short async
3+
// hops (closer in shape to real Firestore-listener-churn async work) for
4+
// longer than pingInterval, and separately try many repeated short blocks
5+
// (bursty jank) rather than one continuous block.
6+
7+
import 'dart:async';
8+
import 'dart:io';
9+
10+
const pingInterval = Duration(seconds: 2);
11+
12+
Future<void> main() async {
13+
await scenario('C) dense microtask flood > pingInterval', mode: Mode.microtaskFlood);
14+
await scenario('D) repeated short sync bursts (bursty jank)', mode: Mode.burstyJank);
15+
}
16+
17+
enum Mode { microtaskFlood, burstyJank }
18+
19+
Future<void> scenario(String label, {required Mode mode}) async {
20+
print('\n=== $label ===');
21+
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
22+
final port = server.port;
23+
24+
var serverGotDone = false;
25+
final serverClosedCompleter = Completer<void>();
26+
27+
server.listen((req) async {
28+
if (!WebSocketTransformer.isUpgradeRequest(req)) return;
29+
final ws = await WebSocketTransformer.upgrade(req);
30+
ws.pingInterval = pingInterval;
31+
ws.listen(
32+
(data) {},
33+
onDone: () {
34+
serverGotDone = true;
35+
print(' [server] onDone fired at ${DateTime.now()}');
36+
if (!serverClosedCompleter.isCompleted) serverClosedCompleter.complete();
37+
},
38+
onError: (e) => print(' [server] onError: $e'),
39+
cancelOnError: false,
40+
);
41+
});
42+
43+
final client = await WebSocket.connect('ws://127.0.0.1:$port/probe');
44+
var clientGotDone = false;
45+
client.listen((data) {}, onDone: () {
46+
clientGotDone = true;
47+
print(' [client] onDone fired, closeCode=${client.closeCode}');
48+
});
49+
50+
await Future<void>.delayed(const Duration(milliseconds: 300));
51+
print(' connection established, pingInterval=$pingInterval');
52+
53+
final totalDuration = pingInterval * 4;
54+
final stopwatch = Stopwatch()..start();
55+
56+
if (mode == Mode.microtaskFlood) {
57+
print(' flooding microtask queue for ${totalDuration.inSeconds}s...');
58+
// Chain millions of scheduleMicrotask calls -- keeps the event loop
59+
// "running" but never lets it drain to service timers/IO callbacks
60+
// (like the WS ping timer) until the chain finally empties.
61+
var count = 0;
62+
void pump() {
63+
count++;
64+
if (stopwatch.elapsed < totalDuration) {
65+
scheduleMicrotask(pump);
66+
}
67+
}
68+
final done = Completer<void>();
69+
void pumpWithCompletion() {
70+
count++;
71+
if (stopwatch.elapsed < totalDuration) {
72+
scheduleMicrotask(pumpWithCompletion);
73+
} else {
74+
done.complete();
75+
}
76+
}
77+
scheduleMicrotask(pumpWithCompletion);
78+
await done.future;
79+
print(' ...done, scheduled $count microtasks');
80+
} else {
81+
print(' bursty jank: 40x (200ms sync block + 50ms yield) for ${totalDuration.inSeconds}s...');
82+
while (stopwatch.elapsed < totalDuration) {
83+
final burstStart = Stopwatch()..start();
84+
var x = 0;
85+
while (burstStart.elapsed < const Duration(milliseconds: 200)) {
86+
x = (x + 1) % 1000000007;
87+
}
88+
await Future<void>.delayed(const Duration(milliseconds: 50));
89+
}
90+
print(' ...done bursting');
91+
}
92+
93+
await Future.any([
94+
serverClosedCompleter.future,
95+
Future<void>.delayed(const Duration(seconds: 3)),
96+
]);
97+
98+
print(' RESULT: serverGotDone=$serverGotDone clientGotDone=$clientGotDone');
99+
if (serverGotDone) {
100+
print(' ==> CONFIRMED: this load shape closes an otherwise-healthy connection.');
101+
} else {
102+
print(' ==> NOT confirmed this way.');
103+
}
104+
105+
await client.close();
106+
await server.close(force: true);
107+
}

docs/wiki/Home.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ Welcome to the FlutterProbe wiki. This documentation covers architecture details
1818

1919
## Project Status
2020

21-
FlutterProbe is in active development. Current version: **0.11.0**.
21+
FlutterProbe is in active development. Current version: **0.12.0**.
2222

2323
### Repository Structure
2424

internal/mcp/server.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,21 @@ Supports the full ProbeScript syntax:
186186
sync "both logged in"
187187
B:
188188
see "Welcome"
189+
Resilience: retry N times — indented block re-runs from the top on failure,
190+
up to N attempts, stopping at first success (unlike repeat,
191+
which always runs every iteration). Trailing "optional" on
192+
tap/type/long press/double tap/clear/see — attempts the step
193+
but logs-and-continues on failure instead of failing the test.
194+
Native UI: tap native "…" / see native "…" / type native "…" into "…" —
195+
drives native (non-Flutter) UI like pickers and share sheets
196+
via uiautomator, matched by text or resource-id. Android only.
197+
Device media: add media "path/to/photo.jpg" — seeds a file into the camera
198+
roll/gallery (Android + iOS simulator).
199+
Deep links: open link "scheme://…" in the app — routes via OS intent/URL
200+
handling to the app's own registered scheme, instead of the
201+
external browser that plain open link uses.
202+
Visual: compare screenshot "name" — baseline-on-first-run comparison;
203+
add of "Widget"/#id to crop to one widget's bounds.
189204
Recipes: recipe "name"(params) — reusable step sequences
190205
Hooks: before each / after each / on failure / before all / after all
191206
Data-driven: with examples | col1 | col2 |`,

0 commit comments

Comments
 (0)