Skip to content

Commit 577d049

Browse files
committed
dashboard/app: pre-gzip all responses
1 parent 8d34fd8 commit 577d049

File tree

3 files changed

+171
-34
lines changed

3 files changed

+171
-34
lines changed

dashboard/app/api.go

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -111,16 +111,16 @@ func handleJSON(fn JSONHandler) http.Handler {
111111
http.Error(w, err.Error(), status)
112112
return
113113
}
114-
w.Header().Set("Content-Type", "application/json")
115-
wJS := w.(io.Writer)
116-
if strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
117-
w.Header().Set("Content-Encoding", "gzip")
118-
gw := gzip.NewWriter(w)
119-
defer gw.Close()
120-
wJS = gw
121-
}
114+
115+
wJS := newGzipResponseWriterCloser(w)
116+
defer wJS.Close()
122117
if err := json.NewEncoder(wJS).Encode(reply); err != nil {
123118
log.Errorf(c, "failed to encode reply: %v", err)
119+
return
120+
}
121+
w.Header().Set("Content-Type", "application/json")
122+
if err := wJS.writeResult(r); err != nil {
123+
log.Errorf(c, "wJS.writeResult: %s", err.Error())
124124
}
125125
})
126126
}

dashboard/app/handler.go

Lines changed: 97 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@ package main
55

66
import (
77
"bytes"
8+
"compress/gzip"
89
"context"
910
"encoding/base64"
1011
"encoding/json"
1112
"errors"
1213
"fmt"
14+
"io"
1315
"net/http"
1416
"sort"
1517
"strings"
@@ -43,37 +45,45 @@ func handleContext(fn contextHandler) http.Handler {
4345
}
4446
defer backpressureRobots(c, r)()
4547
}
46-
if err := fn(c, w, r); err != nil {
47-
hdr := commonHeaderRaw(c, r)
48-
data := &struct {
49-
Header *uiHeader
50-
Error string
51-
TraceID string
52-
}{
53-
Header: hdr,
54-
Error: err.Error(),
55-
TraceID: strings.Join(r.Header["X-Cloud-Trace-Context"], " "),
56-
}
57-
if err == ErrAccess {
58-
if hdr.LoginLink != "" {
59-
http.Redirect(w, r, hdr.LoginLink, http.StatusTemporaryRedirect)
60-
return
61-
}
62-
http.Error(w, "403 Forbidden", http.StatusForbidden)
48+
49+
w.Header().Set("Content-Type", "text/html; charset=utf-8")
50+
gzw := newGzipResponseWriterCloser(w)
51+
defer gzw.Close()
52+
err := fn(c, gzw, r)
53+
if err == nil {
54+
if err = gzw.writeResult(r); err == nil {
6355
return
6456
}
65-
var redir *ErrRedirect
66-
if errors.As(err, &redir) {
67-
http.Redirect(w, r, redir.Error(), http.StatusFound)
57+
}
58+
hdr := commonHeaderRaw(c, r)
59+
data := &struct {
60+
Header *uiHeader
61+
Error string
62+
TraceID string
63+
}{
64+
Header: hdr,
65+
Error: err.Error(),
66+
TraceID: strings.Join(r.Header["X-Cloud-Trace-Context"], " "),
67+
}
68+
if err == ErrAccess {
69+
if hdr.LoginLink != "" {
70+
http.Redirect(w, r, hdr.LoginLink, http.StatusTemporaryRedirect)
6871
return
6972
}
73+
http.Error(w, "403 Forbidden", http.StatusForbidden)
74+
return
75+
}
76+
var redir *ErrRedirect
77+
if errors.As(err, &redir) {
78+
http.Redirect(w, r, redir.Error(), http.StatusFound)
79+
return
80+
}
7081

71-
status := logErrorPrepareStatus(c, err)
72-
w.WriteHeader(status)
73-
if err1 := templates.ExecuteTemplate(w, "error.html", data); err1 != nil {
74-
combinedError := fmt.Sprintf("got err \"%v\" processing ExecuteTemplate() for err \"%v\"", err1, err)
75-
http.Error(w, combinedError, http.StatusInternalServerError)
76-
}
82+
status := logErrorPrepareStatus(c, err)
83+
w.WriteHeader(status)
84+
if err1 := templates.ExecuteTemplate(w, "error.html", data); err1 != nil {
85+
combinedError := fmt.Sprintf("got err \"%v\" processing ExecuteTemplate() for err \"%v\"", err1, err)
86+
http.Error(w, combinedError, http.StatusInternalServerError)
7787
}
7888
})
7989
}
@@ -339,3 +349,64 @@ func encodeCookie(w http.ResponseWriter, cd *cookieData) {
339349
}
340350

