Skip to content

Commit 28f3635

Browse files
committed
Make a refused device say so, and stop containers eating clicks
Three faults that all present as "touch does nothing", found while a console tab and the device page were open on the same simulator. A refused input socket was announced only in the document title. The video keeps streaming and the mirror keeps updating, so the page looks healthy while every touch is discarded — the second tab you open is simply dead, with no way to tell. The page now says why on screen, and adds the remedy itself: the server's reason travels in a close frame capped at 123 bytes, and the remedy is precisely the half that gets truncated. That notice is the only wording of ours on a page whose entire purpose is to contain nothing but the app, so it is painted by CSS from an attribute rather than written into the DOM. aria-hidden alone was measured to be insufficient — it hides the element from getByRole and from an ARIA snapshot, but getByText still matched it, which would let our sentence satisfy an assertion meant for the app. Mirroring the native tree faithfully stacks a container's full-size transparent div over the leaves it contains, and a later sibling wins the hit test, so Playwright refused to click visible controls with "another element intercepts pointer events". Containers are now click-through and only leaves clickable; clicking a container still works, because the hit lands on a descendant. hittable cannot serve here — iOS reports it false for whole visible subtrees. Finally, input connections are pinged. A driver that dies without closing leaves the socket open from this side, so Read blocks for ever and the claim outlives the client that made it; every later client is then refused on behalf of a driver that no longer exists. The close is CloseNow rather than Close: waiting for a peer already measured as unable to answer was observed to cost ~3s, all of it with the device still held.
1 parent 4767d04 commit 28f3635

6 files changed

Lines changed: 198 additions & 2 deletions

File tree

‎README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,8 +109,8 @@ on durable selectors.
109109
in the Android driver, and they are discarded rather than guessed at.
110110
- **A device serves one driver at a time**, and a second claim is refused rather than shared.
111111
That includes the console: a browser tab left open on a device will refuse your test run,
112-
and only the refused side is told why. There is no keepalive yet, so a client that dies
113-
without closing its socket holds the device until the server restarts.
112+
and the refused page says so on screen. A driver that dies without closing its socket is
113+
detected by ping within about half a minute, and the device is released.
114114
- Relaunching an app does not reset its state.
115115

116116
## Licence

‎internal/server/ws.go‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,48 @@ func (s *Server) handleVideoWS(w http.ResponseWriter, r *http.Request) {
7171
}
7272
}
7373

74+
// Input connections are pinged on this cadence. A driver that dies
75+
// without closing its socket — a killed browser, a slept laptop, a CI
76+
// job that was cancelled — leaves the connection open from this side, so
77+
// conn.Read blocks for ever and the device's claim is never released.
78+
// Every later client is then refused on behalf of a driver that no
79+
// longer exists, and on the device page the refusal is nearly invisible.
80+
// A ping is the only thing that distinguishes a quiet driver from a dead
81+
// one. The interval is a compromise: long enough to be nothing on the
82+
// wire, short enough that a device is reclaimable within half a minute.
83+
const (
84+
inputPingInterval = 15 * time.Second
85+
inputPingTimeout = 10 * time.Second
86+
)
87+
88+
// watchLiveness pings conn until a ping goes unanswered, then closes it
89+
// so the handler's blocked Read returns and its deferred release runs.
90+
// Closing is what frees the device; this only decides when.
91+
// The cadence is a parameter so tests need not wait real seconds for a
92+
// verdict; production callers pass the constants above.
93+
func watchLiveness(ctx context.Context, conn *websocket.Conn, interval, timeout time.Duration) {
94+
ticker := time.NewTicker(interval)
95+
defer ticker.Stop()
96+
for {
97+
select {
98+
case <-ctx.Done():
99+
return
100+
case <-ticker.C:
101+
pingCtx, cancel := context.WithTimeout(ctx, timeout)
102+
err := conn.Ping(pingCtx)
103+
cancel()
104+
if err != nil {
105+
// CloseNow, not Close: a graceful close waits for the
106+
// peer to acknowledge, and this peer has already been
107+
// measured as unable to answer — waiting was observed to
108+
// cost ~3s, all of it with the device still claimed.
109+
conn.CloseNow()
110+
return
111+
}
112+
}
113+
}
114+
}
115+
74116
// handleInputWS receives raw sidecar protocol frames from the browser —
75117
// the real-time path for streamed touches during manual driving. Each
76118
// binary message must be exactly one valid frame; malformed frames are
@@ -94,6 +136,12 @@ func (s *Server) handleInputWS(w http.ResponseWriter, r *http.Request) {
94136
}
95137
defer release()
96138

