carlos / platform Public

console: ship door — part-count cap, immutable 409, concurrency + time bounds

Review round on the multipart ship endpoint (apicli.go) found five gaps
the byte cap alone didn't close:

- apiShipMaxBytes bounds bytes, not part COUNT. r.MultipartReader() has
  no stdlib cap on part count (only ReadForm's 1000-part cap, which
  streaming opts out of), so a body of tiny validly-named parts could
  fit millions of them, blowing up the console's heap and then issuing
  one sequential store write per part until the ship context died,
  orphaning manifest-less bucket objects (Ship writes the manifest
  last). Fixed with a hard cap — 1 part for static, 8 for binary —
  enforced before io.ReadAll ever runs on the offending part.

- Every release.Ship error answered 502 "try again", including its
  permanent refusal to overwrite an existing version with different
  bytes — a case a retry loop can never fix. That refusal has no
  sentinel or typed error (release.go), so it's matched by its literal
  wording and answered 409 instead; genuine store failures stay 502.
  Along the way, a GetManifest pre-check makes a genuinely IDENTICAL
  re-ship succeed (200, no-op) rather than tripping release.Ship's own
  manifest-level conflict — both this handler and the direct `carlos
  ship` path stamp a fresh BuiltAt on every call, so comparing manifests
  byte-for-byte (as Ship's putImmutable does) would treat identical
  content as a conflict for a reason no caller could avoid; comparing
  artifacts (content) instead of the whole manifest (content + metadata)
  is what makes the true idempotent case actually work.

- The body read was unbounded in time: no server ReadTimeout, and the
  2-minute context only starts after the body is already read. A
  trickled upload could hold a goroutine and buffer open for hours,
  since the rate limiter counts requests, not concurrency. Fixed with a
  10-minute read deadline set via http.NewResponseController before
  touching the body, plus a small (cap 2) in-flight semaphore on Server
  — a third concurrent ship is refused with 429 rather than queuing.

- Duplicate part names silently overwrote each other, charging the byte
  and part-count budgets twice for content only the last write kept.
  Refused with 400 instead.

New tests: too-many-parts, duplicate-part, an expanded traversal-defense
table (asserting the store is untouched after each refusal, not just
the status code), the immutable-conflict/idempotent-reship pair, and a
concurrency-limit test using io.Pipe-backed requests plus a
shipStarted test hook (same shape as cliNotified) to synchronize on the
semaphore actually being held rather than racing it.
Paul Campbell f0b5fa22f286e42e14d68123a58f72affa850791 parent 8a876df
3 files changed, +414 −3
  • internal/console/apicli.go +171 −3
  • internal/console/apicli_test.go +222 −0
  • internal/console/server.go +21 −0
diff --git a/internal/console/apicli.go b/internal/console/apicli.go
index 948e66760..9ab77f6da 100644
--- a/internal/console/apicli.go
+++ b/internal/console/apicli.go
@@ -343,6 +343,24 @@ func (s *Server) handleAPIRollback(w http.ResponseWriter, r *http.Request) {
// covers today's binaries and a static site tarball in one request.
var apiShipMaxBytes int64 = 256 << 20
+// apiShipMaxPartsStatic and apiShipMaxPartsBinary bound the NUMBER of file
+// parts a ship request may carry: one for a static release (the site.tar.gz
+// name alone), eight for a binary release (comfortably above today's
+// platform count). r.MultipartReader() has no stdlib cap on part count at all — that
+// cap exists only on mime/multipart's ReadForm (1000 parts), which
+// streaming deliberately opts out of. Without a check of our own, a body
+// full of tiny, validly-named parts fits millions of them inside
+// apiShipMaxBytes: each one a map entry on the console's heap, and — had it
+// ever gotten that far — one sequential putImmutable call per part inside
+// release.Ship until the ship context timed out, orphaning however many
+// artifact objects had already landed (Ship writes the manifest LAST by
+// design, so a killed run leaves bytes with no manifest naming them). This
+// check IS the part-count cap; nothing upstream provides one.
+const (
+ apiShipMaxPartsStatic = 1
+ apiShipMaxPartsBinary = 8
+)
+
// targetRe validates a binary artifact's part NAME before it is used to
// build a bucket key: release.ArtifactKey joins it straight into the object
// path, so this check is path-traversal defense, not tidiness — a part
@@ -362,6 +380,53 @@ func shipMaxBytesRefused(w http.ResponseWriter, err error) bool {
return false
}
+// shipImmutableConflict reports whether err is release.Ship's refusal to
+// overwrite an existing version's object with DIFFERENT bytes — a
+// permanent conflict a retry can never fix, unlike every other error Ship
+// returns (a store/IO failure, which a retry might actually resolve).
+//
+// release.Ship/putImmutable (internal/release/release.go) expose no
+// sentinel or typed error for this specific case — the only signal
+// available is the literal wording putImmutable formats ("... already
+// exists with different content — refusing to overwrite an immutable
+// release object"), so this is a STRING match against release.go's current
+// wording, not a durable error-type check (errors.Is/As have nothing to
+// grab onto here). If that wording ever changes in release.go, this
+// silently stops firing and the conflict falls back to the 502 below —
+// worth revisiting if release.go ever grows a proper sentinel for it.
+//
+// validateManifest's errors also return from Ship along this same
+// unwrapped path, but none of its messages ("invalid app...", "invalid
+// kind...", "manifest has no artifacts", etc) contain this wording, so they
+// can never match here. They are unreachable in this branch for an
+// unrelated reason: every field validateManifest checks (app, version,
+// kind, artifact file names, sha256, size) was already validated by
+// handleAPIShip itself, using the same rules, before Ship was ever called.
+func shipImmutableConflict(err error) bool {
+ return strings.Contains(err.Error(), "already exists with different content")
+}
+
+// shipArtifactsMatch reports whether two artifact maps describe the exact
+// same shipped CONTENT — same file set, same bytes (by sha256+size) for
+// each. BuiltAt/BuiltBy are deliberately excluded: they are metadata, not
+// content, and both handleAPIShip and the direct `carlos ship` CLI path
+// stamp a fresh BuiltAt into every manifest they build, so comparing
+// manifests wholesale (as release.Ship's own putImmutable does,
+// byte-for-byte) would call a genuinely identical re-ship a conflict for a
+// reason no caller could ever avoid.
+func shipArtifactsMatch(a, b map[string]release.Artifact) bool {
+ if len(a) != len(b) {
+ return false
+ }
+ for k, av := range a {
+ bv, ok := b[k]
+ if !ok || av.File != bv.File || av.SHA256 != bv.SHA256 || av.Size != bv.Size {
+ return false
+ }
+ }
+ return true
+}
+
// handleAPIShip answers `carlos ship`: a multipart upload of one release's
// artifacts. Hashes and sizes are computed HERE, from the bytes that
// actually arrived on the wire, and handed to release.Ship as
@@ -394,6 +459,40 @@ func (s *Server) handleAPIShip(w http.ResponseWriter, r *http.Request) {
jsonError(w, http.StatusTooManyRequests, "too many uploads — wait a while and try again")
return
}
+ // The rate limiter bounds requests over TIME; it says nothing about how
+ // many run AT ONCE. A ship holds a goroutine and a substantial buffer
+ // open for as long as the client trickles bytes in, so a handful of
+ // slow uploads in parallel is a resource-exhaustion path the counter
+ // above does nothing to close. Non-blocking: a full semaphore means
+ // "busy right now", answered immediately, rather than a request that
+ // queues indefinitely behind someone else's slow upload.
+ select {
+ case s.shipSem <- struct{}{}:
+ defer func() { <-s.shipSem }()
+ default:
+ jsonError(w, http.StatusTooManyRequests, "another ship is in progress — try again shortly")
+ return
+ }
+ if s.shipStarted != nil {
+ s.shipStarted <- struct{}{}
+ }
+
+ // Bounds the read side in TIME, not just in bytes: with no deadline, a
+ // client sending one byte a second could hold this goroutine (and
+ // whatever it has already buffered) open for hours — the semaphore
+ // above only bounds how many can do that at once, not for how long.
+ // http.ErrNotSupported is tolerated because some transports (test
+ // servers among them, in principle) don't implement the underlying
+ // deadline setter; the semaphore is still there as a backstop on those.
+ rc := http.NewResponseController(w)
+ if err := rc.SetReadDeadline(time.Now().Add(10 * time.Minute)); err != nil && !errors.Is(err, http.ErrNotSupported) {
+ log.Printf("console: api ship set read deadline: %v", err)
+ }
+ defer func() {
+ if err := rc.SetReadDeadline(time.Time{}); err != nil && !errors.Is(err, http.ErrNotSupported) {
+ log.Printf("console: api ship clear read deadline: %v", err)
+ }
+ }()
r.Body = http.MaxBytesReader(w, r.Body, apiShipMaxBytes)
mr, err := r.MultipartReader()
@@ -431,6 +530,10 @@ func (s *Server) handleAPIShip(w http.ResponseWriter, r *http.Request) {
jsonError(w, http.StatusBadRequest, `invalid kind (want "binary" or "static")`)
return
}
+ maxParts := apiShipMaxPartsBinary
+ if meta.Kind == "static" {
+ maxParts = apiShipMaxPartsStatic
+ }
files := make(map[string][]byte)
artifacts := make(map[string]release.Artifact)
@@ -446,6 +549,16 @@ func (s *Server) handleAPIShip(w http.ResponseWriter, r *http.Request) {
jsonError(w, http.StatusBadRequest, "malformed multipart body")
return
}
+ // Checked before anything else touches this part — including its
+ // name, and certainly before io.ReadAll — because the cost this
+ // guards against is per-PART, not per-byte: a body of nothing but
+ // tiny, validly-named parts must be refused on the (maxParts+1)th
+ // one, not after buffering it.
+ if len(files) >= maxParts {
+ jsonError(w, http.StatusBadRequest,
+ fmt.Sprintf("too many file parts (max %d for a %s release)", maxParts, meta.Kind))
+ return
+ }
name := part.FormName()
if meta.Kind == "static" {
if name != "site.tar.gz" {
@@ -456,6 +569,16 @@ func (s *Server) handleAPIShip(w http.ResponseWriter, r *http.Request) {
jsonError(w, http.StatusBadRequest, "invalid artifact name "+name)
return
}
+ // A second part claiming a name already seen is refused rather than
+ // silently overwriting the first: both would otherwise be charged
+ // against apiShipMaxBytes and apiShipMaxPartsBinary/Static, but only
+ // the last one's bytes would ever reach the manifest — a caller
+ // deserves an error, not a release that quietly ships less than it
+ // paid to upload.
+ if _, dup := files[name]; dup {
+ jsonError(w, http.StatusBadRequest, "duplicate artifact part "+name)
+ return
+ }
b, err := io.ReadAll(part)
if err != nil {
if shipMaxBytesRefused(w, err) {
@@ -491,11 +614,56 @@ func (s *Server) handleAPIShip(w http.ResponseWriter, r *http.Request) {
// short strings.
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
defer cancel()
+
+ // A version's objects are immutable once shipped, but BOTH callers that
+ // build a manifest like this one (this handler and the direct `carlos
+ // ship` CLI path) stamp BuiltAt with a fresh time.Now() on every
+ // invocation. Comparing the freshly-marshaled manifest byte-for-byte
+ // against whatever release.Ship's own putImmutable finds already
+ // stored — which is exactly what it does — would make a genuinely
+ // identical re-ship register as a "different content" conflict for a
+ // reason no caller could ever avoid: its OWN timestamp. Comparing
+ // ARTIFACTS here first — content, not metadata — is what makes
+ // re-shipping the exact same bytes twice actually succeed instead of
+ // permanently wedging on its own BuiltAt.
+ if existing, gerr := release.GetManifest(ctx, s.cfg.Releases, acct.Sqid, app, ver); gerr == nil {
+ if !shipArtifactsMatch(existing.Artifacts, artifacts) {
+ jsonError(w, http.StatusConflict,
+ "version "+ver+" was already shipped with different content — versions are immutable; ship under a new version")
+ return
+ }
+ // Identical content already shipped: answer success without
+ // writing anything — calling release.Ship again would only
+ // rediscover this the slow way, and would in fact trip its OWN
+ // manifest-level putImmutable conflict (see above), since BuiltAt
+ // drifts every call even when the artifacts don't. 200, not 201:
+ // nothing was created by this request.
+ out := make(map[string]any, len(existing.Artifacts))
+ for name, a := range existing.Artifacts {
+ out[name] = map[string]any{"sha256": a.SHA256, "size": a.Size}
+ }
+ writeJSON(w, http.StatusOK, map[string]any{
+ "result": "shipped", "version": ver, "artifacts": out,
+ })
+ return
+ }
+
if err := release.Ship(ctx, s.cfg.Releases, acct.Sqid, m, files); err != nil {
log.Printf("console: api ship %s %s: %v", app, ver, err)
- // The store failed, not the caller: a 502, matching the other doors'
- // convention that a bucket-side error is never described to the
- // caller in bucket-side terms.
+ if shipImmutableConflict(err) {
+ // A permanent conflict, not a transient one: reachable when this
+ // raced against another ship of the same version (the
+ // GetManifest check above is not atomic with the Ship call
+ // below), not through the common path above. Retrying this
+ // exact request will never succeed, so it gets its own status
+ // rather than the retry-flavored 502 below.
+ jsonError(w, http.StatusConflict,
+ "version "+ver+" was already shipped with different content — versions are immutable; ship under a new version")
+ return
+ }
+ // Anything else is the store failing, not the caller: a 502,
+ // matching the other doors' convention that a bucket-side error is
+ // never described to the caller in bucket-side terms.
jsonError(w, http.StatusBadGateway, "could not store the release — try again")
return
}
diff --git a/internal/console/apicli_test.go b/internal/console/apicli_test.go
index bb758ce1e..60afc5b60 100644
--- a/internal/console/apicli_test.go
+++ b/internal/console/apicli_test.go
@@ -15,10 +15,12 @@ import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
+ "fmt"
"io"
"mime/multipart"
"net/http"
"strings"
+ "sync"
"testing"
"time"
@@ -910,3 +912,223 @@ func TestAPIShipMemberGate(t *testing.T) {
t.Fatalf("ship as non-member body = %q, want the stdlib http.NotFound output", body)
}
}
+
+// TestAPIShipTooManyParts: apiShipMaxBytes bounds bytes, not COUNT — a body
+// of nothing but tiny, validly-named parts would otherwise fit millions of
+// them in one small request. The ninth binary part (max is
+// apiShipMaxPartsBinary=8) is refused before it is ever read, and nothing
+// from this request reaches release.Ship: no manifest for this version
+// exists afterward.
+func TestAPIShipTooManyParts(t *testing.T) {
+ fx := newConsoleFixture(t)
+ now := time.Now()
+ if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ token := cliToken(t, fx, "paul@keymail.dev", "code-ship-toomanyparts")
+
+ parts := []shipPart{metaPart(t, "abc1234", "binary")}
+ for i := 0; i < 9; i++ {
+ parts = append(parts, shipPart{name: fmt.Sprintf("part-%d", i), body: []byte("x")})
+ }
+ status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", token, parts)
+ if status != http.StatusBadRequest {
+ t.Fatalf("9-part ship = %d %v, want 400", status, out)
+ }
+ if _, err := release.GetManifest(context.Background(), fx.rel, release.OpsAccount, "titogo", "abc1234"); err == nil {
+ t.Fatalf("manifest exists after a refused too-many-parts ship, want none — nothing should reach release.Ship")
+ }
+}
+
+// TestAPIShipDuplicatePart: two parts claiming the same artifact name are
+// refused rather than letting the second silently win — both would
+// otherwise be charged against the byte and part-count budgets while only
+// the last one's bytes ever reached the manifest.
+func TestAPIShipDuplicatePart(t *testing.T) {
+ fx := newConsoleFixture(t)
+ now := time.Now()
+ if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ token := cliToken(t, fx, "paul@keymail.dev", "code-ship-duplicate")
+
+ status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", token,
+ []shipPart{
+ metaPart(t, "abc1234", "binary"),
+ {name: "linux-arm64", body: randomBytes(t, 32)},
+ {name: "linux-arm64", body: randomBytes(t, 32)},
+ })
+ if status != http.StatusBadRequest {
+ t.Fatalf("duplicate-named ship = %d %v, want 400", status, out)
+ }
+ if _, err := release.GetManifest(context.Background(), fx.rel, release.OpsAccount, "titogo", "abc1234"); err == nil {
+ t.Fatalf("manifest exists after a refused duplicate-part ship, want none")
+ }
+}
+
+// TestAPIShipTraversalRefusals: every part name that reads like an attempt
+// to escape the artifact's bucket-key segment is refused with 400, and — the
+// point of this test, not just its status code — none of them leave any
+// trace in the release store. Part names become bucket key segments via the
+// manifest (release.ArtifactKey joins them straight into the object path),
+// so this is path-traversal defense, not tidiness.
+func TestAPIShipTraversalRefusals(t *testing.T) {
+ fx := newConsoleFixture(t)
+ now := time.Now()
+ if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ token := cliToken(t, fx, "paul@keymail.dev", "code-ship-traversal")
+ payload := randomBytes(t, 64)
+
+ cases := []struct {
+ name string
+ version string
+ kind string
+ part string
+ }{
+ {"dotdot slash etc passwd", "trv00001", "binary", "../../etc/passwd"},
+ {"nested traversal under a valid-looking prefix", "trv00002", "binary", "linux-arm64/../../x"},
+ {"bare dotdot", "trv00003", "binary", ".."},
+ {"empty part name", "trv00004", "binary", ""},
+ {"static traversal", "trv00005", "static", "../site.tar.gz"},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", token,
+ []shipPart{metaPart(t, c.version, c.kind), {name: c.part, body: payload}})
+ if status != http.StatusBadRequest {
+ t.Fatalf("%s = %d %v, want 400", c.name, status, out)
+ }
+ if _, err := release.GetManifest(context.Background(), fx.rel, release.OpsAccount, "titogo", c.version); err == nil {
+ t.Fatalf("%s: manifest exists for version %s, want none — release.Ship must never have been reached",
+ c.name, c.version)
+ }
+ })
+ }
+}
+
+// TestAPIShipImmutableConflict: re-shipping an already-shipped version with
+// DIFFERENT bytes is a permanent 409, not the generic 502 every other
+// release.Ship failure gets — a caller retrying a 502 would re-upload
+// forever and never learn this can't succeed. Re-shipping the exact same
+// bytes, by contrast, must stay a success: it is the one case release.Ship
+// itself calls idempotent, and a caller retrying a dropped connection needs
+// that retry to actually land.
+func TestAPIShipImmutableConflict(t *testing.T) {
+ fx := newConsoleFixture(t)
+ now := time.Now()
+ if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ token := cliToken(t, fx, "paul@keymail.dev", "code-ship-immutable")
+ payload := randomBytes(t, 256)
+
+ status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", token,
+ []shipPart{metaPart(t, "abc1234", "binary"), {name: "linux-arm64", body: payload}})
+ if status != http.StatusCreated {
+ t.Fatalf("first ship = %d %v", status, out)
+ }
+
+ different := randomBytes(t, 256)
+ status, out = postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", token,
+ []shipPart{metaPart(t, "abc1234", "binary"), {name: "linux-arm64", body: different}})
+ if status != http.StatusConflict {
+ t.Fatalf("re-ship with different bytes = %d %v, want 409", status, out)
+ }
+ if !strings.Contains(str(t, out, "error"), "immutable") {
+ t.Fatalf("conflict error = %q, want it to name immutability", str(t, out, "error"))
+ }
+
+ status, out = postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", token,
+ []shipPart{metaPart(t, "abc1234", "binary"), {name: "linux-arm64", body: payload}})
+ if status != http.StatusOK {
+ t.Fatalf("re-ship with identical bytes = %d %v, want 200 (idempotent, nothing created)", status, out)
+ }
+ if str(t, out, "result") != "shipped" || str(t, out, "version") != "abc1234" {
+ t.Fatalf("idempotent re-ship body = %v", out)
+ }
+}
+
+// TestAPIShipConcurrencyLimit: the rate limiter bounds requests over TIME,
+// but says nothing about how many run AT ONCE. Two ships already in flight
+// (each holding the server's ship semaphore while its body trickles in,
+// via an io.Pipe under the test's control) leave a third concurrent
+// request no room — it must be refused immediately with 429, not queued
+// behind either of the first two.
+func TestAPIShipConcurrencyLimit(t *testing.T) {
+ fx := newConsoleFixture(t)
+ now := time.Now()
+ if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ token := cliToken(t, fx, "paul@keymail.dev", "code-ship-concurrency")
+
+ // A hook (same shape as cliNotified): signalled once a request has
+ // actually acquired shipSem, so this test waits on a real fact instead
+ // of racing the two background goroutines below with a sleep.
+ fx.srv.shipStarted = make(chan struct{}, 2)
+
+ var wg sync.WaitGroup
+ statuses := make([]int, 2)
+ var closers []func() error
+ for i := 0; i < 2; i++ {
+ pr, pw := io.Pipe()
+ closers = append(closers, pw.Close)
+ req, err := http.NewRequest(http.MethodPost, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", pr)
+ if err != nil {
+ t.Fatal(err)
+ }
+ req.Header.Set("Content-Type", "multipart/form-data; boundary=xshipboundaryx")
+ req.Header.Set("Authorization", "Bearer "+token)
+ wg.Add(1)
+ idx := i
+ go func() {
+ defer wg.Done()
+ res, err := http.DefaultClient.Do(req)
+ if err != nil {
+ statuses[idx] = -1
+ return
+ }
+ defer res.Body.Close()
+ io.Copy(io.Discard, res.Body)
+ statuses[idx] = res.StatusCode
+ }()
+ }
+
+ for i := 0; i < 2; i++ {
+ select {
+ case <-fx.srv.shipStarted:
+ case <-time.After(5 * time.Second):
+ t.Fatal("timed out waiting for the first two ships to start (acquire shipSem)")
+ }
+ }
+
+ status, out := postShip(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", token,
+ []shipPart{metaPart(t, "abc1234", "binary"), {name: "linux-arm64", body: randomBytes(t, 64)}})
+ if status != http.StatusTooManyRequests {
+ t.Fatalf("third concurrent ship = %d %v, want 429", status, out)
+ }
+ if !strings.Contains(str(t, out, "error"), "in progress") {
+ t.Fatalf("third concurrent ship error = %q, want it to name the concurrency limit", str(t, out, "error"))
+ }
+
+ // Unblock the two held-open requests (each now sees an immediately-EOF
+ // body and is refused as an empty upload) so the test doesn't leak
+ // goroutines, and confirm they actually finish.
+ for _, c := range closers {
+ c()
+ }
+ done := make(chan struct{})
+ go func() { wg.Wait(); close(done) }()
+ select {
+ case <-done:
+ case <-time.After(5 * time.Second):
+ t.Fatal("timed out waiting for the held-open ships to finish after closing their bodies")
+ }
+ for i, st := range statuses {
+ if st != http.StatusBadRequest {
+ t.Fatalf("held-open ship %d finished with status %d, want 400 (empty upload, once unblocked)", i, st)
+ }
+ }
+}
diff --git a/internal/console/server.go b/internal/console/server.go
index 53fb6adf1..abcf0babb 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -120,6 +120,22 @@ type Server struct {
// notification attempt. Tests only — the send is off the request
// goroutine, and a test that asserted on it without this would be racing.
cliNotified chan struct{}
+ // shipSem bounds how many `carlos ship` uploads (apicli.go,
+ // handleAPIShip) may run AT ONCE, as opposed to s.cli.ship, which
+ // bounds them over TIME. A rate limiter is a counter; it says nothing
+ // about concurrency, and a ship holds a goroutine plus a substantial
+ // buffer open for as long as the client trickles bytes in — a handful
+ // of slow uploads run in parallel is a resource-exhaustion path the
+ // counter alone does nothing to close. Always initialized (never nil):
+ // unlike cliNotified below this is load-bearing in production, not a
+ // test hook.
+ shipSem chan struct{}
+ // shipStarted, when non-nil, is signalled after a ship request
+ // acquires shipSem, before it does any (possibly slow, possibly
+ // blocking) reading of the request body. Tests only, same reasoning as
+ // cliNotified: without this a test racing to fill shipSem would have no
+ // way to know the earlier requests actually hold it yet.
+ shipStarted chan struct{}
}
// NewServer wires a server over an opened Store, loading (or minting) the
@@ -140,6 +156,11 @@ func NewServer(cfg Config, st *Store) (*Server, error) {
instances: newInstanceFetcher(cfg.AppClient, cfg.AppScheme),
secure: strings.HasPrefix(cfg.Origin, "https://"),
cli: newCLILimits(),
+ // Two concurrent ships at a time: roomy for one operator's CI plus a
+ // human running `carlos ship` by hand, tight enough that a fleet of
+ // slow-loris uploads against a stolen or shared token can't pile up
+ // goroutines and buffers without bound.
+ shipSem: make(chan struct{}, 2),
}
s.mux.HandleFunc("GET /{$}", s.handleHome)
s.mux.HandleFunc("POST /auth/continue", s.handleContinue)