Skip to content
Open
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
58 changes: 58 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,64 @@ $ builder-playground cook l1 --latest-fork --output ~/my-builder-testnet --genes

To stop the playground, press `Ctrl+C`.

## Network Readiness

The playground can expose a `/readyz` HTTP endpoint to check if the network is ready to accept transactions (i.e., blocks are being produced).

### Readyz Endpoint

Enable the readyz server with the `--readyz-port` flag:

```bash
$ builder-playground cook l1 --readyz-port 8080
```

Then check readiness:

```bash
$ curl http://localhost:8080/readyz
{"ready":true}
```

Returns:
- `200 OK` with `{"ready": true}` when the network is producing blocks
- `503 Service Unavailable` with `{"ready": false, "error": "..."}` otherwise

### Wait-Ready Command

Use the `wait-ready` command to block until the network is ready:

```bash
$ builder-playground wait-ready [flags]
```

Flags:
- `--url` (string): readyz endpoint URL. Defaults to `http://localhost:8080/readyz`
- `--timeout` (duration): Maximum time to wait. Defaults to `60s`
- `--interval` (duration): Poll interval. Defaults to `1s`

Example:

```bash
# In terminal 1: Start the playground with readyz enabled
$ builder-playground cook l1 --readyz-port 8080

# In terminal 2: Wait for the network to be ready
$ builder-playground wait-ready --timeout 120s
Waiting for http://localhost:8080/readyz (timeout: 2m0s, interval: 1s)
[1s] Attempt 1: 503 Service Unavailable
[2s] Attempt 2: 503 Service Unavailable
[3s] Ready! (200 OK)
```

This is useful for CI/CD pipelines or scripts that need to wait for the network before deploying contracts.

Alternatively, use a bash one-liner:

```bash
$ timeout 60 bash -c 'until curl -sf http://localhost:8080/readyz | grep -q "\"ready\":true"; do sleep 1; done'
```

## Inspect

Builder-playground supports inspecting the connection of a service to a specific port.
Expand Down
26 changes: 26 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"time"

"github.com/flashbots/builder-playground/playground"
"github.com/flashbots/builder-playground/playground/cmd"
"github.com/spf13/cobra"
)

Expand All @@ -33,6 +34,7 @@ var platform string
var contenderEnabled bool
var contenderArgs []string
var contenderTarget string
var readyzPort int