139+
// Only after the claim: a refused connection has nothing to release,
140+
// and pinging it would keep a doomed socket alive for no reason.
141+
liveCtx, stopWatching := context.WithCancel(ctx)
142+
defer stopWatching()
143+
go watchLiveness(liveCtx, conn, inputPingInterval, inputPingTimeout)
144+
97145
var held heldTouch
98146
// Closure, not a direct defer: deferred arguments evaluate at defer
99147
// time, when nothing is held yet.

‎internal/server/ws_test.go‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,3 +366,67 @@ func TestInputWSReleasesOnDisconnect(t *testing.T) {
366366
}
367367
}
368368
}
369+
370+
// wsAcceptOnce serves exactly one upgrade and hands the connection to fn,
371+
// signalling when fn returns.
372+
func wsAcceptOnce(t *testing.T, fn func(*websocket.Conn)) (addr string, done <-chan struct{}) {
373+
t.Helper()
374+
finished := make(chan struct{})
375+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
376+
conn, err := websocket.Accept(w, r, nil)
377+
if err != nil {
378+
return
379+
}
380+
defer conn.CloseNow()
381+
fn(conn)
382+
close(finished)
383+
}))
384+
t.Cleanup(srv.Close)
385+
return "ws" + strings.TrimPrefix(srv.URL, "http"), finished
386+
}
387+
388+
// A peer that stops answering is the case that stranded a real device for
389+
// 22 hours: the socket stayed open, Read blocked for ever, and the claim
390+
// outlived the client that made it. Only an unanswered ping reveals it.
391+
func TestWatchLivenessGivesUpOnASilentPeer(t *testing.T) {
392+
addr, done := wsAcceptOnce(t, func(conn *websocket.Conn) {
393+
watchLiveness(context.Background(), conn, 5*time.Millisecond, 40*time.Millisecond)
394+
})
395+
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
396+
defer cancel()
397+
conn, _, err := websocket.Dial(ctx, addr, nil)
398+
if err != nil {
399+
t.Fatalf("dial: %v", err)
400+
}
401+
defer conn.CloseNow()
402+
// Deliberately never read: coder/websocket answers pings from the read
403+
// path, so a client that is not reading is indistinguishable from one
404+
// that is gone — which is exactly the condition being tested.
405+
select {
406+
case <-done:
407+
case <-time.After(5 * time.Second):
408+
t.Fatal("watchLiveness never gave up on a peer that stopped answering")
409+
}
410+
}
411+
412+
// Cancellation is the ordinary exit: the handler returned, the claim is
413+
// already being released, and the watcher must not outlive it.
414+
func TestWatchLivenessStopsWhenTheHandlerReturns(t *testing.T) {
415+
addr, done := wsAcceptOnce(t, func(conn *websocket.Conn) {
416+
ctx, cancel := context.WithCancel(context.Background())
417+
cancel()
418+
watchLiveness(ctx, conn, time.Hour, time.Hour)
419+
})
420+
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
421+
defer cancel()
422+
conn, _, err := websocket.Dial(ctx, addr, nil)
423+
if err != nil {
424+
t.Fatalf("dial: %v", err)
425+
}
426+
defer conn.CloseNow()
427+
select {
428+
case <-done:
429+
case <-time.After(5 * time.Second):
430+
t.Fatal("watchLiveness ignored a cancelled context")
431+
}
432+
}

‎internal/web/static/device.html‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,45 @@
2626
user-select: none;
2727
cursor: default;
2828
font: 10px/1 sans-serif;
29+
/* A native container's frame spans its children, so mirroring the
30+
tree faithfully stacks big transparent divs over the leaves they
31+
contain — and a later sibling wins the hit test. Playwright then
32+
refuses to click a perfectly visible control because "another
33+
element intercepts pointer events". Containers must therefore be
34+
click-through and only leaves clickable. Clicking a container
35+
still works: the hit lands on a descendant, which the actionability
36+
check accepts. `hittable` cannot serve here — iOS reports it false
37+
for whole visible subtrees (see device.js). */
38+
pointer-events: none;
2939
}
40+
#mirror [data-dd-node]:not(:has([data-dd-node])) { pointer-events: auto; }
41+
/* A refused page looks identical to a working one: the video still
42+
streams, the mirror still updates, and only the touches go nowhere.
43+
Someone driving by hand reads that as "DeviceDeck is broken".
44+
45+
The text is drawn from an attribute via ::after rather than written
46+
into the DOM. aria-hidden alone was measured to be insufficient: it
47+
hides the notice from getByRole and from an ARIA snapshot, but
48+
getByText still matched it, which would let our sentence satisfy an
49+
assertion meant for the app. Generated content has no text node, so
50+
there is nothing for a text query to find. */
51+
#notice {
52+
position: absolute; top: 0; left: 0; right: 0;
53+
padding: 10px 14px;
54+
background: #b3261e; color: #fff;
55+
font: 13px/1.4 -apple-system, system-ui, sans-serif;
56+
pointer-events: none;
57+
display: none;
58+
}
59+
#notice[data-shown] { display: block; }
60+
#notice::after { content: attr(data-reason); }
3061
</style>
3162
</head>
3263
<body>
3364
<div id="stage">
3465
<canvas id="video"></canvas>
3566
<div id="mirror" role="application" aria-label="device"></div>
67+
<div id="notice" aria-hidden="true"></div>
3668
</div>
3769
<script src="/video-common.js"></script>
3870
<script src="/input-common.js"></script>

