fix(web): bound concurrent highlighting and revalidate raw files at every request
- commit
- c83fe6794d7d3fe07705bb5d9b7d55bafdad6f6c
- author
- brice · · 2026-10-10 14:14 UTC
- parent
- 56a2ec9
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +22 -12
M cmd/ermite/web_files_test.go +2 -5
| @@ -212,11 +212,8 @@ func TestWebRaw(t *testing.T) { | |||
| 212 | 212 | assert.Equal(t, "default-src 'none'; sandbox", hdr.Get("Content-Security-Policy")) | |
| 213 | 213 | assert.Equal(t, "DENY", hdr.Get("X-Frame-Options")) | |
| 214 | 214 | assert.Equal(t, strconv.Itoa(len(body)), hdr.Get("Content-Length")) | |
| 215 | - | if strings.Contains(tt.path, sha) { | |
| 216 | - | assert.Equal(t, "public, max-age=31536000, immutable", hdr.Get("Cache-Control")) | |
| 217 | - | } else { | |
| 218 | - | assert.Equal(t, "no-cache", hdr.Get("Cache-Control"), "a branch or a tag moves") | |
| 219 | - | } | |
| 215 | + | // Even at a full commit id: the repository can turn private. | |
| 216 | + | assert.Equal(t, "no-cache", hdr.Get("Cache-Control")) | |
| 220 | 217 | }) | |
| 221 | 218 | } | |
| 222 | 219 | ||
| @@ -212,11 +212,8 @@ func TestWebRaw(t *testing.T) { | |||||
| 212 | assert.Equal(t, "default-src 'none'; sandbox", hdr.Get("Content-Security-Policy")) | 212 | assert.Equal(t, "default-src 'none'; sandbox", hdr.Get("Content-Security-Policy")) | ||
| 213 | assert.Equal(t, "DENY", hdr.Get("X-Frame-Options")) | 213 | assert.Equal(t, "DENY", hdr.Get("X-Frame-Options")) | ||
| 214 | assert.Equal(t, strconv.Itoa(len(body)), hdr.Get("Content-Length")) | 214 | assert.Equal(t, strconv.Itoa(len(body)), hdr.Get("Content-Length")) | ||
| 215 | - | if strings.Contains(tt.path, sha) { | 215 | + | // Even at a full commit id: the repository can turn private. |
| 216 | - | assert.Equal(t, "public, max-age=31536000, immutable", hdr.Get("Cache-Control")) | 216 | + | assert.Equal(t, "no-cache", hdr.Get("Cache-Control")) |
| 217 | - | } else { | |||
| 218 | - | assert.Equal(t, "no-cache", hdr.Get("Cache-Control"), "a branch or a tag moves") | |||
| 219 | - | } | |||
| 220 | }) | 217 | }) | ||
| 221 | } | 218 | } | ||
| 222 | 219 | ||||
M internal/adapters/web/highlight.go +16 -1
| @@ -8,6 +8,7 @@ import ( | |||
| 8 | 8 | "html" | |
| 9 | 9 | "html/template" | |
| 10 | 10 | "path" | |
| 11 | + | "runtime" | |
| 11 | 12 | "strconv" | |
| 12 | 13 | "strings" | |
| 13 | 14 | "time" | |
| @@ -78,12 +79,26 @@ func highlight(name, src string) codeView { | |||
| 78 | 79 | // input can be slow, and the page then shows the file as plain text. | |
| 79 | 80 | const highlightBudget = 10 * time.Second | |
| 80 | 81 | ||
| 82 | + | // lexSlots bounds the lexers running at once, those left to finish after | |
| 83 | + | // their budget included: hostile files requested over and over cannot pile | |
| 84 | + | // up CPU-bound goroutines. | |
| 85 | + | var lexSlots = make(chan struct{}, runtime.GOMAXPROCS(0)) | |
| 86 | + | ||
| 81 | 87 | // highlightCtx is highlight with a time budget and the cancellation of ctx. | |
| 82 | 88 | // A lexer cannot be interrupted, so one that overruns is left to finish on | |
| 83 | 89 | // its own (its regexps have their own timeout) and its result is dropped. | |
| 90 | + | // When every lexing slot is taken the file is shown as plain text. | |
| 84 | 91 | func highlightCtx(ctx context.Context, name, src string) codeView { | |
| 92 | + | select { | |
| 93 | + | case lexSlots <- struct{}{}: | |
| 94 | + | default: | |
| 95 | + | return highlightPlain(src) | |
| 96 | + | } | |
| 85 | 97 | done := make(chan codeView, 1) | |
| 86 | - | go func() { done <- highlight(name, src) }() | |
| 98 | + | go func() { | |
| 99 | + | defer func() { <-lexSlots }() | |
| 100 | + | done <- highlight(name, src) | |
| 101 | + | }() | |
| 87 | 102 | select { | |
| 88 | 103 | case v := <-done: | |
| 89 | 104 | return v | |
| @@ -8,6 +8,7 @@ import ( | |||||
| 8 | "html" | 8 | "html" | ||
| 9 | "html/template" | 9 | "html/template" | ||
| 10 | "path" | 10 | "path" | ||
| 11 | + | "runtime" | |||
| 11 | "strconv" | 12 | "strconv" | ||
| 12 | "strings" | 13 | "strings" | ||
| 13 | "time" | 14 | "time" | ||
| @@ -78,12 +79,26 @@ func highlight(name, src string) codeView { | |||||
| 78 | // input can be slow, and the page then shows the file as plain text. | 79 | // input can be slow, and the page then shows the file as plain text. | ||
| 79 | const highlightBudget = 10 * time.Second | 80 | const highlightBudget = 10 * time.Second | ||
| 80 | 81 | ||||
| 82 | + | // lexSlots bounds the lexers running at once, those left to finish after | |||
| 83 | + | // their budget included: hostile files requested over and over cannot pile | |||
| 84 | + | // up CPU-bound goroutines. | |||
| 85 | + | var lexSlots = make(chan struct{}, runtime.GOMAXPROCS(0)) | |||
| 86 | + | ||||
| 81 | // highlightCtx is highlight with a time budget and the cancellation of ctx. | 87 | // highlightCtx is highlight with a time budget and the cancellation of ctx. | ||
| 82 | // A lexer cannot be interrupted, so one that overruns is left to finish on | 88 | // A lexer cannot be interrupted, so one that overruns is left to finish on | ||
| 83 | // its own (its regexps have their own timeout) and its result is dropped. | 89 | // its own (its regexps have their own timeout) and its result is dropped. | ||
| 90 | + | // When every lexing slot is taken the file is shown as plain text. | |||
| 84 | func highlightCtx(ctx context.Context, name, src string) codeView { | 91 | func highlightCtx(ctx context.Context, name, src string) codeView { | ||
| 92 | + | select { | |||
| 93 | + | case lexSlots <- struct{}{}: | |||
| 94 | + | default: | |||
| 95 | + | return highlightPlain(src) | |||
| 96 | + | } | |||
| 85 | done := make(chan codeView, 1) | 97 | done := make(chan codeView, 1) | ||
| 86 | - | go func() { done <- highlight(name, src) }() | 98 | + | go func() { |
| 99 | + | defer func() { <-lexSlots }() | |||
| 100 | + | done <- highlight(name, src) | |||
| 101 | + | }() | |||
| 87 | select { | 102 | select { | ||
| 88 | case v := <-done: | 103 | case v := <-done: | ||
| 89 | return v | 104 | return v | ||
M internal/adapters/web/raw.go +4 -6
| @@ -56,12 +56,10 @@ func (s *Server) raw(w http.ResponseWriter, r *http.Request) { | |||
| 56 | 56 | if etag := b.info.GetId(); etag != "" { | |
| 57 | 57 | h.Set("ETag", `"`+etag+`"`) | |
| 58 | 58 | } | |
| 59 | - | // A commit named in full never changes; a branch or a tag moves. | |
| 60 | - | if t.detached && len(t.rev) == 40 { | |
| 61 | - | h.Set("Cache-Control", "public, max-age=31536000, immutable") | |
| 62 | - | } else { | |
| 63 | - | h.Set("Cache-Control", "no-cache") | |
| 64 | - | } | |
| 59 | + | // Revalidated every time, even at a full commit id: a repository can turn | |
| 60 | + | // private, and a shared cache must not keep serving its files (AC2). The | |
| 61 | + | // ETag keeps the revalidation cheap. | |
| 62 | + | h.Set("Cache-Control", "no-cache") | |
| 65 | 63 | if match := r.Header.Get("If-None-Match"); match != "" && h.Get("ETag") != "" && match == h.Get("ETag") { | |
| 66 | 64 | w.WriteHeader(http.StatusNotModified) | |
| 67 | 65 | return | |
| @@ -56,12 +56,10 @@ func (s *Server) raw(w http.ResponseWriter, r *http.Request) { | |||||
| 56 | if etag := b.info.GetId(); etag != "" { | 56 | if etag := b.info.GetId(); etag != "" { | ||
| 57 | h.Set("ETag", `"`+etag+`"`) | 57 | h.Set("ETag", `"`+etag+`"`) | ||
| 58 | } | 58 | } | ||
| 59 | - | // A commit named in full never changes; a branch or a tag moves. | 59 | + | // Revalidated every time, even at a full commit id: a repository can turn |
| 60 | - | if t.detached && len(t.rev) == 40 { | 60 | + | // private, and a shared cache must not keep serving its files (AC2). The |
| 61 | - | h.Set("Cache-Control", "public, max-age=31536000, immutable") | 61 | + | // ETag keeps the revalidation cheap. |
| 62 | - | } else { | 62 | + | h.Set("Cache-Control", "no-cache") |
| 63 | - | h.Set("Cache-Control", "no-cache") | |||
| 64 | - | } | |||
| 65 | if match := r.Header.Get("If-None-Match"); match != "" && h.Get("ETag") != "" && match == h.Get("ETag") { | 63 | if match := r.Header.Get("If-None-Match"); match != "" && h.Get("ETag") != "" && match == h.Get("ETag") { | ||
| 66 | w.WriteHeader(http.StatusNotModified) | 64 | w.WriteHeader(http.StatusNotModified) | ||
| 67 | return | 65 | return | ||