341351
var templates = html.CreateGlob("*.html")
352+
353+
// gzipResponseWriterCloser accumulates the gzipped result.
354+
// In case of error during the handler processing, we'll drop this gzipped data.
355+
// It allows to call http.Error in the middle of the response generation.
356+
//
357+
// For 200 Ok responses we return the compressed data or decompress it depending on the client Accept-Encoding header.
358+
type gzipResponseWriterCloser struct {
359+
w *gzip.Writer
360+
plainResponseSize int
361+
buf *bytes.Buffer
362+
rw http.ResponseWriter
363+
}
364+
365+
func (g *gzipResponseWriterCloser) Write(p []byte) (n int, err error) {
366+
g.plainResponseSize += len(p)
367+
return g.w.Write(p)
368+
}
369+
370+
func (g *gzipResponseWriterCloser) Close() {
371+
if g.w != nil {
372+
g.w.Close()
373+
}
374+
}
375+
376+
func (g *gzipResponseWriterCloser) Header() http.Header {
377+
return g.rw.Header()
378+
}
379+
380+
func (g *gzipResponseWriterCloser) WriteHeader(statusCode int) {
381+
g.rw.WriteHeader(statusCode)
382+
}
383+
384+
func (g *gzipResponseWriterCloser) writeResult(r *http.Request) error {
385+
g.w.Close()
386+
g.w = nil
387+
clientSupportsGzip := strings.Contains(r.Header.Get("Accept-Encoding"), "gzip")
388+
if clientSupportsGzip {
389+
g.rw.Header().Set("Content-Encoding", "gzip")
390+
_, err := g.rw.Write(g.buf.Bytes())
391+
return err
392+
}
393+
if g.plainResponseSize > 31<<20 { // 32MB is the AppEngine hard limit for the response size.
394+
return fmt.Errorf("len(response) > 31M, try to request gzipped: %w", ErrClientBadRequest)
395+
}
396+
gzr, err := gzip.NewReader(g.buf)
397+
if err != nil {
398+
return fmt.Errorf("gzip.NewReader: %w", err)
399+
}
400+
defer gzr.Close()
401+
_, err = io.Copy(g.rw, gzr)
402+
return err
403+
}
404+
405+
func newGzipResponseWriterCloser(w http.ResponseWriter) *gzipResponseWriterCloser {
406+
buf := &bytes.Buffer{}
407+
return &gzipResponseWriterCloser{
408+
w: gzip.NewWriter(buf),
409+
buf: buf,
410+
rw: w,
411+
}
412+
}

dashboard/app/handler_test.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
// Copyright 2025 syzkaller project authors. All rights reserved.
2+
// Use of this source code is governed by Apache 2 LICENSE that can be found in the LICENSE file.
3+
4+
package main
5+
6+
import (
7+
"compress/gzip"
8+
"net/http"
9+
"net/http/httptest"
10+
"testing"
11+
12+
"github.com/stretchr/testify/assert"
13+
)
14+
15+
func TestGzipResponseWriterCloser_no_compression(t *testing.T) {
16+
res := httptest.NewRecorder()
17+
gz := newGzipResponseWriterCloser(res)
18+
gz.Write([]byte("test"))
19+
20+
err := gz.writeResult(httpRequestWithAcceptedEncoding(""))
21+
assert.NoError(t, err)
22+
assert.Equal(t, "test", res.Body.String())
23+
assert.Equal(t, "", res.Header().Get("Content-Encoding"))
24+
}
25+
26+
func TestGzipResponseWriterCloser_with_compression(t *testing.T) {
27+
res := httptest.NewRecorder()
28+
gz := newGzipResponseWriterCloser(res)
29+
gz.Write([]byte("test"))
30+
31+
err := gz.writeResult(httpRequestWithAcceptedEncoding("gzip"))
32+
assert.NoError(t, err)
33+
assert.Equal(t, "gzip", res.Header().Get("Content-Encoding"))
34+
35+
gr, _ := gzip.NewReader(res.Body)
36+
gotBytes := make([]byte, 28)
37+
n, _ := gr.Read(gotBytes)
38+
gotBytes = gotBytes[:n]
39+
assert.Equal(t, "test", string(gotBytes))
40+
}
41+
42+
func TestGzipResponseWriterCloser_headers(t *testing.T) {
43+
res := httptest.NewRecorder()
44+
gz := newGzipResponseWriterCloser(res)
45+
46+
gz.Header().Add("key", "val1")
47+
gz.Header().Add("key", "val2")
48+
err := gz.writeResult(httpRequestWithAcceptedEncoding(""))
49+
assert.NoError(t, err)
50+
assert.Equal(t, http.Header{
51+
"Key": []string{"val1", "val2"},
52+
}, res.Header())
53+
}
54+
55+
func TestGzipResponseWriterCloser_status(t *testing.T) {
56+
res := httptest.NewRecorder()
57+
gz := newGzipResponseWriterCloser(res)
58+
59+
gz.WriteHeader(333)
60+
gz.writeResult(httpRequestWithAcceptedEncoding("gzip"))
61+
assert.Equal(t, 333, res.Code)
62+
}
63+
64+
func httpRequestWithAcceptedEncoding(encoding string) *http.Request {
65+
return &http.Request{Header: http.Header{"Accept-Encoding": []string{encoding}}}
66+
}

0 commit comments

Comments
 (0)