diff --git a/docs/ceilings.md b/docs/ceilings.md index c0f17cfa..36fb788b 100644 --- a/docs/ceilings.md +++ b/docs/ceilings.md @@ -1033,7 +1033,9 @@ work when nobody knows it and that the call site never shows. The mechanism and none of 30 from 500 ms; Chrome for Testing 155 and Helium 0.19.2.1). The agent hears of a dialog the person answered, at most twenty between two page answers, but not of one the agent itself answered. A held page cannot lay out, so a pane opened or resized meanwhile shows it at the size it had, and lays it out at the pane's once the - dialog closes; a screenshot fails until then. + dialog closes; a screenshot fails until then. On Chrome 153 and older, which answers a screencast's start and + stop only once the dialog closes, a pane resized while one is open keeps its last frame until it is answered + (Chrome 151 on the macos-15-intel runner, measured; 154 on answers at once). A window opened with Open in a window was not tried with a dialog. ## Languages diff --git a/internal/browser/chrome.go b/internal/browser/chrome.go index 4218a613..e3cf7f53 100644 --- a/internal/browser/chrome.go +++ b/internal/browser/chrome.go @@ -9,6 +9,7 @@ import ( "os" "os/exec" "path/filepath" + "sync" "time" "github.com/chromedp/cdproto/browser" @@ -87,6 +88,12 @@ type chromePage struct { dl *downloads // gate is held while the page has a JavaScript dialog open. gate dialogGate + // castMu guards castLast, the last screencast start or stop queued, which + // the next one waits for: they can run on past their caller + // (castPastDialog). casting says one is running. + castMu sync.Mutex + castLast chan struct{} + casting bool } // downloadFolder is downloadDir, which the live tests point at a folder of diff --git a/internal/browser/chrome_view.go b/internal/browser/chrome_view.go index 486bd5e8..0bf914b4 100644 --- a/internal/browser/chrome_view.go +++ b/internal/browser/chrome_view.go @@ -190,22 +190,71 @@ func (p *chromePage) deliver(frame *page.EventScreencastFrame) { // Screencast starts the frames at v, or restarts them there: Chrome keeps // the size a screencast started with. func (p *chromePage) Screencast(ctx context.Context, v Viewport) error { - if err := p.runFree(ctx, page.StopScreencast()); err != nil { - return err + w, h := v.devicePixels() + return p.castPastDialog(func() error { + if err := p.stopCast(ctx); err != nil { + return err + } + if err := p.Resize(ctx, v); err != nil { + return err + } + p.casting = true + return p.runFree(ctx, page.StartScreencast(). + WithFormat(page.ScreencastFormatJpeg). + WithQuality(screencastQuality). + WithMaxWidth(w). + WithMaxHeight(h)) + }) +} + +func (p *chromePage) StopScreencast(ctx context.Context) error { + return p.castPastDialog(func() error { return p.stopCast(ctx) }) +} + +// stopCast stops the frames. With none running it sends nothing, so a pane +// opened on a held page starts its frames without a stop waiting first. +func (p *chromePage) stopCast(ctx context.Context) error { + if !p.casting { + return nil } - if err := p.Resize(ctx, v); err != nil { + if err := p.runFree(ctx, page.StopScreencast()); err != nil { return err } - w, h := v.devicePixels() - return p.runFree(ctx, page.StartScreencast(). - WithFormat(page.ScreencastFormatJpeg). - WithQuality(screencastQuality). - WithMaxWidth(w). - WithMaxHeight(h)) + p.casting = false + return nil } -func (p *chromePage) StopScreencast(ctx context.Context) error { - return p.runFree(ctx, page.StopScreencast()) +// castPastDialog runs step, a start or stop of the screencast, after the ones +// before it, and returns once it is done or the page holds a dialog. Chrome up +// to 153 answers Page.startScreencast and Page.stopScreencast only once the +// renderer has seen them, and a renderer holding a dialog sees nothing until +// it closes, though the browser starts or stops the frames at once: a pane +// opened on the dialog waited the whole pageWait and lost the browser (Chrome +// 151, macos-15-intel runner, measured 2026-10-28). step runs on after that. +func (p *chromePage) castPastDialog(step func() error) error { + p.castMu.Lock() + prev, mine := p.castLast, make(chan struct{}) + p.castLast = mine + p.castMu.Unlock() + done := make(chan error, 1) + go func() { + defer close(mine) + if prev != nil { + <-prev + } + done <- step() + }() + select { + case err := <-done: + return err + case <-p.gate.held(): + go func() { + if err := <-done; err != nil && p.Alive() { + slog.Warn("browser: start or stop the screencast under a dialog", "err", err) + } + }() + return nil + } } // Resize lays the page out at the pane's size, as a window of that size would. diff --git a/internal/browser/dialogs_test.go b/internal/browser/dialogs_test.go index 42912fff..b96c6082 100644 --- a/internal/browser/dialogs_test.go +++ b/internal/browser/dialogs_test.go @@ -214,3 +214,34 @@ func TestTheGateKeepsTheLastSizeAskedForWhileHeld(t *testing.T) { t.Fatalf("a second close handed back %+v", v) } } + +// A screencast step stands for Chrome 153 and older here, which answers it +// only once the dialog closes. +func TestAScreencastUnderADialogReturnsAndRunsInOrder(t *testing.T) { + var p chromePage + p.gate.open() + answered := make(chan struct{}) + var order []int + first := func() error { + <-answered + order = append(order, 1) + return nil + } + if err := p.castPastDialog(first); err != nil { + t.Fatal(err) + } + ran := make(chan struct{}) + second := func() error { + order = append(order, 2) + close(ran) + return nil + } + if err := p.castPastDialog(second); err != nil { + t.Fatal(err) + } + close(answered) + <-ran + if len(order) != 2 || order[0] != 1 { + t.Fatalf("steps ran as %v, want 1 then 2", order) + } +}