Skip to content

Commit

Permalink
crocochrome: add
Browse files Browse the repository at this point in the history
  • Loading branch information
nadiamoe committed May 28, 2024
1 parent 222f8cd commit 4ca6cc2
Show file tree
Hide file tree
Showing 7 changed files with 477 additions and 0 deletions.
26 changes: 26 additions & 0 deletions cmd/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package main

import (
"log/slog"
"net/http"
"os"

"github.com/grafana/crocochrome"
crocohttp "github.com/grafana/crocochrome/http"
)

func main() {
logger := slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{
Level: slog.LevelDebug,
}))

supervisor := crocochrome.New(logger, crocochrome.Options{ChromiumPath: "chromium"})
server := crocohttp.New(logger, supervisor)

const address = ":8080"
logger.Info("Starting HTTP server", "address", address)
err := http.ListenAndServe(address, server)
if err != nil {
logger.Error("Setting up HTTP listener", "err", err)
}
}
165 changes: 165 additions & 0 deletions crocochrome.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
package crocochrome

import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"log/slog"
"net"
"os/exec"
"sync"
"time"

"github.com/grafana/crocochrome/chromium"
)

type Supervisor struct {
opts Options
logger *slog.Logger
sessions map[string]context.CancelFunc
sessionsMtx sync.Mutex
}

type Options struct {
// ChromiumPath is the path to the chromium executable.
// Must be specified.
ChromiumPath string
// ChromiumPort is the port where chromium will be instructed to listen.
// Defaults to 5222.
ChromiumPort string
// Maximum time a browser is allowed to be running, after which it will be killed unconditionally.
// Defaults to 5m.
SessionTimeout time.Duration
}

func (o Options) withDefaults() Options {
if o.ChromiumPort == "" {
o.ChromiumPort = "5222"
}

if o.SessionTimeout == 0 {
o.SessionTimeout = 5 * time.Minute
}

return o
}

type SessionInfo struct {
ID string `json:"id"`
ChromiumVersion *chromium.VersionInfo `json:"chromiumVersion"`
}

func New(logger *slog.Logger, opts Options) *Supervisor {
return &Supervisor{
opts: opts.withDefaults(),
logger: logger,
sessions: map[string]context.CancelFunc{},
}
}

// Sessions returns the list of Session IDs.
func (s *Supervisor) Sessions() []string {
s.sessionsMtx.Lock()
defer s.sessionsMtx.Unlock()

ids := make([]string, 0, len(s.sessions))
for id := range s.sessions {
ids = append(ids, id)
}

return ids
}

// Session creates a new browser session, and returns its information.
func (s *Supervisor) Session() (SessionInfo, error) {
s.sessionsMtx.Lock()
defer s.sessionsMtx.Unlock()

s.killExisting()

id := randString()
logger := s.logger.With("sessionID", id)

ctx, cancel := context.WithTimeout(context.Background(), s.opts.SessionTimeout)
s.sessions[id] = cancel

context.AfterFunc(ctx, func() {
// The session context may be cancelled by calling s.Delete, but may also timeout naturally. Here we call
// s.Delete to ensure that the session is removed from the map.
// If the session is deleted by s.Delete, then it will be called again by this function, but that is okay.
logger.Debug("context cancelled, deleting session")
s.Delete(id) // AfterFunc runs on a separate goroutine, so we want the mutex-locking version.
})

go func() {
logger.Debug("starting session")
cmd := exec.CommandContext(ctx,
s.opts.ChromiumPath,
"--headless",
"--remote-debugging-address=0.0.0.0",
"--remote-debugging-port="+s.opts.ChromiumPort,
"--no-sandbox", // TODO: Sandbox.
)
cmd.Env = []string{}

err := cmd.Run()
if err != nil && !errors.Is(ctx.Err(), context.Canceled) {
logger.Error("running chromium", "err", err)
}
}()

version, err := chromium.Version(net.JoinHostPort("localhost", s.opts.ChromiumPort), 2*time.Second)
if err != nil {
logger.Error("could not get chromium info", "err", err)
s.delete(id) // We were not able to connect to chrome, the session is borked.
return SessionInfo{}, err
}

return SessionInfo{
ID: id,
ChromiumVersion: version,
}, nil
}

// Delete cancels a session's context and removes it from the map.
func (s *Supervisor) Delete(sessionID string) bool {
s.sessionsMtx.Lock()
defer s.sessionsMtx.Unlock()

return s.delete(sessionID)
}

// delete cancels a session's context and removes it from the map, without locking the mutex.
// It must be used only inside functions that already grab the lock.
func (s *Supervisor) delete(sessionID string) bool {
if cancelSession, found := s.sessions[sessionID]; found {
s.logger.Debug("cancelling context and deleting session", "sessionID", sessionID)
cancelSession()
delete(s.sessions, sessionID)
return true
}

return false
}

// killExisting cancels all sessions present in the map.
func (s *Supervisor) killExisting() {
for id := range s.sessions {
s.logger.Error("existing session found, killing", "sessionID", id)
s.delete(id)
}
}

