Skip to content

Commit 334419c

Browse files
sanketsudakeclaude
andauthored
fix(wait): settle --idle on held-open streams; report settled URL in target (#5)
* fix(wait): settle --idle on held-open streams (websocket/long-poll/SSE) wait --idle counted requestWillBeSent up and loadingFinished/Failed down and required inflight == 0. A websocket / long-poll / EventSource request fires requestWillBeSent but never loadingFinished, so inflight never returned to zero and --idle hung until --timeout on SPAs that hold such a stream open (observed on Workday: a 20s timeout on an already-loaded page). Settle when EITHER no requests are in flight for the window (the clean path, unchanged for pages whose requests all complete) OR requests remain open but the connection has been silent for ~2s (the stalled path). Response/data events reset the silence clock, so an in-progress download is never mistaken for a stalled stream — only a genuinely quiet still-open request settles. Adds TestWaitIdleStalledStream (a held-open request settles via the stalled path in ~2s instead of hanging); TestWaitIdle still guards the clean path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(wait,nav): report the settled URL in the envelope target The envelope's target was captured before the action ran, so after a nav or a --url / redirect wait it showed the pre-navigation URL — misleading (a wait --url that matched could echo a target URL that didn't even contain the matched substring). Wait now reads the tab's location after the condition holds and returns it as result.url; targetAction syncs target.url from any result carrying a url key (nav already returned it). No extra round-trips. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(wait): collapse waitIdle to a single activity clock Review follow-up (/simplify + /deslop) — no behavior change: - waitIdle tracked two clocks (lastZero + lastActivity); lastZero is derivable (it only diverged from lastActivity in the pre-Enable in-flight-request edge, where a single clock is actually more correct — it respects active bytes instead of settling early). Collapse to one lastActivity clock and select the threshold by whether requests are in flight: `window` when idle, `idleStall` when a stream is held open. Drops a field, a branch, and one idle predicate. - Dedup the "held-open stream / not-a-stalled-download" rationale that was stated three times (doc comment + two inline comments) down to once. Live idle tests (clean + stalled paths) and the full race suite stay green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 91d9fa6 commit 334419c

4 files changed

Lines changed: 149 additions & 10 deletions

File tree

internal/chrome/cdp.go

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1397,10 +1397,13 @@ func (c *CDP) Wait(ctx context.Context, id string, cond WaitCond) (map[string]an
13971397
default:
13981398
return nil, fmt.Errorf("wait needs one of --url, --visible, --gone, --text, --stable, --idle, --for")
13991399
}
1400-
if err := c.run(ctx, id, action); err != nil {
1400+
// Also read the URL the tab settled at, so the envelope's target reflects
1401+
// where a --url / redirect wait actually landed, not the pre-wait URL.
1402+
var loc string
1403+
if err := c.run(ctx, id, action, chromedp.Location(&loc)); err != nil {
14011404
return nil, err
14021405
}
1403-
return map[string]any{"waited": what}, nil
1406+
return map[string]any{"waited": what, "url": loc}, nil
14041407
}
14051408

14061409
// waitURL polls location.href until it contains substr (or the context ends).
@@ -1473,37 +1476,58 @@ func waitStable(window time.Duration) chromedp.Action {
14731476
})
14741477
}
14751478

