Skip to content

Commit 77cf7fc

Browse files
authored
feat: make build timeout adjustable (#397)
* feat: make build timeout configurable * chore: document timeout var * feat: format sigkill errors * feat: format build timeout errors
1 parent f5c29c6 commit 77cf7fc

File tree

7 files changed

+167
-47
lines changed

7 files changed

+167
-47
lines changed

cmd/playground/main.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@ func main() {
4040
analyzer.SetLogger(logger)
4141
defer logger.Sync() //nolint:errcheck
4242

43+
if err := cfg.Validate(); err != nil {
44+
logger.Fatal("invalid server configuration", zap.Error(err))
45+
}
46+
4347
goRoot, err := builder.GOROOT()
4448
if err != nil {
4549
logger.Fatal("Failed to find GOROOT environment variable value", zap.Error(err))
@@ -53,6 +57,7 @@ func main() {
5357
func start(goRoot string, logger *zap.Logger, cfg *config.Config) error {
5458
logger.Info("Starting service",
5559
zap.String("version", Version), zap.Any("config", cfg))
60+
5661
analyzer.SetRoot(goRoot)
5762
packages, err := analyzer.ReadPackagesFile(cfg.Build.PackagesFile)
5863
if err != nil {
@@ -92,7 +97,12 @@ func start(goRoot string, logger *zap.Logger, cfg *config.Config) error {
9297
Mount(apiRouter)
9398

9499
apiv2Router := apiRouter.PathPrefix("/v2").Subrouter()
95-
server.NewAPIv2Handler(playgroundClient, buildSvc).Mount(apiv2Router)
100+
server.NewAPIv2Handler(server.APIv2HandlerConfig{
101+
Client: playgroundClient,
102+
Builder: buildSvc,
103+
BuildTimeout: cfg.Build.GoBuildTimeout,
104+
}).Mount(apiv2Router)
105+
//server.NewAPIv2Handler(playgroundClient, buildSvc).Mount(apiv2Router)
96106

97107
// Web UI routes
98108
tplVars := server.TemplateArguments{

docs/deployment/docker/README.md

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -20,21 +20,22 @@ docker-compose ps
2020

2121
Playground server can be configured using environment variables described below.
2222

23-
| Environment Variable | Example | Description |
24-
|------------------------|--------------------------------|----------------------------------------------------------------|
25-
| `GOROOT` | `/usr/local/go` | Go root location. Uses `go env GOROOT` as fallback. |
26-
| `APP_DEBUG` | `false` | Enables debug logging. |
27-
| `APP_LOG_LEVEL` | `info` | Logger log level. `debug` requires `APP_DEBUG` env var. |
28-
| `APP_LOG_FORMAT` | `console`, `json` | Log format |
29-
| `APP_PLAYGROUND_URL` | `https://play.golang.org` | Official Go playground service URL. |
30-
| `APP_GOTIP_URL` | `https://gotipplay.golang.org` | GoTip playground service URL. |
31-
| `APP_BUILD_DIR` | `/var/cache/wasm` | Path to store cached WebAssembly builds. |
32-
| `APP_CLEAN_INTERVAL` | `10m` | WebAssembly build files cache cleanup interval. |
33-
| `APP_SKIP_MOD_CLEANUP` | `1` | Disables WASM builds cache cleanup. |
34-
| `APP_PERMIT_ENV_VARS` | `GOSUMDB,GOPROXY` | Restricts list of environment variables passed to Go compiler. |
35-
| `HTTP_READ_TIMEOUT` | `15s` | HTTP request read timeout. |
36-
| `HTTP_WRITE_TIMEOUT` | `60s` | HTTP response timeout. |
37-
| `HTTP_IDLE_TIMEOUT` | `90s` | HTTP keep alive timeout. |
23+
| Environment Variable | Example | Description |
24+
|------------------------|--------------------------------|--------------------------------------------------------------------------------------------------|
25+
| `GOROOT` | `/usr/local/go` | Go root location. Uses `go env GOROOT` as fallback. |
26+
| `APP_DEBUG` | `false` | Enables debug logging. |
27+
| `APP_LOG_LEVEL` | `info` | Logger log level. `debug` requires `APP_DEBUG` env var. |
28+
| `APP_LOG_FORMAT` | `console`, `json` | Log format |
29+
| `APP_PLAYGROUND_URL` | `https://play.golang.org` | Official Go playground service URL. |
30+
| `APP_GOTIP_URL` | `https://gotipplay.golang.org` | GoTip playground service URL. |
31+
| `APP_BUILD_DIR` | `/var/cache/wasm` | Path to store cached WebAssembly builds. |
32+
| `APP_CLEAN_INTERVAL` | `10m` | WebAssembly build files cache cleanup interval. |
33+
| `APP_SKIP_MOD_CLEANUP` | `1` | Disables WASM builds cache cleanup. |
34+
| `APP_PERMIT_ENV_VARS` | `GOSUMDB,GOPROXY` | Restricts list of environment variables passed to Go compiler. |
35+
| `APP_GO_BUILD_TIMEOUT` | `40s` | Go WebAssembly program build timeout. Includes dependency download process via `go mod download` |
36+
| `HTTP_READ_TIMEOUT` | `15s` | HTTP request read timeout. |
37+
| `HTTP_WRITE_TIMEOUT` | `60s` | HTTP response timeout. |
38+
| `HTTP_IDLE_TIMEOUT` | `90s` | HTTP keep alive timeout. |
3839

3940
## Building custom image
4041

internal/builder/compiler.go

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,7 @@ func (s BuildService) runGoTool(ctx context.Context, workDir string, args ...str
181181
zap.Error(err), zap.Strings("cmd", cmd.Args), zap.Stringer("stderr", buff),
182182
)
183183

184-
return newBuildErrorFromStdout(err, buff)
184+
return formatBuildError(ctx, err, buff)
185185
}
186186

187187
return nil
@@ -212,11 +212,3 @@ func (s BuildService) Clean(ctx context.Context) error {
212212

213213
return nil
214214
}
215-
216-
func newBuildErrorFromStdout(err error, buff *bytes.Buffer) error {
217-
if buff.Len() > 0 {
218-
return newBuildError(buff.String())
219-
}
220-
221-
return newBuildError("Process returned error: %s", err)
222-
}

internal/builder/error.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package builder
22

33
import (
4+
"bytes"
5+
"context"
46
"errors"
57
"fmt"
68
)
@@ -31,3 +33,37 @@ func IsBuildError(err error) bool {
3133
dst := new(BuildError)
3234
return errors.As(err, &dst)
3335
}
36+
37+
func checkContextErrors(err error) (error, bool) {
38+
if err == nil {
39+
return nil, false
40+
}
41+
42+
if errors.Is(err, context.Canceled) {
43+
return err, true
44+
}
45+
46+
if errors.Is(err, context.DeadlineExceeded) {
47+
return newBuildError("Go program build timeout exceeded"), true
48+
}
49+
50+
return nil, false
51+
}
52+
53+
func formatBuildError(ctx context.Context, err error, buff *bytes.Buffer) error {
54+
if buff.Len() > 0 {
55+
return newBuildError(buff.String())
56+
}
57+
58+
newErr, ok := checkContextErrors(err)
59+
if ok {
60+
return newErr
61+
}
62+
63+
newErr, ok = checkContextErrors(ctx.Err())
64+
if ok {
65+
return newErr
66+
}
67+
68+
return newBuildError("Build process returned an error: %s", err)
69+
}

internal/config/config.go

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,11 @@ import (
1313
)
1414

1515
const (
16-
DefaultWriteTimeout = 60 * time.Second
17-
DefaultReadTimeout = 15 * time.Second
18-
DefaultIdleTimeout = 90 * time.Second
16+
DefaultWriteTimeout = 60 * time.Second
17+
DefaultReadTimeout = 15 * time.Second
18+
DefaultIdleTimeout = 90 * time.Second
19+
DefaultGoBuildTimeout = 40 * time.Second
20+
DefaultCleanInterval = 10 * time.Minute
1921
)
2022

2123
type HTTPConfig struct {
@@ -71,6 +73,9 @@ type BuildConfig struct {
7173
// CleanupInterval is WebAssembly build artifact cache clean interval
7274
CleanupInterval time.Duration `envconfig:"APP_CLEAN_INTERVAL" json:"cleanupInterval"`
7375

76+
// GoBuildTimeout is Go program build timeout.
77+
GoBuildTimeout time.Duration `envconfig:"APP_GO_BUILD_TIMEOUT" json:"goBuildTimeout"`
78+
7479
// SkipModuleCleanup disables Go module cache cleanup.
7580
SkipModuleCleanup bool `envconfig:"APP_SKIP_MOD_CLEANUP" json:"skipModuleCleanup"`
7681

@@ -85,7 +90,8 @@ func (cfg *BuildConfig) mountFlagSet(f *flag.FlagSet) {
8590
f.StringVar(&cfg.PackagesFile, "f", "packages.json", "Path to packages index JSON file")
8691
f.StringVar(&cfg.BuildDir, "wasm-build-dir", os.TempDir(), "Directory for WASM builds")
8792
f.BoolVar(&cfg.SkipModuleCleanup, "skip-mod-clean", false, "Skip Go module cache cleanup")
88-
f.DurationVar(&cfg.CleanupInterval, "clean-interval", 10*time.Minute, "Build directory cleanup interval")
93+
f.DurationVar(&cfg.CleanupInterval, "clean-interval", DefaultCleanInterval, "Build directory cleanup interval")
94+
f.DurationVar(&cfg.GoBuildTimeout, "go-build-timeout", DefaultGoBuildTimeout, "Go program build timeout.")
8995
f.Var(cmdutil.NewStringsListValue(&cfg.BypassEnvVarsList), "permit-env-vars", "Comma-separated allow list of environment variables passed to Go compiler tool")
9096
}
9197

@@ -106,6 +112,18 @@ type Config struct {
106112
Services ServicesConfig `json:"services"`
107113
}
108114

115+
// Validate validates a config and returns error if config is invalid.
116+
func (cfg Config) Validate() error {
117+
if cfg.Build.GoBuildTimeout > cfg.HTTP.WriteTimeout {
118+
return fmt.Errorf(
119+
"go build timeout (%s) exceeds HTTP response timeout (%s)",
120+
cfg.Build.GoBuildTimeout, cfg.HTTP.WriteTimeout,
121+
)
122+
}
123+
124+
return nil
125+
}
126+
109127
// FromFlagSet returns config file which will read values from flags
110128
// when flag.Parse will be called.
111129
func FromFlagSet(f *flag.FlagSet) *Config {

internal/config/config_test.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package config
22

33
import (
44
"flag"
5+
"fmt"
56
"testing"
67
"time"
78

@@ -28,6 +29,7 @@ func TestFromFlags(t *testing.T) {
2829
CleanupInterval: 1 * time.Hour,
2930
BypassEnvVarsList: []string{"FOO", "BAR"},
3031
SkipModuleCleanup: true,
32+
GoBuildTimeout: 4 * time.Second,
3133
},
3234
Services: ServicesConfig{GoogleAnalyticsID: "GA-123456"},
3335
Log: LogConfig{
@@ -60,6 +62,7 @@ func TestFromFlags(t *testing.T) {
6062
"-http-read-timeout=7s",
6163
"-http-write-timeout=6s",
6264
"-http-idle-timeout=3s",
65+
"-go-build-timeout=4s",
6366
}
6467

6568
fl := flag.NewFlagSet("app", flag.PanicOnError)
@@ -118,6 +121,7 @@ func TestFromEnv(t *testing.T) {
118121
CleanupInterval: 1 * time.Hour,
119122
BypassEnvVarsList: []string{"FOO", "BAR"},
120123
SkipModuleCleanup: true,
124+
GoBuildTimeout: time.Hour,
121125
},
122126
Services: ServicesConfig{GoogleAnalyticsID: "GA-123456"},
123127
Log: LogConfig{
@@ -151,6 +155,7 @@ func TestFromEnv(t *testing.T) {
151155
"HTTP_READ_TIMEOUT": "21s",
152156
"HTTP_WRITE_TIMEOUT": "22s",
153157
"HTTP_IDLE_TIMEOUT": "23s",
158+
"APP_GO_BUILD_TIMEOUT": "1h",
154159
},
155160
},
156161
}
@@ -173,3 +178,48 @@ func TestFromEnv(t *testing.T) {
173178
})
174179
}
175180
}
181+
182+
func TestConfig_Validate(t *testing.T) {
183+
cases := map[string]struct {
184+
cfg func(t *testing.T) Config
185+
expectErr string
186+
}{
187+
"default config is valid": {
188+
cfg: func(t *testing.T) Config {
189+
fset := flag.NewFlagSet("foo", flag.PanicOnError)
190+
cfg := FromFlagSet(fset)
191+
require.NoError(t, fset.Parse([]string{"foo"}))
192+
require.NotNil(t, cfg)
193+
return *cfg
194+
},
195+
},
196+
"go build timeout": {
197+
expectErr: fmt.Sprintf(
198+
"go build timeout (%s) exceeds HTTP response timeout (%s)",
199+
time.Hour, time.Second,
200+
),
201+
cfg: func(_ *testing.T) Config {
202+
return Config{
203+
Build: BuildConfig{
204+
GoBuildTimeout: time.Hour,
205+
},
206+
HTTP: HTTPConfig{
207+
WriteTimeout: time.Second,
208+
},
209+
}
210+
},
211+
},
212+
}
213+
214+
for n, c := range cases {
215+
t.Run(n, func(t *testing.T) {
216+
err := c.cfg(t).Validate()
217+
if c.expectErr == "" {
218+
require.NoError(t, err)
219+
return
220+
}
221+
222+
require.EqualError(t, err, c.expectErr)
223+
})
224+
}
225+
}

internal/server/handler_v2.go

Lines changed: 31 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -17,19 +17,31 @@ import (
1717

1818
var ErrEmptyRequest = errors.New("empty request")
1919

20+
type APIv2HandlerConfig struct {
21+
Client *goplay.Client
22+
Builder builder.BuildService
23+
BuildTimeout time.Duration
24+
}
25+
26+
func (cfg APIv2HandlerConfig) buildContext(parentCtx context.Context) (context.Context, context.CancelFunc) {
27+
if cfg.BuildTimeout == 0 {
28+
return parentCtx, func() {}
29+
}
30+
31+
return context.WithDeadline(parentCtx, time.Now().Add(cfg.BuildTimeout))
32+
}
33+
2034
type APIv2Handler struct {
21-
logger *zap.Logger
22-
compiler builder.BuildService
23-
client *goplay.Client
24-
limiter *rate.Limiter
35+
logger *zap.Logger
36+
limiter *rate.Limiter
37+
cfg APIv2HandlerConfig
2538
}
2639

27-
func NewAPIv2Handler(client *goplay.Client, builder builder.BuildService) *APIv2Handler {
40+
func NewAPIv2Handler(cfg APIv2HandlerConfig) *APIv2Handler {
2841
return &APIv2Handler{
29-
logger: zap.L().Named("api.v2"),
30-
compiler: builder,
31-
client: client,
32-
limiter: rate.NewLimiter(rate.Every(frameTime), compileRequestsPerFrame),
42+
logger: zap.L().Named("api.v2"),
43+
cfg: cfg,
44+
limiter: rate.NewLimiter(rate.Every(frameTime), compileRequestsPerFrame),
3345
}
3446
}
3547

@@ -38,7 +50,7 @@ func (h *APIv2Handler) HandleGetSnippet(w http.ResponseWriter, r *http.Request)
3850
vars := mux.Vars(r)
3951
snippetID := vars["id"]
4052

41-
snippet, err := h.client.GetSnippet(r.Context(), snippetID)
53+
snippet, err := h.cfg.Client.GetSnippet(r.Context(), snippetID)
4254
if err != nil {
4355
if errors.Is(err, goplay.ErrSnippetNotFound) {
4456
return Errorf(http.StatusNotFound, "snippet %q not found", snippetID)
@@ -76,7 +88,7 @@ func (h *APIv2Handler) HandleShare(w http.ResponseWriter, r *http.Request) error
7688
return err
7789
}
7890

79-
snippetID, err := h.client.Share(ctx, payload.Reader())
91+
snippetID, err := h.cfg.Client.Share(ctx, payload.Reader())
8092
if err != nil {
8193
return err
8294
}
@@ -108,7 +120,7 @@ func (h *APIv2Handler) HandleFormat(w http.ResponseWriter, r *http.Request) erro
108120
return err
109121
}
110122

111-
rsp, err := h.client.GoImports(ctx, payload.Bytes(), backend)
123+
rsp, err := h.cfg.Client.GoImports(ctx, payload.Bytes(), backend)
112124
if err != nil {
113125
if isContentLengthError(err) {
114126
return ErrSnippetTooLarge
@@ -148,7 +160,7 @@ func (h *APIv2Handler) HandleRun(w http.ResponseWriter, r *http.Request) error {
148160
return NewBadRequestError(err)
149161
}
150162

151-
res, err := h.client.Evaluate(ctx, goplay.CompileRequest{
163+
res, err := h.cfg.Client.Evaluate(ctx, goplay.CompileRequest{
152164
Version: goplay.DefaultVersion,
153165
WithVet: params.Vet,
154166
Body: snippet,
@@ -172,7 +184,7 @@ func (h *APIv2Handler) HandleRun(w http.ResponseWriter, r *http.Request) error {
172184
// HandleCompile handles WebAssembly compile requests.
173185
func (h *APIv2Handler) HandleCompile(w http.ResponseWriter, r *http.Request) error {
174186
// Limit for request timeout
175-
ctx, cancel := context.WithDeadline(r.Context(), time.Now().Add(maxBuildTimeDuration))
187+
ctx, cancel := h.cfg.buildContext(r.Context())
176188
defer cancel()
177189

178190
// Wait for our queue in line for compilation
@@ -185,11 +197,12 @@ func (h *APIv2Handler) HandleCompile(w http.ResponseWriter, r *http.Request) err
185197
return err
186198
}
187199

188-
result, err := h.compiler.Build(ctx, files)
189-
if builder.IsBuildError(err) {
190-
return NewHTTPError(http.StatusBadRequest, err)
191-
}
200+
result, err := h.cfg.Builder.Build(ctx, files)
192201
if err != nil {
202+
if builder.IsBuildError(err) || errors.Is(err, context.Canceled) {
203+
return NewHTTPError(http.StatusBadRequest, err)
204+
}
205+
193206
return err
194207
}
195208

0 commit comments

Comments
 (0)