‎internal/web/static/device.js‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,13 +36,30 @@ function connectVideo() {
3636
ws.onclose = () => setTimeout(connectVideo, 1500);
3737
}
3838

39+
// showNotice puts a human-readable reason on screen. The text goes into
40+
// an attribute and is painted by CSS generated content, so the page gains
41+
// no text node a locator could match — see device.html for why aria-hidden
42+
// alone was not enough.
43+
function showNotice(text) {
44+
const notice = document.getElementById("notice");
45+
notice.setAttribute("data-reason", text);
46+
notice.setAttribute("data-shown", "");
47+
}
48+
49+
// The server's reason travels in a close frame, which holds 123 bytes —
50+
// so the remedy is exactly the part that gets truncated away. The page
51+
// knows the remedy without being told, so it says it itself.
52+
const REFUSAL_REMEDY =
53+
"Close the other DeviceDeck tab or test run for this device, then reload.";
54+
3955
function connectInput() {
4056
input = createInputSocket(wsURL(`/api/devices/${udid}/input`), {
4157
// Surfaced in the title so a driver that cannot drive says so
4258
// somewhere a test's failure message will show it.
4359
onRefused: (reason) => {
4460
document.title = `DeviceDeck — ${reason || "device busy"}`;
4561
mirror.setAttribute("data-dd-input-refused", reason || "device busy");
62+
showNotice(`${reason || "Another client is driving this device."} — ${REFUSAL_REMEDY}`);
4663
},
4764
});
4865
}

‎internal/web/web_test.go‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,3 +101,38 @@ func TestDevicePageCarriesNoControlsOfOurOwn(t *testing.T) {
101101
}
102102
}
103103
}
104+
105+
// The refusal notice is the one piece of our own wording on the automation
106+
// page, so it has to be provably invisible to a locator. Two things make
107+
// that true, and both were needed: aria-hidden keeps it out of getByRole
108+
// and the ARIA snapshot, and keeping the element empty keeps it out of
109+
// getByText — which was measured still matching an aria-hidden node. A
110+
// refused page must never offer an agent a sentence the app never had.
111+
func TestDeviceNoticeIsHiddenFromAutomation(t *testing.T) {
112+
body := get(t, "/device/EB69B42A-4763-4A33-AF0F-CD233F721951").Body.String()
113+
start := strings.Index(body, "<body>")
114+
end := strings.Index(body, "<script")
115+
if start < 0 || end < 0 {
116+
t.Fatal("device page shape changed; this guard needs updating")
117+
}
118+
markup := body[start:end]
119+
notice := strings.Index(markup, `id="notice"`)
120+
if notice < 0 {
121+
t.Fatal("device page has no refusal notice; a refused page would look merely broken")
122+
}
123+
// The attribute must sit on the notice element itself.
124+
tagStart := strings.LastIndex(markup[:notice], "<")
125+
tagEnd := strings.Index(markup[notice:], ">") + notice
126+
if tag := markup[tagStart : tagEnd+1]; !strings.Contains(tag, `aria-hidden="true"`) {
127+
t.Errorf("notice is not aria-hidden, so automation would read it as app content: %s", tag)
128+
}
129+
// It must also live outside the mirror, which is the app's subtree.
130+
if mirror := strings.Index(markup, `id="mirror"`); mirror > notice {
131+
t.Error("notice precedes the mirror; it must not sit inside the app subtree")
132+
}
133+
// Empty element: the wording is painted by CSS from an attribute, so
134+
// there is no text node for getByText to match.
135+
if body := markup[tagEnd+1:]; !strings.HasPrefix(strings.TrimSpace(body), "</div>") {
136+
t.Errorf("notice carries text in the DOM, which getByText would match: %.60s", body)
137+
}
138+
}

0 commit comments

Comments
 (0)