avoid hanging when Chrome is closed separately

It's Run that actually starts a Browser, not NewContext. If the browser
is closed or crashes, the browser handler will fail to read from the
websocket, and its goroutines will stop.

However, the target handler's goroutines may not stop. The browser
handler uses a separate cancel function to stop itself when encountering
a websocket error, so that doesn't propagate to the original context
children, like the target handler.

To fix this, make it so that NewContext can keep the cancel function
around, for Run to use it in this scenario. And add a test case that
tests this very edge case, which used to time out before the fix.

Fixes #289.
This commit is contained in:
Daniel Martí 2019-04-07 19:25:03 +02:00
parent b977e305d2
commit 939d377090
4 changed files with 74 additions and 9 deletions

View File

@ -157,6 +157,7 @@ func (p *ExecAllocator) Allocate(ctx context.Context) (*Browser, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
browser.process = cmd.Process
browser.userDataDir = dataDir browser.userDataDir = dataDir
return browser, nil return browser, nil
} }

View File

@ -10,6 +10,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"log" "log"
"os"
"sync/atomic" "sync/atomic"
"github.com/mailru/easyjson" "github.com/mailru/easyjson"
@ -24,8 +25,6 @@ import (
// the browser process runner, WebSocket clients, associated targets, and // the browser process runner, WebSocket clients, associated targets, and
// network, page, and DOM events. // network, page, and DOM events.
type Browser struct { type Browser struct {
userDataDir string
conn Transport conn Transport
// next is the next message id. // next is the next message id.
@ -43,6 +42,16 @@ type Browser struct {
// logging funcs // logging funcs
logf func(string, ...interface{}) logf func(string, ...interface{})
errf func(string, ...interface{}) errf func(string, ...interface{})
// The optional fields below are helpful for some tests.
// process can be initialized by the allocators which start a process
// when allocating a browser.
process *os.Process
// userDataDir can be initialized by the allocators which set up user
// data dirs directly.
userDataDir string
} }
type newTab struct { type newTab struct {
@ -164,9 +173,7 @@ type tabEvent struct {
func (b *Browser) run(ctx context.Context) { func (b *Browser) run(ctx context.Context) {
defer b.conn.Close() defer b.conn.Close()
// add cancel to context cancel := FromContext(ctx).cancel
ctx, cancel := context.WithCancel(ctx)
defer cancel()
// tabEventQueue is the queue of incoming target events, to be routed by // tabEventQueue is the queue of incoming target events, to be routed by
// their session ID. // their session ID.
@ -179,10 +186,13 @@ func (b *Browser) run(ctx context.Context) {
// connection. The separate goroutine is needed since a websocket read // connection. The separate goroutine is needed since a websocket read
// is blocking, so it cannot be used in a select statement. // is blocking, so it cannot be used in a select statement.
go func() { go func() {
defer cancel()
for { for {
msg, err := b.conn.Read() msg, err := b.conn.Read()
if err != nil { if err != nil {
// If the websocket failed, most likely Chrome
// was closed or crashed. Cancel the entire
// Browser context to stop all activity.
cancel()
return return
} }
if msg.Method == cdproto.EventRuntimeExceptionThrown { if msg.Method == cdproto.EventRuntimeExceptionThrown {
@ -232,8 +242,6 @@ func (b *Browser) run(ctx context.Context) {
// This goroutine handles tabs, as well as routing events to each tab // This goroutine handles tabs, as well as routing events to each tab
// via the pages map. // via the pages map.
go func() { go func() {
defer cancel()
// This map is only safe for use within this goroutine, so don't // This map is only safe for use within this goroutine, so don't
// declare it as a Browser field. // declare it as a Browser field.
pages := make(map[target.SessionID]*Target, 1024) pages := make(map[target.SessionID]*Target, 1024)

View File

@ -30,6 +30,12 @@ type Context struct {
// have its own unique Target pointing to a separate browser tab (page). // have its own unique Target pointing to a separate browser tab (page).
Target *Target Target *Target
// cancel simply cancels the context that was used to start Browser.
// This is useful to stop all activity and avoid deadlocks if we detect
// that the browser was closed or happened to crash. Note that this
// cancel function doesn't do any waiting.
cancel func()
// first records whether this context was the one that allocated // first records whether this context was the one that allocated
// Browser. This is important, because its cancellation will stop the // Browser. This is important, because its cancellation will stop the
// entire browser handler, meaning that no further actions can be // entire browser handler, meaning that no further actions can be
@ -44,7 +50,7 @@ type Context struct {
func NewContext(parent context.Context, opts ...ContextOption) (context.Context, context.CancelFunc) { func NewContext(parent context.Context, opts ...ContextOption) (context.Context, context.CancelFunc) {
ctx, cancel := context.WithCancel(parent) ctx, cancel := context.WithCancel(parent)
c := &Context{} c := &Context{cancel: cancel}
if pc := FromContext(parent); pc != nil { if pc := FromContext(parent); pc != nil {
c.Allocator = pc.Allocator c.Allocator = pc.Allocator
c.Browser = pc.Browser c.Browser = pc.Browser

View File

@ -2,7 +2,13 @@ package chromedp
import ( import (
"context" "context"
"fmt"
"net/http"
"net/http/httptest"
"os"
"runtime"
"testing" "testing"
"time"
) )
func TestTargets(t *testing.T) { func TestTargets(t *testing.T) {
@ -48,3 +54,47 @@ func TestTargets(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
} }
func TestBrowserQuit(t *testing.T) {
t.Parallel()
if runtime.GOOS == "windows" {
t.Skip("os.Interrupt isn't supported on Windows")
}
// Simulate a scenario where we navigate to a page that's slow to
// respond, and the browser is closed before we can finish the
// navigation.
serve := make(chan bool, 1)
close := make(chan bool, 1)
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
close <- true
<-serve
fmt.Fprintf(w, "response")
}))
defer s.Close()
ctx, cancel := NewContext(context.Background())
defer cancel()
if err := Run(ctx); err != nil {
t.Fatal(err)
}
go func() {
<-close
b := FromContext(ctx).Browser
if err := b.process.Signal(os.Interrupt); err != nil {
t.Error(err)
}
serve <- true
}()
// Run should error with something other than "deadline exceeded" in
// much less than 5s.
ctx2, _ := context.WithTimeout(ctx, 5*time.Second)
switch err := Run(ctx2, Navigate(s.URL)); err {
case nil:
t.Fatal("did not expect a nil error")
case context.DeadlineExceeded:
t.Fatalf("did not expect a standard context error: %v", err)
}
}