var rootCmd = &cobra.Command{
Use: "playground",
Expand Down Expand Up @@ -185,6 +187,7 @@ func main() {
recipeCmd.Flags().BoolVar(&contenderEnabled, "contender", false, "spam nodes with contender")
recipeCmd.Flags().StringArrayVar(&contenderArgs, "contender.arg", []string{}, "add/override contender CLI flags")
recipeCmd.Flags().StringVar(&contenderTarget, "contender.target", "", "override the node that contender spams -- accepts names like \"el\"")
recipeCmd.Flags().IntVar(&readyzPort, "readyz-port", 0, "port for readyz HTTP endpoint (0 to disable)")

cookCmd.AddCommand(recipeCmd)
}
Expand All @@ -193,10 +196,13 @@ func main() {
artifactsCmd.Flags().StringVar(&outputFlag, "output", "", "Output folder for the artifacts")
artifactsAllCmd.Flags().StringVar(&outputFlag, "output", "", "Output folder for the artifacts")

cmd.InitWaitReadyCmd()

rootCmd.AddCommand(cookCmd)
rootCmd.AddCommand(artifactsCmd)
rootCmd.AddCommand(artifactsAllCmd)
rootCmd.AddCommand(inspectCmd)
rootCmd.AddCommand(cmd.WaitReadyCmd)

if err := rootCmd.Execute(); err != nil {
fmt.Println(err)
Expand Down Expand Up @@ -296,6 +302,16 @@ func runIt(recipe playground.Recipe) error {
cancel()
}()

var readyzServer *playground.ReadyzServer
if checker, ok := recipe.(playground.NetworkReadyChecker); ok && readyzPort > 0 {
readyzServer = playground.NewReadyzServer(checker, svcManager, readyzPort)
if err := readyzServer.Start(); err != nil {
return fmt.Errorf("failed to start readyz server: %w", err)
}
defer readyzServer.Stop()
fmt.Printf("Readyz endpoint available at http://localhost:%d/readyz\n", readyzPort)
}

if err := dockerRunner.Run(); err != nil {
dockerRunner.Stop()
return fmt.Errorf("failed to run docker: %w", err)
Expand Down Expand Up @@ -332,6 +348,16 @@ func runIt(recipe playground.Recipe) error {
return fmt.Errorf("failed to complete ready: %w", err)
}

if checker, ok := recipe.(playground.NetworkReadyChecker); ok {
fmt.Printf("\nWaiting for network to be ready for transactions...\n")
networkReadyStart := time.Now()
if err := checker.WaitForNetworkReady(ctx, svcManager, 60*time.Second); err != nil {
dockerRunner.Stop()
return fmt.Errorf("network not ready: %w", err)
}
fmt.Printf("Network is ready for transactions (took %.1fs)\n", time.Since(networkReadyStart).Seconds())
}

// get the output from the recipe
output := recipe.Output(svcManager)
if len(output) > 0 {
Expand Down
93 changes: 93 additions & 0 deletions playground/cmd/wait_ready.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package cmd

import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"os/signal"
"time"

"github.com/flashbots/builder-playground/playground"
"github.com/spf13/cobra"
)

var waitReadyURL string
var waitReadyTimeout time.Duration
var waitReadyInterval time.Duration

var WaitReadyCmd = &cobra.Command{
Use: "wait-ready",
Short: "Wait for the network to be ready for transactions",
RunE: func(cmd *cobra.Command, args []string) error {
return waitForReady()
},
}

func InitWaitReadyCmd() {
WaitReadyCmd.Flags().StringVar(&waitReadyURL, "url", "http://localhost:8080/readyz", "readyz endpoint URL")
WaitReadyCmd.Flags().DurationVar(&waitReadyTimeout, "timeout", 60*time.Second, "maximum time to wait")
WaitReadyCmd.Flags().DurationVar(&waitReadyInterval, "interval", 1*time.Second, "poll interval")
}

func waitForReady() error {
fmt.Printf("Waiting for %s (timeout: %s, interval: %s)\n", waitReadyURL, waitReadyTimeout, waitReadyInterval)

sig := make(chan os.Signal, 1)
signal.Notify(sig, os.Interrupt)

ctx, cancel := context.WithCancel(context.Background())
go func() {
<-sig
cancel()
}()

client := &http.Client{
Timeout: 5 * time.Second,
}

deadline := time.Now().Add(waitReadyTimeout)
attempt := 0

for time.Now().Before(deadline) {
select {
case <-ctx.Done():
return fmt.Errorf("interrupted")
default:
}

attempt++
elapsed := time.Since(deadline.Add(-waitReadyTimeout))

resp, err := client.Get(waitReadyURL)
if err != nil {
fmt.Printf(" [%s] Attempt %d: connection error: %v\n", elapsed.Truncate(time.Second), attempt, err)
time.Sleep(waitReadyInterval)
continue
}

var readyzResp playground.ReadyzResponse
if err := json.NewDecoder(resp.Body).Decode(&readyzResp); err != nil {
resp.Body.Close()
fmt.Printf(" [%s] Attempt %d: failed to parse response: %v\n", elapsed.Truncate(time.Second), attempt, err)
time.Sleep(waitReadyInterval)
continue
}
resp.Body.Close()

if resp.StatusCode == http.StatusOK && readyzResp.Ready {
fmt.Printf(" [%s] Ready! (200 OK)\n", elapsed.Truncate(time.Second))
return nil
}

errMsg := ""
if readyzResp.Error != "" {
errMsg = fmt.Sprintf(" - %s", readyzResp.Error)
}
fmt.Printf(" [%s] Attempt %d: %d %s%s\n", elapsed.Truncate(time.Second), attempt, resp.StatusCode, http.StatusText(resp.StatusCode), errMsg)
time.Sleep(waitReadyInterval)
}

return fmt.Errorf("timeout waiting for readyz after %s", waitReadyTimeout)
}
13 changes: 13 additions & 0 deletions playground/manifest.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package playground

import (
"context"
"encoding/json"
"fmt"
"os"
Expand All @@ -23,6 +24,18 @@ type Recipe interface {
Output(manifest *Manifest) map[string]interface{}
}

// NetworkReadyChecker is an optional interface that recipes can implement
// to provide network-level readiness checks beyond individual service health.
// This ensures the network is actually producing blocks and can accept transactions.
type NetworkReadyChecker interface {
// IsNetworkReady returns true if the network is ready to accept transactions.
// This can be used to implement /readyz endpoints.
IsNetworkReady(ctx context.Context, manifest *Manifest) (bool, error)

// WaitForNetworkReady blocks until the network is ready or context is cancelled/timeout.
WaitForNetworkReady(ctx context.Context, manifest *Manifest, timeout time.Duration) error
}

// Manifest describes a list of services and their dependencies
type Manifest struct {
ctx *ExContext
Expand Down
87 changes: 87 additions & 0 deletions playground/readyz.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package playground

import (
"context"
"encoding/json"
"fmt"
"net/http"
"sync"
)

type ReadyzServer struct {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the utility of this server? In the previous iteration, Playground was meant to work as a template engine, not as a long live instance.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right now it also works as runner for docker-compose, and this is just more correct way to actually ensure readiness for processing transactions. Example use case: we need to understand when we can start working with network in a CI job and when it's considered ready

Copy link
Contributor

@ferranbt ferranbt Nov 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree that it would be useful to have a way to ensure the playground is ready and healthy before doing other actions. But, I am not sure I follow what the intended workflow is.

Is the ready server running forever after the deployment is ready and healthy? Or it just stops alongside the deployment command?

Why is this workflow better than just using the wait-ready flag? I would find the ready server useful if it were to take minutes to deploy anything but it takes seconds right now.

checker NetworkReadyChecker
manifest *Manifest
port int
server *http.Server
mu sync.RWMutex
}

type ReadyzResponse struct {
Ready bool `json:"ready"`
Error string `json:"error,omitempty"`
}

func NewReadyzServer(checker NetworkReadyChecker, manifest *Manifest, port int) *ReadyzServer {
return &ReadyzServer{
checker: checker,
manifest: manifest,
port: port,
}
}

func (s *ReadyzServer) Start() error {
mux := http.NewServeMux()
mux.HandleFunc("/readyz", s.handleReadyz)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

while at it, might also be useful to add a /livez handler that always returns 200, just to indicate that it's running, but maybe not yet ready. see also https://github.com/flashbots/go-template/blob/3cbb5ff225ce89f828c56a959d03b93ae58b70aa/httpserver/server.go#L74


s.server = &http.Server{
Addr: fmt.Sprintf(":%d", s.port),
Handler: mux,
}

go func() {
if err := s.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
fmt.Printf("Readyz server error: %v\n", err)
}
}()

return nil
}

func (s *ReadyzServer) Stop() error {
if s.server != nil {
return s.server.Shutdown(context.Background())
}
return nil
}

func (s *ReadyzServer) handleReadyz(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}

ctx := r.Context()
ready, err := s.checker.IsNetworkReady(ctx, s.manifest)

response := ReadyzResponse{
Ready: ready,
}

if err != nil {
response.Error = err.Error()
}

w.Header().Set("Content-Type", "application/json")

if ready {
w.WriteHeader(http.StatusOK)
} else {
w.WriteHeader(http.StatusServiceUnavailable)
}

json.NewEncoder(w).Encode(response)
}

func (s *ReadyzServer) Port() int {
return s.port
}
11 changes: 11 additions & 0 deletions playground/recipe_buildernet.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
package playground

import (
"context"
"fmt"
"time"

flag "github.com/spf13/pflag"
)

var _ Recipe = &BuilderNetRecipe{}
var _ NetworkReadyChecker = &BuilderNetRecipe{}

// BuilderNetRecipe is a recipe that extends the L1 recipe to include builder-hub
type BuilderNetRecipe struct {
Expand Down Expand Up @@ -90,3 +93,11 @@ func (b *BuilderNetRecipe) Output(manifest *Manifest) map[string]interface{} {

return output
}

func (b *BuilderNetRecipe) IsNetworkReady(ctx context.Context, manifest *Manifest) (bool, error) {
return b.l1Recipe.IsNetworkReady(ctx, manifest)
}

func (b *BuilderNetRecipe) WaitForNetworkReady(ctx context.Context, manifest *Manifest, timeout time.Duration) error {
return b.l1Recipe.WaitForNetworkReady(ctx, manifest, timeout)
}
Loading
Loading