1476-
// waitIdle returns once network activity has settled: no in-flight requests for
1477-
// `window`. It tracks requestWillBeSent vs loadingFinished/Failed — for SPA
1478-
// loads (Outlook, Workday) where "DOM loaded" ≠ "content rendered".
1479+
// waitIdle returns once network activity has settled. It considers the page
1480+
// settled when EITHER no requests are in flight for `window` (the clean path,
1481+
// for pages whose requests all complete), OR requests remain open but the
1482+
// connection has gone silent for `idleStall` (the stalled path). The stalled
1483+
// path is what makes --idle usable on SPAs (Outlook, Workday) that hold a
1484+
// websocket / long-poll / EventSource stream open indefinitely: such a request
1485+
// fires requestWillBeSent but never loadingFinished, so inflight never returns
1486+
// to zero and a strict "inflight == 0" wait would hang until --timeout. Progress
1487+
// events (response/data), not just start/finish, keep the clock live, so an
1488+
// in-progress download is never mistaken for a silent held-open stream.
14791489
func waitIdle(window time.Duration) chromedp.Action {
1490+
// A still-open request is treated as idle after this much network silence.
1491+
// Longer than `window` so a normally-completing load always settles via the
1492+
// clean path first; short enough that a held-open stream doesn't wait out
1493+
// the whole --timeout.
1494+
const idleStall = 2 * time.Second
14801495
return chromedp.ActionFunc(func(ctx context.Context) error {
14811496
if err := network.Enable().Do(ctx); err != nil {
14821497
return err
14831498
}
14841499
var mu sync.Mutex
14851500
inflight := 0
1486-
lastZero := time.Now()
1501+
// lastActivity is the last time any request started, progressed, or
1502+
// ended — set only for network events (ListenTarget also delivers page/
1503+
// DOM events, which must not count as network activity).
1504+
lastActivity := time.Now()
14871505
chromedp.ListenTarget(ctx, func(ev interface{}) {
14881506
mu.Lock()
14891507
defer mu.Unlock()
14901508
switch ev.(type) {
14911509
case *network.EventRequestWillBeSent:
14921510
inflight++
1511+
lastActivity = time.Now()
14931512
case *network.EventLoadingFinished, *network.EventLoadingFailed:
14941513
if inflight > 0 {
14951514
inflight--
14961515
}
1497-
if inflight == 0 {
1498-
lastZero = time.Now()
1499-
}
1516+
lastActivity = time.Now()
1517+
case *network.EventResponseReceived, *network.EventDataReceived:
1518+
// bytes moving on an open request — keep it counted as active
1519+
lastActivity = time.Now()
15001520
}
15011521
})
15021522
t := time.NewTicker(100 * time.Millisecond)
15031523
defer t.Stop()
15041524
for {
15051525
mu.Lock()
1506-
idle := inflight == 0 && time.Since(lastZero) >= window
1526+
threshold := window
1527+
if inflight > 0 {
1528+
threshold = idleStall // still-open requests need the longer stall window
1529+
}
1530+
idle := time.Since(lastActivity) >= threshold
15071531
mu.Unlock()
15081532
if idle {
15091533
return nil

internal/chrome/wait_idle_test.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,55 @@ addEventListener('load', () => setTimeout(() => {
5555
t.Errorf("window.__done = %q after wait --idle, want true (idle returned too early, in %v)", got, time.Since(start))
5656
}
5757
}
58+
59+
// wait --idle must still settle when a request is held open indefinitely (a
60+
// websocket / long-poll / EventSource — the shape that made --idle hang on
61+
// Workday): inflight never returns to zero, so it settles via the stalled path
62+
// once the connection goes silent, rather than waiting out --timeout.
63+
func TestWaitIdleStalledStream(t *testing.T) {
64+
if testing.Short() {
65+
t.Skip("skipping live-Chrome integration in -short mode")
66+
}
67+
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
68+
defer cancel()
69+
70+
// Build the server BEFORE launching Chrome so its Close() defer runs LAST
71+
// (LIFO), after b.Close() tears Chrome down. Otherwise srv.Close() blocks on
72+
// the still-open /hang request, which only unblocks when Chrome disconnects
73+
// — a teardown deadlock. /hang stays open until the request context is
74+
// cancelled (Chrome dropping the connection at b.Close()).
75+
mux := http.NewServeMux()
76+
mux.HandleFunc("/hang", func(_ http.ResponseWriter, r *http.Request) {
77+
<-r.Context().Done()
78+
})
79+
mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) {
80+
fmt.Fprint(w, `<!doctype html><title>Stalled</title><body><script>
81+
addEventListener('load', () => setTimeout(() => { fetch('/hang'); }, 50));
82+
</script></body>`)
83+
})
84+
srv := httptest.NewServer(mux)
85+
defer srv.Close()
86+
87+
b, err := launch(true, tmpProfile(t), 0)
88+
if err != nil {
89+
t.Skipf("cannot launch a managed headless Chrome here: %v", err)
90+
}
91+
defer b.Close()
92+
93+
id := firstTab(ctx, t, b)
94+
if _, err := b.Navigate(ctx, id, srv.URL); err != nil {
95+
t.Fatalf("Navigate: %v", err)
96+
}
97+
98+
// Bound the wait well under the 30s+ a held-open request would otherwise
99+
// cost: if the fix works it settles via the stalled path in ~2s.
100+
wctx, wcancel := context.WithTimeout(ctx, 10*time.Second)
101+
defer wcancel()
102+
start := time.Now()
103+
if _, err := b.Wait(wctx, id, WaitCond{Idle: true}); err != nil {
104+
t.Fatalf("Wait --idle hung on a held-open request: %v", err)
105+
}
106+
if elapsed := time.Since(start); elapsed >= 8*time.Second {
107+
t.Errorf("wait --idle took %v — it waited for the held-open request instead of settling on silence", elapsed)
108+
}
109+
}

internal/cli/commands.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -753,6 +753,13 @@ func (a *App) targetAction(command string, fn func(ctx context.Context, b chrome
753753
a.emitErr(command, code, msg, details)
754754
return nil
755755
}
756+
// If the action settled at a new URL (nav, wait --url / redirect), report
757+
// that in the envelope's target rather than the pre-action URL.
758+
if m, ok := res.(map[string]any); ok {
759+
if u, ok := m["url"].(string); ok && u != "" {
760+
tgt.URL = u
761+
}
762+
}
756763
// --wait-text folds "act, then confirm the write landed" into one call:
757764
// after the action succeeds, block until the page contains the text.
758765
if a.actWaitText != "" {

internal/cli/target_url_test.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package cli
2+
3+
// Regression: the envelope's target must reflect the URL the tab settled at
4+
// after a nav / redirect wait, not the pre-action URL.
5+
6+
import (
7+
"context"
8+
"testing"
9+
10+
"github.com/sanketsudake/chrome-cdp-cli/internal/chrome"
11+
"github.com/sanketsudake/chrome-cdp-cli/internal/target"
12+
)
13+
14+
// navWaitBrowser returns a settled URL from nav/wait so the CLI can echo the
15+
// post-action URL in the envelope's target.
16+
type navWaitBrowser struct {
17+
fakeBrowser
18+
settled string
19+
}
20+
21+
func (n *navWaitBrowser) Navigate(_ context.Context, _, _ string) (map[string]any, error) {
22+
return map[string]any{"url": n.settled}, nil
23+
}
24+
func (n *navWaitBrowser) Wait(_ context.Context, _ string, _ chrome.WaitCond) (map[string]any, error) {
25+
return map[string]any{"waited": "url:after", "url": n.settled}, nil
26+
}
27+
28+
func TestNavReportsSettledURL(t *testing.T) {
29+
t.Parallel()
30+
b := &navWaitBrowser{
31+
fakeBrowser: fakeBrowser{tabs: []target.Info{{ID: "aa11", Title: "A", URL: "https://before/"}}},
32+
settled: "https://after/redirected",
33+
}
34+
env, _, code := run(t, b, "nav", "https://before/", "--target", "aa11", "--json")
35+
if code != 0 {
36+
t.Fatalf("exit = %d, want 0", code)
37+
}
38+
if got := env["target"].(map[string]any)["url"]; got != "https://after/redirected" {
39+
t.Errorf("target.url = %v, want the settled URL (not the pre-nav URL)", got)
40+
}
41+
}
42+
43+
func TestWaitReportsSettledURL(t *testing.T) {
44+
t.Parallel()
45+
b := &navWaitBrowser{
46+
fakeBrowser: fakeBrowser{tabs: []target.Info{{ID: "aa11", Title: "A", URL: "https://before/"}}},
47+
settled: "https://after/home",
48+
}
49+
env, _, code := run(t, b, "wait", "--url", "home", "--target", "aa11", "--json")
50+
if code != 0 {
51+
t.Fatalf("exit = %d, want 0", code)
52+
}
53+
if got := env["target"].(map[string]any)["url"]; got != "https://after/home" {
54+
t.Errorf("target.url = %v, want the settled URL", got)
55+
}
56+
}

0 commit comments

Comments
 (0)