Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/strict-tigers-read.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@browserbasehq/stagehand-python": patch
"@browserbasehq/stagehand-go": patch
---

Make python and golang SDKs reject occupied local chrome debugging ports, and make local browser launch wait for readiness in python.
85 changes: 66 additions & 19 deletions packages/sdk-go/chrome_launcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"strconv"
"strings"
"sync"
"syscall"
"time"
)

Expand Down Expand Up @@ -91,6 +92,14 @@ func launchLocalBrowser(
func launchChrome(
ctx context.Context,
options LocalBrowserLaunchOptions,
) (*launchedChrome, error) {
return launchChromeWithPortResolver(ctx, options, resolveChromePort)
}

func launchChromeWithPortResolver(
ctx context.Context,
options LocalBrowserLaunchOptions,
resolvePort func(int) (int, error),
) (*launchedChrome, error) {
if ctx == nil {
return nil, errors.New("stagehand Chrome launch context is required")
Expand All @@ -106,26 +115,16 @@ func launchChrome(
if err != nil {
return nil, err
}
port := options.Port
if port == 0 {
port, err = availablePort()
if err != nil {
return nil, err
}
port, err := resolvePort(options.Port)
if err != nil {
return nil, err
}

userDataDir := options.UserDataDir
temporaryProfile := userDataDir == ""
if temporaryProfile {
userDataDir, err = os.MkdirTemp("", "stagehand-chrome-")
if err != nil {
return nil, fmt.Errorf("create Chrome profile: %w", err)
}
} else if err := os.MkdirAll(userDataDir, 0o700); err != nil {
return nil, fmt.Errorf("create Chrome profile %q: %w", userDataDir, err)
userDataDir, removeDir, err := resolveChromeProfile(options)
if err != nil {
return nil, err
}

removeDir := temporaryProfile && !options.PreserveUserDataDir
cleanupProfile := func() error {
if !removeDir {
return nil
Expand Down Expand Up @@ -173,6 +172,20 @@ func launchChrome(
return launched, nil
}

func resolveChromeProfile(options LocalBrowserLaunchOptions) (string, bool, error) {
if options.UserDataDir != "" {
if err := os.MkdirAll(options.UserDataDir, 0o700); err != nil {
return "", false, fmt.Errorf("create Chrome profile %q: %w", options.UserDataDir, err)
}
return options.UserDataDir, false, nil
}
userDataDir, err := os.MkdirTemp("", "stagehand-chrome-")
if err != nil {
return "", false, fmt.Errorf("create Chrome profile: %w", err)
}
return userDataDir, !options.PreserveUserDataDir, nil
}

func validateLocalBrowserOptions(options LocalBrowserLaunchOptions) error {
if options.Port < 0 || options.Port > 65_535 {
return errors.New("stagehand Chrome port must be 0 or between 1 and 65535")
Expand Down Expand Up @@ -356,15 +369,49 @@ func isFile(path string) bool {
}

func availablePort() (int, error) {
listener, err := net.Listen("tcp4", "127.0.0.1:0")
port, err := inspectChromePort(0)
if err != nil {
return 0, fmt.Errorf("select Chrome debugging port: %w", err)
}
defer listener.Close()
port := listener.Addr().(*net.TCPAddr).Port
return port, nil
}

func resolveChromePort(requestedPort int) (int, error) {
return resolveChromePortWith(requestedPort, inspectChromePort)
}

func resolveChromePortWith(
requestedPort int,
inspect func(int) (int, error),
) (int, error) {
if requestedPort == 0 {
port, err := inspect(0)
if err != nil {
return 0, fmt.Errorf("select Chrome debugging port: %w", err)
}
return port, nil
}
if _, err := inspect(requestedPort); err != nil {
if errors.Is(err, syscall.EADDRINUSE) {
return 0, fmt.Errorf("Chrome debugging port %d is already in use: %w", requestedPort, err)
}
return 0, fmt.Errorf("inspect Chrome debugging port %d: %w", requestedPort, err)
}
return requestedPort, nil
}

func inspectChromePort(port int) (int, error) {
listener, err := net.Listen("tcp4", net.JoinHostPort("127.0.0.1", strconv.Itoa(port)))
if err != nil {
return 0, err
}
assignedPort := listener.Addr().(*net.TCPAddr).Port
if err := listener.Close(); err != nil {
return 0, err
}
return assignedPort, nil
}

func waitForChrome(
ctx context.Context,
cdpURL string,
Expand Down
174 changes: 143 additions & 31 deletions packages/sdk-go/chrome_launcher_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,60 +4,96 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"math"
"net"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"reflect"
"slices"
"strings"
"syscall"
"testing"
"time"
)

const testWebMCPChromeFlag = "--enable-features=WebMCPTesting,DevToolsWebMCPSupport"

func TestDefaultChromeFlags(t *testing.T) {
want := []string{
"--disable-features=Translate,OptimizationHints,MediaRouter,DialMediaRouteProvider," +
"CalculateNativeWinOcclusion,InterestFeedContentSuggestions," +
"CertificateTransparencyComponentUpdater,AutofillServerCommunication," +
"PrivacySandboxSettings4,RenderDocument",
"--disable-component-extensions-with-background-pages",
"--disable-background-networking",
"--disable-component-update",
"--disable-client-side-phishing-detection",
"--disable-sync",
"--metrics-recording-only",
"--disable-default-apps",
"--mute-audio",
"--no-default-browser-check",
"--no-first-run",
"--disable-backgrounding-occluded-windows",
"--disable-renderer-backgrounding",
"--disable-background-timer-throttling",
"--disable-ipc-flooding-protection",
"--password-store=basic",
"--use-mock-keychain",
"--force-fieldtrials=*BackgroundTracing/default/",
"--disable-hang-monitor",
"--disable-prompt-on-repost",
"--disable-domain-reliability",
"--propagate-iph-for-testing",
"--enable-unsafe-extension-debugging",
"--remote-allow-origins=*",
testWebMCPChromeFlag,
fixturePath := filepath.Join("..", "..", "tests", "fixtures", "local-browser-default-flags.json")
fixture, err := os.ReadFile(fixturePath)
if err != nil {
t.Fatalf("read default Chrome flags fixture: %v", err)
}
if !reflect.DeepEqual(defaultChromeFlags, want) {
var want []string
if err := json.Unmarshal(fixture, &want); err != nil {
t.Fatalf("decode default Chrome flags fixture: %v", err)
}
if !slices.Equal(defaultChromeFlags, want) {
t.Fatalf("defaultChromeFlags = %#v, want %#v", defaultChromeFlags, want)
}
if slices.Contains(defaultChromeFlags, "--disable-extensions") {
t.Fatal("defaultChromeFlags contains --disable-extensions")
}
}

func TestLaunchedChromeProfileOwnership(t *testing.T) {
tests := []struct {
name string
removeDir bool
wantExist bool
}{
{name: "SDK-owned", removeDir: true, wantExist: false},
{name: "caller-owned or preserved", removeDir: false, wantExist: true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
profile := filepath.Join(t.TempDir(), "profile")
if err := os.Mkdir(profile, 0o700); err != nil {
t.Fatalf("create profile: %v", err)
}
done := make(chan struct{})
close(done)
launched := &launchedChrome{
userDataDir: profile,
process: &chromeProcess{done: done},
removeDir: test.removeDir,
}

if err := launched.close(context.Background()); err != nil {
t.Fatalf("close launched Chrome: %v", err)
}
_, err := os.Stat(profile)
if test.wantExist && err != nil {
t.Fatalf("preserved profile is unavailable: %v", err)
}
if !test.wantExist && !errors.Is(err, os.ErrNotExist) {
t.Fatalf("SDK-owned profile still exists: %v", err)
}
})
}
}

func TestResolveChromeProfileTreatsEmptyPathAsTemporary(t *testing.T) {
profile, remove, err := resolveChromeProfile(LocalBrowserLaunchOptions{UserDataDir: ""})
if err != nil {
t.Fatalf("resolveChromeProfile() error = %v", err)
}
t.Cleanup(func() {
if err := os.RemoveAll(profile); err != nil {
t.Errorf("remove temporary Chrome profile: %v", err)
}
})
if !remove {
t.Fatal("resolveChromeProfile() remove = false, want true")
}
if filepath.Base(profile) == "." || !strings.HasPrefix(filepath.Base(profile), "stagehand-chrome-") {
t.Fatalf("resolveChromeProfile() path = %q, want Stagehand temporary profile", profile)
}
}

func TestBuildChromeArgsSupportsLocalBrowserOptions(t *testing.T) {
t.Setenv("CI", "")
sandbox := false
Expand Down Expand Up @@ -452,4 +488,80 @@ func TestAvailablePort(t *testing.T) {
if port < 1 || port > 65_535 {
t.Fatalf("availablePort() = %d, want valid TCP port", port)
}
listener, err := net.Listen("tcp4", net.JoinHostPort("127.0.0.1", fmt.Sprint(port)))
if err != nil {
t.Fatalf("automatic port %d was not released: %v", port, err)
}
if err := listener.Close(); err != nil {
t.Fatalf("close automatic-port listener: %v", err)
}
}

func TestResolveChromePort(t *testing.T) {
t.Run("automatic", func(t *testing.T) {
got, err := resolveChromePortWith(0, func(port int) (int, error) {
if port != 0 {
t.Fatalf("inspect port = %d, want 0", port)
}
return 4567, nil
})
if err != nil || got != 4567 {
t.Fatalf("resolveChromePortWith() = (%d, %v), want (4567, nil)", got, err)
}
})

t.Run("explicit available", func(t *testing.T) {
got, err := resolveChromePortWith(9222, func(port int) (int, error) {
return port, nil
})
if err != nil || got != 9222 {
t.Fatalf("resolveChromePortWith() = (%d, %v), want (9222, nil)", got, err)
}
})

t.Run("explicit occupied", func(t *testing.T) {
_, err := resolveChromePortWith(9222, func(int) (int, error) {
return 0, syscall.EADDRINUSE
})
if !errors.Is(err, syscall.EADDRINUSE) ||
!strings.Contains(err.Error(), "Chrome debugging port 9222 is already in use") {
t.Fatalf("resolveChromePortWith() error = %v, want occupied-port error", err)
}
})

t.Run("other socket error", func(t *testing.T) {
socketErr := errors.New("socket unavailable")
_, err := resolveChromePortWith(9222, func(int) (int, error) {
return 0, socketErr
})
if !errors.Is(err, socketErr) || strings.Contains(err.Error(), "already in use") {
t.Fatalf("resolveChromePortWith() error = %v, want preserved socket error", err)
}
})
}

func TestLaunchChromeRejectsOccupiedPortBeforeProfileCreation(t *testing.T) {
profile := filepath.Join(t.TempDir(), "profile")
portChecked := false

_, err := launchChromeWithPortResolver(context.Background(), LocalBrowserLaunchOptions{
ExecutablePath: os.Args[0],
Port: 9222,
UserDataDir: profile,
}, func(port int) (int, error) {
portChecked = true
if port != 9222 {
t.Fatalf("resolve port = %d, want 9222", port)
}
return 0, fmt.Errorf("Chrome debugging port %d is already in use: %w", port, syscall.EADDRINUSE)
})
if err == nil || !strings.Contains(err.Error(), "already in use") {
t.Fatalf("launchChrome() error = %v, want occupied-port error", err)
}
if !portChecked {
t.Fatal("Chrome port was not checked")
}
if _, statErr := os.Stat(profile); !errors.Is(statErr, os.ErrNotExist) {
t.Fatalf("profile was created before occupied-port rejection: %v", statErr)
}
}
Loading
Loading