-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
7 changed files
with
477 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} | ||
}) | ||
}) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
module github.com/grafana/crocochrome | ||
|
||
go 1.22 |
Oops, something went wrong.