// randString returns 12 random hex characters.
func randString() string {
const IDLen = 12
idBytes := make([]byte, IDLen/2)
_, err := rand.Read(idBytes)
if err != nil {
panic(fmt.Errorf("error reading random bytes, %w", err))
}

return hex.EncodeToString(idBytes)
}
163 changes: 163 additions & 0 deletions crocochrome_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
package crocochrome_test

import (
"log/slog"
"net/url"
"os"
"slices"
"testing"
"time"

"github.com/grafana/crocochrome"
"github.com/grafana/crocochrome/testutil"
)

func TestCrocochrome(t *testing.T) {
t.Parallel()

logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{}))

t.Run("creates a session", func(t *testing.T) {
t.Parallel()

hb := testutil.NewHeartbeat(t)
port := testutil.HTTPInfo(t, testutil.ChromiumVersion)
cc := crocochrome.New(logger, crocochrome.Options{ChromiumPath: hb.Path, ChromiumPort: port})
session, err := cc.Session()
if err != nil {
t.Fatalf("creating session: %v", err)
}

hb.AssertAliveDead(1, 0)

t.Run("returns valid session info", func(t *testing.T) {
t.Parallel()

u, err := url.Parse(session.ChromiumVersion.WebSocketDebuggerURL)
if err != nil {
t.Fatalf("invalid wsurl %q: %v", session.ChromiumVersion.WebSocketDebuggerURL, err)
}

if u.Scheme != "ws" {
t.Fatalf("unexpected scheme %q", u.Scheme)
}
if u.Port() != "9222" { // As returned by testutil.ChromiumVersion()
t.Fatalf("unexpected port %q", u.Port())
}
})

t.Run("returns session ID in list", func(t *testing.T) {
if list := cc.Sessions(); !slices.Contains(list, session.ID) {
t.Fatalf("session ID %q not found in sessions list %v", session.ID, list)
}
})
})

t.Run("returns an error and kills chromium", func(t *testing.T) {
t.Parallel()

t.Run("when chromium returns 500", func(t *testing.T) {
t.Parallel()

hb := testutil.NewHeartbeat(t)
port := testutil.HTTPInfo(t, testutil.Version500)
cc := crocochrome.New(logger, crocochrome.Options{ChromiumPath: hb.Path, ChromiumPort: port})
_, err := cc.Session()

if err == nil {
t.Fatalf("expected an error, got: %v", err)
}

hb.AssertAliveDead(0, 1)
})

t.Run("when chromium is not reachable", func(t *testing.T) {
t.Parallel()

hb := testutil.NewHeartbeat(t)
cc := crocochrome.New(logger, crocochrome.Options{ChromiumPath: hb.Path, ChromiumPort: "0"})
_, err := cc.Session()

if err == nil {
t.Fatalf("expected an error, got: %v", err)
}

hb.AssertAliveDead(0, 1)
})
})

t.Run("terminates a session when asked", func(t *testing.T) {
t.Parallel()

hb := testutil.NewHeartbeat(t)
port := testutil.HTTPInfo(t, testutil.ChromiumVersion)
cc := crocochrome.New(logger, crocochrome.Options{ChromiumPath: hb.Path, ChromiumPort: port})
sess, err := cc.Session()
if err != nil {
t.Fatalf("creating session: %v", err)
}

hb.AssertAliveDead(1, 0)
cc.Delete(sess.ID)
hb.AssertAliveDead(0, 1)

t.Run("session is removed from list", func(t *testing.T) {
if list := cc.Sessions(); len(list) > 0 {
t.Fatalf("expected sessions list to be empty, not %v", list)
}
})
})

t.Run("terminates a session when another is created", func(t *testing.T) {
t.Parallel()

hb := testutil.NewHeartbeat(t)
port := testutil.HTTPInfo(t, testutil.ChromiumVersion)
cc := crocochrome.New(logger, crocochrome.Options{ChromiumPath: hb.Path, ChromiumPort: port})

sess1, err := cc.Session()
if err != nil {
t.Fatalf("creating session: %v", err)
}

hb.AssertAliveDead(1, 0)

_, err = cc.Session()
if err != nil {
t.Fatalf("creating second session: %v", err)
}

hb.AssertAliveDead(1, 1)

t.Run("session is removed from list", func(t *testing.T) {
if list := cc.Sessions(); slices.Contains(list, sess1.ID) {
t.Fatalf("session list %v should not contain terminated session %q", list, sess1.ID)
}
})
})

t.Run("terminates a session after timeout", func(t *testing.T) {
t.Parallel()

hb := testutil.NewHeartbeat(t)
port := testutil.HTTPInfo(t, testutil.ChromiumVersion)
cc := crocochrome.New(logger, crocochrome.Options{ChromiumPath: hb.Path, ChromiumPort: port, SessionTimeout: 3 * time.Second})

_, err := cc.Session()
if err != nil {
t.Fatalf("creating session: %v", err)
}

hb.AssertAliveDead(1, 0)

time.Sleep(4 * time.Second)

hb.AssertAliveDead(0, 1)

t.Run("session is removed from list", func(t *testing.T) {
if list := cc.Sessions(); len(list) > 0 {
t.Fatalf("expected sessions list to be empty, not %v", list)
}
})
})
}
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module github.com/grafana/crocochrome

go 1.22
Empty file added go.sum
Empty file.
Loading

0 comments on commit 4ca6cc2

Please sign in to comment.