server+home: storage meter — per-space bytes on the ledger, shown to members (#669)
#668 slice 1. controld's stats endpoint gains storage_bytes (SQLite files + blob dir; pure filesystem metadata, never wakes a hibernating space); the cloud service sweeps it into the ledger every 6h; the space listing carries it; home renders it under each created space. Transparency only — enforcement is #561, whose quota numbers want this data first.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
10 files changed,
+305
−21
changelog/2026-07-11-storage-meter.md+4 −0cloud.go+2 −0cloud_db.go+47 −3controld.go+7 −2docs/cloud-provisioner.md+12 −0home/web/pages/index.html+2 −0home/web/static/home.js+18 −0meter.go+102 −0meter_test.go+91 −0reaper.go+20 −16
diff --git a/changelog/2026-07-11-storage-meter.md b/changelog/2026-07-11-storage-meter.md| new file mode 100644 |
| index 0000000..7be9311 |
| --- /dev/null |
| +++ b/changelog/2026-07-11-storage-meter.md |
| @@ -0,0 +1,4 @@ |
| +🤖 Your Eleven home page now shows how much each space you created is |
| +storing — photos, videos, and files, measured as the encrypted bytes we hold |
| +(we can't see what's in them, only how much there is). Nothing is limited; |
| +this is just your own usage, visible to you. |
diff --git a/cloud.go b/cloud.go| index 9242e95..77f9e83 100644 |
| --- a/cloud.go |
| +++ b/cloud.go |
| @@ -379,6 +379,7 @@ func runCloud(args []string) { |
| reapIntervalHours := fs.Int("reap-interval-hours", envInt("LCHAT_CLOUD_REAP_INTERVAL_HOURS", 6), "how often the reaper sweeps") |
| reapDryRun := fs.Bool("reap-dry-run", os.Getenv("LCHAT_CLOUD_REAP_DRY_RUN") != "", "log what the reaper would reclaim without deleting anything") |
| reapMax := fs.Int("reap-max-per-sweep", envInt("LCHAT_CLOUD_REAP_MAX_PER_SWEEP", 5), "cap reclaims per reaper sweep (blast-radius bound; 0 = unlimited)") |
| + meterIntervalHours := fs.Int("meter-interval-hours", envInt("LCHAT_CLOUD_METER_INTERVAL_HOURS", 6), "how often the storage meter sweeps (0 disables)") |
| _ = fs.Parse(args) |
| if *baseDomain == "" || !validHost("x."+*baseDomain) { |
| @@ -422,6 +423,7 @@ func runCloud(args []string) { |
| log.Printf("cloud: billing on (%d tiers)", len(billing.tiers)) |
| } |
| go s.reapLoop(time.Duration(*reapIntervalHours) * time.Hour) |
| + go s.meterLoop(time.Duration(*meterIntervalHours) * time.Hour) |
| ln, where, err := listen(*addr, *socketPath) |
| if err != nil { |
| die("cloud: listen: %v", err) |
diff --git a/cloud_db.go b/cloud_db.go| index ae47b68..efc871d 100644 |
| --- a/cloud_db.go |
| +++ b/cloud_db.go |
| @@ -65,7 +65,10 @@ CREATE TABLE IF NOT EXISTS spaces ( |
| if err := db.migrateSpacesID(); err != nil { |
| return err |
| } |
| - return db.migrateBilling() |
| + if err := db.migrateBilling(); err != nil { |
| + return err |
| + } |
| + return db.migrateMeter() |
| } |
| // migrateSpacesID rebuilds a spaces table created under the original host-PK |
| @@ -160,6 +163,19 @@ ALTER TABLE spaces ADD COLUMN paid_until INTEGER NOT NULL DEFAULT 0;`); err != n |
| return nil |
| } |
| +// migrateMeter adds the storage meter's columns (#668 slice 1): the byte count |
| +// controld last measured and when. Bytes of ciphertext — content-blind, like |
| +// everything in this ledger. |
| +func (db *cloudDB) migrateMeter() error { |
| + if has, err := db.spacesHasColumn("storage_bytes"); err != nil || has { |
| + return err |
| + } |
| + _, err := db.sql.Exec(` |
| +ALTER TABLE spaces ADD COLUMN storage_bytes INTEGER NOT NULL DEFAULT 0; |
| +ALTER TABLE spaces ADD COLUMN metered_at INTEGER NOT NULL DEFAULT 0;`) |
| + return err |
| +} |
| + |
| // mintCallerToken creates a caller token, stores only its hash, and returns the |
| // plaintext (shown once). |
| func (db *cloudDB) mintCallerToken(label string) (id, token string, err error) { |
| @@ -337,13 +353,17 @@ type Space struct { |
| Entitlement string `json:"entitlement"` |
| Tier string `json:"tier,omitempty"` |
| PaidUntil int64 `json:"paid_until,omitempty"` |
| + // The storage meter's last reading (bytes of ciphertext; 0 = not yet |
| + // metered) and when it was taken. |
| + StorageBytes int64 `json:"storage_bytes,omitempty"` |
| + MeteredAt int64 `json:"metered_at,omitempty"` |
| } |
| // listSpaces returns a requester's finalized spaces (a NULL host is a create |
| // still in flight or one that failed before rollback — not shown). |
| func (db *cloudDB) listSpaces(callerID, requester string) ([]Space, error) { |
| rows, err := db.sql.Query( |
| - `SELECT host, created_at, entitlement, tier, paid_until FROM spaces |
| + `SELECT host, created_at, entitlement, tier, paid_until, storage_bytes, metered_at FROM spaces |
| WHERE caller_id = ? AND requester = ? AND host IS NOT NULL ORDER BY id`, |
| callerID, requester, |
| ) |
| @@ -354,7 +374,7 @@ func (db *cloudDB) listSpaces(callerID, requester string) ([]Space, error) { |
| out := []Space{} |
| for rows.Next() { |
| var s Space |
| - if err := rows.Scan(&s.Host, &s.CreatedAt, &s.Entitlement, &s.Tier, &s.PaidUntil); err != nil { |
| + if err := rows.Scan(&s.Host, &s.CreatedAt, &s.Entitlement, &s.Tier, &s.PaidUntil, &s.StorageBytes, &s.MeteredAt); err != nil { |
| return nil, err |
| } |
| s.URL = "https://" + s.Host |
| @@ -408,6 +428,30 @@ func (db *cloudDB) setEntitlement(id int64, ent string, paidUntil int64) error { |
| return nil |
| } |
| +// allFinalizedSpaces is the meter's sweep set: every space with a hostname. |
| +func (db *cloudDB) allFinalizedSpaces() ([]reapCandidate, error) { |
| + rows, err := db.sql.Query(`SELECT id, host, created_at FROM spaces WHERE host IS NOT NULL ORDER BY id`) |
| + if err != nil { |
| + return nil, err |
| + } |
| + defer rows.Close() |
| + var out []reapCandidate |
| + for rows.Next() { |
| + var c reapCandidate |
| + if err := rows.Scan(&c.ID, &c.Host, &c.CreatedAt); err != nil { |
| + return nil, err |
| + } |
| + out = append(out, c) |
| + } |
| + return out, rows.Err() |
| +} |
| + |
| +// setStorageBytes records one meter reading. |
| +func (db *cloudDB) setStorageBytes(id, bytes, at int64) error { |
| + _, err := db.sql.Exec(`UPDATE spaces SET storage_bytes = ?, metered_at = ? WHERE id = ?`, bytes, at, id) |
| + return err |
| +} |
| + |
| // findSpaceBySubscription maps a Stripe subscription id back to its space — the |
| // fallback for subscription events that arrive without space_id metadata. |
| func (db *cloudDB) findSpaceBySubscription(subscriptionID string) (int64, bool, error) { |
diff --git a/controld.go b/controld.go| index 1641203..05451b1 100644 |
| --- a/controld.go |
| +++ b/controld.go |
| @@ -128,7 +128,7 @@ func (c *controld) statsHandler(w http.ResponseWriter, r *http.Request) { |
| // skips those, as before.) |
| if c.reg != nil { |
| if rt, rerr := c.reg.get(host); rerr == nil && rt != nil && rt.Kind == "instance" && rt.Hibernate { |
| - writeJSON(w, http.StatusOK, map[string]any{"host": host, "members": 0, "setup_complete": false, "has_content": false}) |
| + writeJSON(w, http.StatusOK, map[string]any{"host": host, "members": 0, "setup_complete": false, "has_content": false, "storage_bytes": 0}) |
| return |
| } |
| } |
| @@ -139,7 +139,12 @@ func (c *controld) statsHandler(w http.ResponseWriter, r *http.Request) { |
| writeControlErr(w, http.StatusInternalServerError, "stats_failed", err.Error()) |
| return |
| } |
| - writeJSON(w, http.StatusOK, map[string]any{"host": host, "members": members, "setup_complete": setupComplete, "has_content": hasContent}) |
| + writeJSON(w, http.StatusOK, map[string]any{ |
| + "host": host, "members": members, "setup_complete": setupComplete, "has_content": hasContent, |
| + // The storage meter's read (meter.go) — pure filesystem metadata, |
| + // measured alongside the DB stats so one endpoint serves both sweeps. |
| + "storage_bytes": instanceStorageBytes(host), |
| + }) |
| } |
| // deleteHandler deprovisions one instance (self-serve space deletion, reached |
diff --git a/docs/cloud-provisioner.md b/docs/cloud-provisioner.md| index d3927e4..e594436 100644 |
| --- a/docs/cloud-provisioner.md |
| +++ b/docs/cloud-provisioner.md |
| @@ -286,6 +286,18 @@ window: `LCHAT_TRASH_KEEP_DAYS` (opt-in — 0/unset keeps trash forever) removes |
| `/data/trash/<host>-<unix>` entries older than that, swept daily. Keep it well |
| above the reap threshold so a mistaken reap stays recoverable for a while. |
| +## The storage meter (#668 slice 1) |
| + |
| +Every sweep interval (`LCHAT_CLOUD_METER_INTERVAL_HOURS`, default 6, 0 |
| +disables), the cloud service asks controld for each ledger space's on-disk |
| +footprint — the SQLite files plus the attachment blob dir, **pure filesystem |
| +metadata: nothing is read, and a hibernating space is never woken**. The |
| +reading lands on the ledger row (`storage_bytes`, `metered_at`), rides the |
| +space listing, and home shows it to the member ("2.1 GB stored"). Transparency |
| +only — quota enforcement is #561, and its numbers should come from this data. |
| +Bytes of ciphertext are exactly the kind of thing this ledger is allowed to |
| +know (#562: price only what ciphertext exposes). |
| + |
| ## Billing (Stripe) — tiers, quota exemption, no metering |
| Optional space upgrades (`billing.go`): a person who created a space can pay a |
diff --git a/home/web/pages/index.html b/home/web/pages/index.html| index 4d5a8ab..3735aaa 100644 |
| --- a/home/web/pages/index.html |
| +++ b/home/web/pages/index.html |
| @@ -315,6 +315,8 @@ |
| ul.servers li a.name { font-weight: 600; line-height: 1.25; color: var(--ink); text-decoration: none; } |
| ul.servers li a.name:hover { text-decoration: underline; } |
| ul.servers li .url { color: var(--muted); font-size: .8rem; line-height: 1.3; overflow-wrap: anywhere; } |
| + /* The storage meter's reading, under the url — quieter still. */ |
| + ul.servers li .srv-usage { color: var(--muted); opacity: .75; font-size: .72rem; line-height: 1.3; } |
| /* Star toggle: a quiet outline star that fills gold when on. */ |
| .star { |
| flex: none; background: none; border: none; padding: 2px; cursor: pointer; |
diff --git a/home/web/static/home.js b/home/web/static/home.js| index 1866a67..04d1876 100644 |
| --- a/home/web/static/home.js |
| +++ b/home/web/static/home.js |
| @@ -71,6 +71,14 @@ function archiveStatus(msg, kind) { |
| function hostOf(url) { try { return new URL(url).host; } catch { return url || ""; } } |
| function labelOf(s) { return s.name || hostOf(s.url); } |
| +// fmtBytes renders a storage reading for members: decimal units (matching how |
| +// storage is priced), one decimal for GB, whole numbers below. |
| +function fmtBytes(n) { |
| + if (n >= 1e9) return (n / 1e9).toFixed(1).replace(/\.0$/, "") + " GB"; |
| + if (n >= 1e6) return Math.round(n / 1e6) + " MB"; |
| + return "<1 MB"; |
| +} |
| + |
| // Whether this home server offers self-serve space creation (set from |
| // /api/features). The create-a-space controls only appear when it's both |
| // enabled here and you're signed in. |
| @@ -458,6 +466,16 @@ function serverRow(s, sortable) { |
| main.append(badge); |
| } |
| + // The storage meter's reading (spaces this account created; 0 = not yet |
| + // metered). Transparency first — quotas come later (#561). |
| + const metered = provisioned ? provisioned[hostOf(s.url)] : null; |
| + if (metered && metered.storage_bytes > 0) { |
| + const usage = document.createElement("span"); |
| + usage.className = "srv-usage"; |
| + usage.textContent = fmtBytes(metered.storage_bytes) + " stored"; |
| + main.append(usage); |
| + } |
| + |
| li.append(star, main); |
| if (ledger && ledger.entitlement !== "paid") { |
diff --git a/meter.go b/meter.go| new file mode 100644 |
| index 0000000..6eb7b33 |
| --- /dev/null |
| +++ b/meter.go |
| @@ -0,0 +1,102 @@ |
| +package main |
| + |
| +// The storage meter (#668 slice 1): how many bytes each cloud-provisioned |
| +// space occupies, measured server-side and shown to members — no enforcement |
| +// (that's #561, and its quota numbers want this data first). |
| +// |
| +// Metering only what ciphertext exposes: an instance's bytes are its DB files |
| +// plus its attachment blobs, all opaque. Nothing here reads content, and |
| +// nothing here ever WAKES a space — measurement is pure filesystem metadata, |
| +// so a sweep over a fleet of hibernating instances stays free. |
| +// |
| +// Split like the reaper: the measuring half runs in controld (root, owns |
| +// /data), the sweeping half in the cloud service (unprivileged, owns the |
| +// ledger), joined by the existing stats endpoint. |
| + |
| +import ( |
| + "log" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "time" |
| +) |
| + |
| +// ---- measuring (controld-side, root) ------------------------------------------- |
| + |
| +// storageBytesForDB sums the on-disk footprint of an instance rooted at its DB |
| +// path: the SQLite files (db, -wal, -shm) plus the attachment blob dir |
| +// (<db-sans-ext>-blobs/, the disk blobStore's layout — db.go). Pure but for |
| +// the filesystem, so it's directly testable. |
| +// |
| +// Slice-1 honesty: a hibernating space whose local copy has been cleaned away |
| +// measures as only what remains on disk; measuring the S3 prefix instead is |
| +// follow-up work in #668. Today the activator keeps local copies, so this is |
| +// the fleet's truth in practice. |
| +func storageBytesForDB(dbPath string) int64 { |
| + var total int64 |
| + for _, suffix := range []string{"", "-wal", "-shm"} { |
| + if info, err := os.Stat(dbPath + suffix); err == nil { |
| + total += info.Size() |
| + } |
| + } |
| + blobDir := strings.TrimSuffix(dbPath, filepath.Ext(dbPath)) + "-blobs" |
| + _ = filepath.WalkDir(blobDir, func(_ string, d os.DirEntry, err error) error { |
| + if err != nil || d.IsDir() { |
| + return nil // unreadable entries just don't count; never fail a sweep |
| + } |
| + if info, err := d.Info(); err == nil { |
| + total += info.Size() |
| + } |
| + return nil |
| + }) |
| + return total |
| +} |
| + |
| +// instanceStorageBytes measures one hosted instance. |
| +func instanceStorageBytes(host string) int64 { |
| + return storageBytesForDB(filepath.Join(instanceDataDir, host+".db")) |
| +} |
| + |
| +// ---- sweeping (cloud-side) ------------------------------------------------------ |
| + |
| +// meterLoop refreshes every ledger space's storage_bytes on an interval. |
| +// Enabled by default (interval <= 0 disables): the meter is harmless metadata |
| +// and members should see their usage without an operator opting in. |
| +func (s *cloudServer) meterLoop(interval time.Duration) { |
| + if interval <= 0 { |
| + return |
| + } |
| + log.Printf("cloud: storage meter on — sweeping every %s", interval) |
| + for { |
| + s.meterOnce() |
| + time.Sleep(interval) |
| + } |
| +} |
| + |
| +// meterOnce asks controld for each finalized space's byte count and records |
| +// it. A stats error skips that space (its last reading stands, metered_at |
| +// shows the staleness); the sweep never fails as a whole. |
| +func (s *cloudServer) meterOnce() (metered int) { |
| + spaces, err := s.db.allFinalizedSpaces() |
| + if err != nil { |
| + log.Printf("cloud: meter: ledger query: %v", err) |
| + return 0 |
| + } |
| + now := time.Now().Unix() |
| + for _, sp := range spaces { |
| + st, err := s.controlStats(sp.Host) |
| + if err != nil { |
| + log.Printf("cloud: meter: skip %s (stats unavailable): %v", sp.Host, err) |
| + continue |
| + } |
| + if err := s.db.setStorageBytes(sp.ID, st.StorageBytes, now); err != nil { |
| + log.Printf("cloud: meter: record %s: %v", sp.Host, err) |
| + continue |
| + } |
| + metered++ |
| + } |
| + if metered > 0 { |
| + log.Printf("cloud: meter: refreshed %d space(s)", metered) |
| + } |
| + return metered |
| +} |
diff --git a/meter_test.go b/meter_test.go| new file mode 100644 |
| index 0000000..3232853 |
| --- /dev/null |
| +++ b/meter_test.go |
| @@ -0,0 +1,91 @@ |
| +package main |
| + |
| +import ( |
| + "net/http" |
| + "os" |
| + "path/filepath" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func TestStorageBytesForDB(t *testing.T) { |
| + dir := t.TempDir() |
| + db := filepath.Join(dir, "space.11ch.at.db") |
| + write := func(path string, n int) { |
| + t.Helper() |
| + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(path, make([]byte, n), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + write(db, 1000) |
| + write(db+"-wal", 200) |
| + write(filepath.Join(dir, "space.11ch.at-blobs", "aa"), 5000) |
| + write(filepath.Join(dir, "space.11ch.at-blobs", "nested", "bb"), 300) |
| + // A sibling instance's files must not leak into the count. |
| + write(filepath.Join(dir, "other.11ch.at.db"), 9999) |
| + |
| + if got := storageBytesForDB(db); got != 6500 { |
| + t.Errorf("storageBytesForDB = %d, want 6500 (db+wal+blobs)", got) |
| + } |
| + // Missing everything → 0, no error. |
| + if got := storageBytesForDB(filepath.Join(dir, "absent.db")); got != 0 { |
| + t.Errorf("absent instance = %d, want 0", got) |
| + } |
| +} |
| + |
| +func TestMeterOnceRecordsReadings(t *testing.T) { |
| + db := openTestCloudDB(t) |
| + idA := insertSpaceRow(t, db, "a.11ch.at", 1000) |
| + idB := insertSpaceRow(t, db, "b.11ch.at", 1000) |
| + // A pending create (NULL host) is never swept. |
| + if _, err := db.sql.Exec(`INSERT INTO spaces(host, caller_id, requester, created_at) VALUES(NULL,'c','r',1)`); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + bytesByHost := map[string]int64{"a.11ch.at": 12345, "b.11ch.at": 0} |
| + mux := http.NewServeMux() |
| + mux.HandleFunc("GET /instances/{host}/stats", func(w http.ResponseWriter, r *http.Request) { |
| + h := r.PathValue("host") |
| + if h == "b.11ch.at" { |
| + w.WriteHeader(http.StatusInternalServerError) // stats down for b |
| + return |
| + } |
| + writeJSON(w, http.StatusOK, map[string]any{"storage_bytes": bytesByHost[h]}) |
| + }) |
| + sock := fakeControl(t, mux.ServeHTTP) |
| + s := &cloudServer{db: db, control: controlClient(sock), controlToken: "t"} |
| + |
| + if metered := s.meterOnce(); metered != 1 { |
| + t.Fatalf("metered = %d, want 1 (a recorded, b skipped)", metered) |
| + } |
| + spaces, err := db.listSpaces("c", "r") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + byHost := map[string]Space{} |
| + for _, sp := range spaces { |
| + byHost[sp.Host] = sp |
| + } |
| + if got := byHost["a.11ch.at"]; got.StorageBytes != 12345 || got.MeteredAt == 0 { |
| + t.Errorf("a = %+v, want 12345 bytes with a timestamp", got) |
| + } |
| + if got := byHost["b.11ch.at"]; got.StorageBytes != 0 || got.MeteredAt != 0 { |
| + t.Errorf("b = %+v, want untouched (stats error is a skip)", got) |
| + } |
| + _ = idA |
| + _ = idB |
| + |
| + // A later sweep refreshes the reading, never accumulates. |
| + bytesByHost["a.11ch.at"] = 200 |
| + before := time.Now().Unix() |
| + s.meterOnce() |
| + spaces, _ = db.listSpaces("c", "r") |
| + for _, sp := range spaces { |
| + if sp.Host == "a.11ch.at" && (sp.StorageBytes != 200 || sp.MeteredAt < before) { |
| + t.Errorf("refresh: %+v, want 200 bytes, fresh timestamp", sp) |
| + } |
| + } |
| +} |
diff --git a/reaper.go b/reaper.go| index 1636cb9..b4741da 100644 |
| --- a/reaper.go |
| +++ b/reaper.go |
| @@ -148,31 +148,35 @@ func runTrashPurge(keep time.Duration) { |
| // ---- the reaper (cloud-side) --------------------------------------------------- |
| -// controlStatus asks the control daemon whether a space was ever set up, plus |
| -// the member count and whether it has any content — the reaper's guards. |
| -func (s *cloudServer) controlStatus(host string) (members int, setupComplete, hasContent bool, err error) { |
| +// instanceStats is what the control daemon reports about one space: the |
| +// reaper's guards plus the storage meter's byte count (meter.go). |
| +type instanceStats struct { |
| + Members int `json:"members"` |
| + SetupComplete bool `json:"setup_complete"` |
| + HasContent bool `json:"has_content"` |
| + StorageBytes int64 `json:"storage_bytes"` |
| +} |
| + |
| +// controlStats asks the control daemon for a space's stats. |
| +func (s *cloudServer) controlStats(host string) (instanceStats, error) { |
| + var st instanceStats |
| req, err := http.NewRequest("GET", "http://control/instances/"+host+"/stats", nil) |
| if err != nil { |
| - return 0, false, false, err |
| + return st, err |
| } |
| req.Header.Set("Authorization", "Bearer "+s.controlToken) |
| resp, err := s.control.Do(req) |
| if err != nil { |
| - return 0, false, false, err |
| + return st, err |
| } |
| defer resp.Body.Close() |
| if resp.StatusCode != http.StatusOK { |
| - return 0, false, false, fmt.Errorf("control stats %s: HTTP %d", host, resp.StatusCode) |
| - } |
| - var body struct { |
| - Members int `json:"members"` |
| - SetupComplete bool `json:"setup_complete"` |
| - HasContent bool `json:"has_content"` |
| + return st, fmt.Errorf("control stats %s: HTTP %d", host, resp.StatusCode) |
| } |
| - if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { |
| - return 0, false, false, err |
| + if err := json.NewDecoder(resp.Body).Decode(&st); err != nil { |
| + return st, err |
| } |
| - return body.Members, body.SetupComplete, body.HasContent, nil |
| + return st, nil |
| } |
| // reapLoop sweeps every interval; enabled only when reapAfter>0. |
| @@ -207,12 +211,12 @@ func (s *cloudServer) reapOnce(now time.Time) (reaped, kept int) { |
| return 0, 0 |
| } |
| for _, c := range cands { |
| - members, setupComplete, hasContent, err := s.controlStatus(c.Host) |
| + st, err := s.controlStats(c.Host) |
| if err != nil { |
| log.Printf("cloud: reaper: skip %s (stats unavailable): %v", c.Host, err) |
| continue |
| } |
| - if setupComplete || members > 1 || hasContent { |
| + if st.SetupComplete || st.Members > 1 || st.HasContent { |
| kept++ |
| continue // set up, someone else joined, or has content — never reap |
| } |