diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 860f27b..5646192 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,7 +22,7 @@ jobs: persist-credentials: false - uses: actions/setup-go@v6 with: - go-version: '1.26' + go-version: '1.26.5' cache: true - name: Build run: go build ./... @@ -53,7 +53,7 @@ jobs: persist-credentials: false - uses: actions/setup-go@v6 with: - go-version: '1.26' + go-version: '1.26.5' cache: true - name: Install govulncheck run: go install golang.org/x/vuln/cmd/govulncheck@latest diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index 3dc4ed1..74f58d6 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -23,10 +23,12 @@ jobs: runs-on: ${{ matrix.runner }} steps: - uses: actions/checkout@v7 + with: + persist-credentials: false - uses: actions/setup-go@v6 with: - go-version: '1.26.x' + go-version: '1.26.5' cache: true - uses: actions/setup-node@v6 @@ -58,7 +60,7 @@ jobs: run: go run github.com/wailsapp/wails/v2/cmd/wails@v2.12.0 build -clean - name: Sign Windows binaries - if: runner.os == 'Windows' && env.WINDOWS_SIGN_CERT != '' + if: runner.os == 'Windows' && secrets.WINDOWS_SIGN_CERT != '' env: WINDOWS_SIGN_CERT: ${{ secrets.WINDOWS_SIGN_CERT }} WINDOWS_SIGN_CERT_PASSWORD: ${{ secrets.WINDOWS_SIGN_CERT_PASSWORD }} @@ -110,6 +112,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 + with: + persist-credentials: false - uses: actions/download-artifact@v8 with: diff --git a/app.go b/app.go index 58e6f47..8eb1bf9 100644 --- a/app.go +++ b/app.go @@ -321,6 +321,13 @@ type FleetState struct { const ( fleetOfflineAfter = 180 * time.Second fleetReapAfter = 1 * time.Hour + + // collectConcurrency bounds how many services collectAll collects at once. Each + // Collect is a real network round trip (up to ~90s worst case on a stalled + // provider), so running the batch through a small worker pool turns the cycle's + // cost from the SUM of per-service latencies into roughly the MAX, without + // unbounded fan-out against provider APIs or the (SetMaxOpenConns(1)) store. + collectConcurrency = 6 ) func (a *App) GetAppState() (AppState, error) { @@ -332,16 +339,21 @@ func (a *App) GetAppState() (AppState, error) { // can proceed (and message honestly) on Docker OR native. Available keeps its // Docker-only meaning; this is purely additive. runtimeStatus.NativeAvailable = a.services.HasNativeRuntime() + // Fetch these once and thread them into the helpers below instead of letting + // each helper re-run its own full-table scan (they were each queried up to 3x + // per GetAppState call before this). + deployments := a.store.ListDeployments() + earnings := a.store.ListLatestEarnings() return AppState{ Config: a.cfg.Config(), Runtime: runtimeStatus, Services: a.catalog.ListVisible(), - Deployments: a.store.ListDeployments(), - Earnings: a.store.ListLatestEarnings(), + Deployments: deployments, + Earnings: earnings, Guides: runtime.InstallGuides(), - Notifications: a.notifications(runtimeStatus), + Notifications: a.notifications(runtimeStatus, earnings, deployments), Currencies: supportedCurrencies(), - Summary: a.computeEarningsSummary(), + Summary: a.computeEarningsSummary(earnings), Health: a.store.HealthScores(7), ServiceDetails: a.store.ListServiceDetails(), }, nil @@ -353,7 +365,7 @@ func (a *App) GetEarningsSummary() (EarningsSummary, error) { if err := a.ready(); err != nil { return EarningsSummary{}, err } - return a.computeEarningsSummary(), nil + return a.computeEarningsSummary(a.store.ListLatestEarnings()), nil } // computeEarningsSummary converts the per-service CUMULATIVE daily balances into @@ -362,7 +374,7 @@ func (a *App) GetEarningsSummary() (EarningsSummary, error) { // points (e.g. GRASS) are surfaced separately and never summed. It is // stale-graceful: missing rates simply drop a service from the total and set // RatesStale, rather than erroring. -func (a *App) computeEarningsSummary() EarningsSummary { +func (a *App) computeEarningsSummary(earnings []store.EarningsRecord) EarningsSummary { disp := "USD" if a.cfg != nil { if c := a.cfg.Config().DisplayCurrency; c != "" { @@ -537,7 +549,7 @@ func (a *App) computeEarningsSummary() EarningsSummary { // Breakdown = every service's latest record, INCLUDING error rows so the UI // keeps a "needs attention" chip. - for _, rec := range a.store.ListLatestEarnings() { + for _, rec := range earnings { se := ServiceEarning{ Platform: rec.Platform, Name: rec.Platform, @@ -601,7 +613,7 @@ func (a *App) GetSettingsState() (SettingsState, error) { {Key: "CASHPILOT_DISPLAY_CURRENCY", Label: "Display Currency", Value: cfg.DisplayCurrency, Source: "Config", Help: "Currency used in the topbar and dashboard summaries."}, {Key: "CASHPILOT_API_KEY", Label: "Fleet API Key", Value: a.fleetKey, Source: "Config", Secret: true, Help: "Bearer token used by external workers and mobile clients."}, {Key: "CASHPILOT_UI_URL", Label: "Desktop API URL", Value: a.fleetUIURL(), Source: "Runtime", ReadOnly: true, Help: "URL that external workers should use for CASHPILOT_UI_URL."}, - {Key: "CASHPILOT_FLEET_BIND", Label: "Fleet Bind Address", Value: cfg.FleetBindAddress, Source: "Config", Help: "Default 127.0.0.1 (this machine only). Set to 0.0.0.0 only to accept worker/mobile connections from your LAN — this exposes the API to your network."}, + {Key: "CASHPILOT_FLEET_BIND", Label: "Fleet Bind Address", Value: cfg.FleetBindAddress, Source: "Config", Help: "Default 127.0.0.1 (this machine only). Set to 0.0.0.0 only to accept worker/mobile connections from your LAN — this exposes the API to your network, and (if metrics are enabled) also serves the UNAUTHENTICATED /metrics endpoint — your earnings and health totals — to anyone on your LAN."}, {Key: "CASHPILOT_FLEET_PORT", Label: "Fleet API Port", Value: strconv.Itoa(cfg.FleetPort), Source: "Config", Help: "Port used for external worker heartbeats."}, {Key: "TZ", Label: "System Timezone", Value: cfg.Timezone, Source: "Config", Help: "Timezone passed to future managed workers and mobile sync events."}, {Key: "CASHPILOT_DATA_DIR", Label: "Data Directory", Value: a.cfg.DataDir(), Source: "Read-only", ReadOnly: true, Help: "Directory containing the local SQLite database."}, @@ -979,8 +991,14 @@ func (a *App) collectOne(ctx context.Context, slug string) { // participate in the scheduled cycle instead of only collecting on a manual click. // A failing collector never aborts the batch — its error is already persisted as an // EarningsRecord (surfaced by notifications()); a store/transport error is logged -// via emitError and the loop continues to the next service. A single-flight guard -// (a.collecting) means overlapping triggers — the ticker, a post-deploy kick, a +// via emitError and the batch continues with the other services. Services are +// collected concurrently through a bounded worker pool (collectConcurrency) instead +// of one at a time, so the cycle's wall-clock cost is roughly the slowest single +// collector rather than the sum of all of them; a.emitError/emitEvent are safe to +// call from multiple goroutines (they only touch the set-once a.ctx and the +// thread-safe wailsruntime.EventsEmit) and the store serializes concurrent writes +// itself (SetMaxOpenConns(1)), so no extra locking is needed here. A single-flight +// guard (a.collecting) means overlapping triggers — the ticker, a post-deploy kick, a // manual refresh — never stack; a run already in progress is skipped rather than // queued. One earnings:changed event is emitted after the batch so the dashboard // refreshes once per cycle. @@ -1014,24 +1032,47 @@ func (a *App) collectAll(ctx context.Context) { add(slug) } - collected := 0 + // Collect the batch through a bounded worker pool: sem caps how many Collect + // calls run at once (collectConcurrency) so a large slug set can't fan out + // unbounded HTTP requests, and wg is waited on below so every launched goroutine + // has fully finished — success or error — before the post-loop steps (sampleHealth, + // PurgeOldData, SweepStaleFleetDevices) run. collected is an atomic counter since + // it is incremented from every worker goroutine. + var collected atomic.Int32 + var wg sync.WaitGroup + sem := make(chan struct{}, collectConcurrency) + aborted := false for _, slug := range slugs { if ctx.Err() != nil { - return - } - creds, err := a.store.GetCredentials(slug) - if err != nil { - a.emitError("collector", err) - continue - } - if _, err := a.collectors.Collect(ctx, slug, creds); err != nil { - a.emitError("collector", err) - continue + // Mirror the previous behavior: a cancelled ctx abandons the rest of the + // batch (including the post-loop steps) rather than starting more work, + // but goroutines already launched are still waited on below so none leak. + aborted = true + break } - collected++ + sem <- struct{}{} + wg.Add(1) + go func(slug string) { + defer wg.Done() + defer func() { <-sem }() + creds, err := a.store.GetCredentials(slug) + if err != nil { + a.emitError("collector", err) + return + } + if _, err := a.collectors.Collect(ctx, slug, creds); err != nil { + a.emitError("collector", err) + return + } + collected.Add(1) + }(slug) + } + wg.Wait() + if aborted { + return } - if collected > 0 { - a.emitEvent("earnings:changed", collected) + if collected.Load() > 0 { + a.emitEvent("earnings:changed", int(collected.Load())) } // Sample each deployed service's up/down state so the health score's uptime% @@ -1197,7 +1238,7 @@ func (a *App) ManagedRuntimePlan() runtime.ManagedRuntimePlan { return runtime.ManagedRuntimeRoadmap() } -func (a *App) notifications(status runtime.Status) []Notification { +func (a *App) notifications(status runtime.Status, earnings []store.EarningsRecord, deployments []store.Deployment) []Notification { var items []Notification // Only warn that the runtime is offline when NEITHER Docker nor the always-on // native runtime can run anything. With native available the app works without @@ -1205,12 +1246,12 @@ func (a *App) notifications(status runtime.Status) []Notification { if !status.Available && !status.NativeAvailable { items = append(items, Notification{Level: "warning", Title: "Runtime offline", Message: status.Message}) } - for _, record := range a.store.ListLatestEarnings() { + for _, record := range earnings { if record.Error != "" { items = append(items, Notification{Level: "error", Title: record.Platform + " collector", Message: record.Error}) } } - if len(a.store.ListDeployments()) == 0 { + if len(deployments) == 0 { items = append(items, Notification{Level: "info", Title: "No services deployed", Message: "Use the setup wizard or register a mobile device to start tracking earnings."}) } return items diff --git a/app_test.go b/app_test.go index e8099f7..82549cb 100644 --- a/app_test.go +++ b/app_test.go @@ -95,7 +95,7 @@ func TestComputeEarningsSummary(t *testing.T) { } app := &App{cfg: cfg, store: st, catalog: cat, exchange: svc, ctx: context.Background()} - sum := app.computeEarningsSummary() + sum := app.computeEarningsSummary(app.store.ListLatestEarnings()) // Total = honeygain latest (2.00 USD) + mysterium latest (8 MYST * 0.25 = 2.00 USD). // GRASS is excluded (non-convertible) and the error row has no successful daily @@ -266,7 +266,7 @@ func TestEarningsSummaryFirstObservationContributesZero(t *testing.T) { now := time.Now().UTC() seedEarnings(t, st, store.EarningsRecord{Platform: "honeygain", Balance: 50.0, Currency: "USD", CreatedAt: atUTC(now, 10)}) - sum := app.computeEarningsSummary() + sum := app.computeEarningsSummary(app.store.ListLatestEarnings()) if !approxEq(sum.Total, 50.0) { t.Fatalf("Total = %v, want 50.00 (latest cumulative still shows in Total)", sum.Total) } @@ -296,7 +296,7 @@ func TestEarningsSummaryCarryForwardAcrossGap(t *testing.T) { store.EarningsRecord{Platform: "honeygain", Balance: 5.0, Currency: "USD", CreatedAt: atUTC(now, 10)}, ) - sum := app.computeEarningsSummary() + sum := app.computeEarningsSummary(app.store.ListLatestEarnings()) if !approxEq(sum.Today, 2.0) { t.Fatalf("Today = %v, want 2.00 (5.00 today minus 3.00 carried across the gap)", sum.Today) } @@ -328,7 +328,7 @@ func TestEarningsSummaryMonthWindow(t *testing.T) { store.EarningsRecord{Platform: "honeygain", Balance: 8.0, Currency: "USD", CreatedAt: atUTC(now, 8)}, ) - sum := app.computeEarningsSummary() + sum := app.computeEarningsSummary(app.store.ListLatestEarnings()) if !approxEq(sum.Month, 5.0) { t.Fatalf("Month = %v, want 5.00 (8.00 today minus 3.00 at the month baseline)", sum.Month) } @@ -359,7 +359,7 @@ func TestEarningsSummaryPointsClassificationDuringOutage(t *testing.T) { store.EarningsRecord{Platform: "mysterium", Balance: 8.0, Currency: "MYST", CreatedAt: atUTC(now, 10)}, ) - sum := app.computeEarningsSummary() + sum := app.computeEarningsSummary(app.store.ListLatestEarnings()) if !approxEq(sum.Total, 0) { t.Fatalf("Total = %v, want 0 (MYST is unpriced during the outage -> excluded)", sum.Total) } @@ -574,11 +574,15 @@ func TestCollectAllCollectsCredentialOnlyServicesAndDedups(t *testing.T) { } } -// TestCollectAllSingleFlight pins the single-flight guard: many concurrent -// collectAll calls never overlap, so at most one Collect is ever in flight. +// TestCollectAllSingleFlight pins the single-flight guard: of many concurrent +// collectAll calls, only ONE runs its body (the rest bail on the a.collecting CAS), so +// each service is collected exactly once total — overlap is rejected, not stacked. +// (Within that one run the services now collect CONCURRENTLY, bounded by +// collectConcurrency, so max-concurrent-collects may exceed 1 — that is expected and is +// a separate property, asserted only to be within the bound.) func TestCollectAllSingleFlight(t *testing.T) { fake := newFakeCollector([]string{"svc-a", "svc-b", "svc-c"}, nil) - fake.hold = 2 * time.Millisecond // hold each collect so an overlap would be observable + fake.hold = 2 * time.Millisecond // hold each collect so overlapping runs would be observable app, _, cancel := newSchedulerTestApp(t, fake, "svc-a", "svc-b", "svc-c") defer cancel() @@ -592,7 +596,14 @@ func TestCollectAllSingleFlight(t *testing.T) { } wg.Wait() - if got := fake.maxSeen.Load(); got != 1 { - t.Fatalf("single-flight violated: max concurrent collects = %d, want 1", got) + // Single-flight: exactly one collectAll body ran, so the 3 services were collected + // once each (3 total), not 8×3. A broken guard would collect them many more times. + c := fake.counts() + if total := c["svc-a"] + c["svc-b"] + c["svc-c"]; total != 3 { + t.Fatalf("single-flight violated: total collects across 8 concurrent runs = %d, want 3 (one run × 3 services)", total) + } + // The one run's internal parallelism stays within the configured bound. + if got := fake.maxSeen.Load(); int(got) > collectConcurrency { + t.Fatalf("bounded concurrency violated: max concurrent collects = %d, want <= %d", got, collectConcurrency) } } diff --git a/frontend/src/main.ts b/frontend/src/main.ts index c32e406..6e1800e 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -486,7 +486,7 @@ function renderCatalogCard(service: Service, deployments: Deployment[]) {

${escapeHtml(service.shortDescription || service.description)}

- ${service.manualOnly && signupUrl ? `` : ``} + ${service.manualOnly && signupUrl ? `` : ``} ${signupUrl ? `` : ""}
@@ -494,7 +494,13 @@ function renderCatalogCard(service: Service, deployments: Deployment[]) { } async function renderSettings(current: AppState) { - const settings = await GetSettingsState(); + let settings: SettingsState; + try { + settings = await GetSettingsState(); + } catch (error) { + showErrorToast({scope: "settings", error: String(error)}); + return; + } const total = totalBalance(current); // Background-helper state is derived live from the OS (not a stored preference): // installed = a login agent is registered, running = the service manager reports it @@ -674,7 +680,13 @@ function envInputName(key: string) { } async function renderFleet(current: AppState) { - const fleet = await GetFleetState(); + let fleet: FleetState; + try { + fleet = await GetFleetState(); + } catch (error) { + showErrorToast({scope: "fleet", error: String(error)}); + return; + } const total = totalBalance(current); root.innerHTML = ` ${titlebar()} @@ -744,8 +756,12 @@ async function renderFleet(current: AppState) { }); document.querySelectorAll("[data-copy]").forEach((button) => { button.addEventListener("click", async () => { - await navigator.clipboard.writeText(button.dataset.copy || ""); - button.textContent = "Copied"; + try { + await navigator.clipboard.writeText(button.dataset.copy || ""); + button.textContent = "Copied"; + } catch (error) { + showErrorToast({scope: "clipboard", error: String(error)}); + } }); }); } @@ -785,8 +801,12 @@ async function addFleetDevice() { async function removeFleetDevice(id: number) { if (!confirm("Remove this fleet device from CashPilot Desktop?")) return; - await RemoveFleetDevice(id); - render(); + try { + await RemoveFleetDevice(id); + render(); + } catch (error) { + showErrorToast({scope: "fleet", error: String(error)}); + } } function totalBalance(current: AppState) { @@ -1011,12 +1031,12 @@ function renderServicesTable(services: Service[], deployments: Deployment[], ear ${deployment.memoryMb.toFixed(0)} MB
- - + + ${deployment.status === "running" - ? `` - : ``} - + ? `` + : ``} +
@@ -1188,7 +1208,7 @@ function renderWizardServices(services: Service[]) {

Choose providers to set up. Earnings vary by location, hardware, uptime, and demand, so CashPilot does not promise a fixed amount.

${services.map((service) => ` - - - + + +
-

+      

     
   `;
 }
@@ -1288,7 +1308,12 @@ function getServiceFields(service: Service): ServiceField[] {
 }
 
 async function hydrateWizardForm(service: Service) {
-  const creds = await GetCredentials(service.slug);
+  let creds: Record = {};
+  try {
+    creds = await GetCredentials(service.slug);
+  } catch (error) {
+    showErrorToast({scope: "credentials", error: String(error)});
+  }
   document.querySelectorAll(`[data-form-slug="${service.slug}"] [data-wizard-env]`).forEach((input) => {
     const key = input.dataset.wizardEnv || "";
     if (creds[key]) input.value = creds[key];
diff --git a/frontend/src/wails.d.ts b/frontend/src/wails.d.ts
index 53a8a93..8768b57 100644
--- a/frontend/src/wails.d.ts
+++ b/frontend/src/wails.d.ts
@@ -14,12 +14,15 @@ declare module "../wailsjs/go/main/App" {
   export function SaveCredentials(slug: string, values: Record): Promise;
   export function GetCredentials(slug: string): Promise>;
   export function DeployService(slug: string, values: Record): Promise;
+  export function StartService(slug: string): Promise;
   export function StopService(slug: string): Promise;
   export function RestartService(slug: string): Promise;
   export function RemoveService(slug: string): Promise;
   export function GetLogs(slug: string, lines: number): Promise;
   export function RefreshDeployments(): Promise;
   export function CollectService(slug: string): Promise;
+  export function ListServices(): Promise;
+  export function GetService(slug: string): Promise;
   export function ManagedRuntimePlan(): Promise;
   export function GetEarningsSummary(): Promise;
 }
diff --git a/internal/collectors/collectors.go b/internal/collectors/collectors.go
index 413e278..04f3428 100644
--- a/internal/collectors/collectors.go
+++ b/internal/collectors/collectors.go
@@ -79,7 +79,7 @@ func (r *Registry) Collect(ctx context.Context, slug string, credentials map[str
 	if fn, ok := collectorDispatch[slug]; ok {
 		result, err = fn(r, ctx, credentials)
 	} else {
-		err = fmt.Errorf("collector for %s is not ported yet", slug)
+		err = fmt.Errorf("Collector for %s is not ported yet", slug)
 	}
 	if err != nil {
 		result = Result{Platform: slug, Balance: 0, Currency: "USD", Error: err.Error()}
@@ -214,7 +214,7 @@ func (r *Registry) collectBytelixir(ctx context.Context, credentials map[string]
 		} `json:"data"`
 	}
 	if err := r.doJSONWithCookies(ctx, "GET", "https://dash.bytelixir.com/api/v1/user", nil, map[string]string{"Accept": "application/json", "X-Requested-With": "XMLHttpRequest"}, cookies, &api); err != nil {
-		return Result{}, fmt.Errorf("could not parse Bytelixir dashboard balance: %w", err)
+		return Result{}, fmt.Errorf("Could not parse Bytelixir dashboard balance: %w", err)
 	}
 	balance, _ := strconv.ParseFloat(api.Data.Balance, 64)
 	return Result{Platform: "bytelixir", Balance: round(balance, 4), Currency: "USD", Error: "Withdrawable balance only; dashboard scrape did not expose total earned"}, nil
@@ -351,7 +351,7 @@ func (r *Registry) collectGrass(ctx context.Context, credentials map[string]stri
 	// overwrite the real balance. Every other collector routes non-2xx through
 	// doJSON, which converts it to an error; do the same here.
 	if status < 200 || status >= 300 {
-		return Result{}, fmt.Errorf("grass: unexpected status %d", status)
+		return Result{}, fmt.Errorf("Grass returned unexpected status %d", status)
 	}
 	if user.Result.Data.TotalPoints > 0 {
 		return Result{Platform: "grass", Balance: round(user.Result.Data.TotalPoints, 4), Currency: "GRASS"}, nil
@@ -375,7 +375,7 @@ func (r *Registry) collectGrass(ctx context.Context, credentials map[string]stri
 	// As with retrieveUser above: a non-2xx here leaves active zero-valued, so treat
 	// it as an error rather than booking a spurious Balance:0.
 	if status < 200 || status >= 300 {
-		return Result{}, fmt.Errorf("grass: unexpected status %d", status)
+		return Result{}, fmt.Errorf("Grass returned unexpected status %d", status)
 	}
 	total := 0.0
 	for _, device := range active.Result.Data {
@@ -488,7 +488,7 @@ type mystNode struct {
 // tests can assert on it).
 func (r *Registry) collectMystPerNode(ctx context.Context, authHeaders map[string]string) error {
 	if r.store == nil {
-		return fmt.Errorf("no store configured for per-node persistence")
+		return fmt.Errorf("No store configured for per-node persistence")
 	}
 	var resp struct {
 		Nodes []struct {
@@ -557,7 +557,7 @@ func (r *Registry) collectPacketStream(ctx context.Context, credentials map[stri
 	}
 	balance, ok := parsePacketStreamBalance(string(body))
 	if !ok {
-		return Result{}, fmt.Errorf("could not parse PacketStream balance from dashboard")
+		return Result{}, fmt.Errorf("Could not parse PacketStream balance from dashboard")
 	}
 	return Result{Platform: "packetstream", Balance: round(balance, 4), Currency: "USD"}, nil
 }
diff --git a/internal/runtime/native.go b/internal/runtime/native.go
index 4eb2078..533249e 100644
--- a/internal/runtime/native.go
+++ b/internal/runtime/native.go
@@ -376,9 +376,22 @@ func (p *NativeProcessProvider) Logs(ctx context.Context, slug string, lines int
 	return readLogTail(filepath.Join(p.baseDir, slug, "log"), lines)
 }
 
+// listStatSemaphoreCap bounds how many statFn samples List runs concurrently. Each
+// sample blocks for cpuStatInterval (default 200ms), so an unbounded per-entry
+// goroutine fan-out is fine at earner-catalog scale, but this cap keeps a very large
+// deployment from spawning hundreds of simultaneous gopsutil/proc samples at once.
+const listStatSemaphoreCap = 8
+
 // List reports every registry entry as a ContainerInfo, checking each recorded pid's
 // liveness (and best-effort CPU/memory) via gopsutil. A process that isn't reachable
 // reports zeros and status "stopped"; it never panics on a dead/reused pid.
+//
+// Alive entries are sampled CONCURRENTLY (mirroring DockerProvider.statsForContainers):
+// each goroutine owns its own out[i] slot, so no locking/shared map is needed, and a
+// buffered semaphore bounds how many statFn calls run at once. A serial loop would
+// make this O(N * cpuStatInterval); with the fan-out the added latency stays ~one
+// interval regardless of N. Stopped entries are written synchronously (no sampling
+// needed), and the result stays sorted by slug just like the previous serial loop.
 func (p *NativeProcessProvider) List(ctx context.Context) ([]ContainerInfo, error) {
 	reg := p.readRegistry()
 	slugs := make([]string, 0, len(reg.Entries))
@@ -387,31 +400,42 @@ func (p *NativeProcessProvider) List(ctx context.Context) ([]ContainerInfo, erro
 	}
 	sort.Strings(slugs)
 
-	out := make([]ContainerInfo, 0, len(slugs))
-	for _, slug := range slugs {
+	out := make([]ContainerInfo, len(slugs))
+	sem := make(chan struct{}, listStatSemaphoreCap)
+	var wg sync.WaitGroup
+	for i, slug := range slugs {
 		e := reg.Entries[slug]
-		var cpu, mem float64
 		// A recorded pid counts as running only when the live process still matches the
 		// identity we persisted for it — a recycled pid (reassigned to an unrelated
 		// process after ours exited) is reported stopped, never as our service.
 		alive := p.entryIsOurs(e)
-		if alive {
-			cpu, mem, _ = p.statFn(e.PID)
-		}
 		status := "stopped"
 		if alive {
 			status = "running"
 		}
-		out = append(out, ContainerInfo{
+		info := ContainerInfo{
 			Slug:        e.Slug,
 			ContainerID: strconv.Itoa(e.PID),
 			Name:        containerName(e.Slug),
 			Image:       e.URL,
 			Status:      status,
-			CPUPercent:  cpu,
-			MemoryMB:    mem,
-		})
-	}
+		}
+		if !alive {
+			out[i] = info
+			continue
+		}
+		wg.Add(1)
+		go func(i, pid int, info ContainerInfo) {
+			defer wg.Done()
+			sem <- struct{}{}
+			defer func() { <-sem }()
+			cpu, mem, _ := p.statFn(pid)
+			info.CPUPercent = cpu
+			info.MemoryMB = mem
+			out[i] = info
+		}(i, e.PID, info)
+	}
+	wg.Wait()
 	return out, nil
 }
 
@@ -1227,7 +1251,7 @@ func (p *NativeProcessProvider) updatePID(mp *managedProcess) {
 		pid = cmd.Process.Pid
 	}
 	exePath, createTime := p.processIdentity(pid)
-	_ = p.mutateRegistry(func(reg *nativeRegistry) {
+	if err := p.mutateRegistry(func(reg *nativeRegistry) {
 		if e, ok := reg.Entries[mp.slug]; ok {
 			e.PID = pid
 			e.ExePath = exePath
@@ -1235,5 +1259,9 @@ func (p *NativeProcessProvider) updatePID(mp *managedProcess) {
 			e.StartedAt = time.Now().UTC().Format(time.RFC3339)
 			e.Desired = "running"
 		}
-	})
+	}); err != nil {
+		// Non-fatal: the process IS running. Note it in the service's own log so the
+		// respawn still succeeds; List will re-derive liveness from the pid next tick.
+		fmt.Fprintf(mp.log, "cashpilot: warning: could not persist native registry: %v\n", err)
+	}
 }
diff --git a/internal/runtime/native_test.go b/internal/runtime/native_test.go
index 4e4963d..e00eaf2 100644
--- a/internal/runtime/native_test.go
+++ b/internal/runtime/native_test.go
@@ -1001,9 +1001,14 @@ func TestNativeSupervisorStopsAfterMaxRestarts(t *testing.T) {
 	if mp == nil {
 		t.Fatal("no managed process recorded")
 	}
-	select {
-	case <-mp.doneCh:
-	case <-time.After(5 * time.Second):
+	if !waitFor(t, 15*time.Second, func() bool {
+		select {
+		case <-mp.doneCh:
+			return true
+		default:
+			return false
+		}
+	}) {
 		t.Fatal("supervisor did not stop after maxRestarts")
 	}
 
@@ -1055,9 +1060,16 @@ func TestNativeSupervisorRecordsCrashAndRestartEvents(t *testing.T) {
 	if mp == nil {
 		t.Fatal("no managed process recorded")
 	}
-	select {
-	case <-mp.doneCh:
-	case <-time.After(5 * time.Second):
+	// Generous bounded poll (not a tight 5s deadline): 3 real fork/exec restarts under
+	// -race routinely eat ~4s, so a fixed 5s ceiling flaked under sibling-test contention.
+	if !waitFor(t, 15*time.Second, func() bool {
+		select {
+		case <-mp.doneCh:
+			return true
+		default:
+			return false
+		}
+	}) {
 		t.Fatal("supervisor did not stop after maxRestarts")
 	}
 
diff --git a/internal/runtime/resource_limits.go b/internal/runtime/resource_limits.go
deleted file mode 100644
index 870172b..0000000
--- a/internal/runtime/resource_limits.go
+++ /dev/null
@@ -1,52 +0,0 @@
-package runtime
-
-import (
-	"strconv"
-	"strings"
-)
-
-// parseMemBytes converts a Docker-style memory size string to bytes. It accepts a plain
-// byte count ("1073741824") or a number with a binary unit suffix — b, k/kb, m/mb, g/gb,
-// t/tb (case-insensitive, IEC 1024-based, matching how the catalog's MemLimit/"768m" style
-// values map to Docker's HostConfig.Memory). It returns (0, false) for an empty or
-// unparseable string so callers can skip applying a limit rather than guess.
-func parseMemBytes(s string) (int64, bool) {
-	s = strings.TrimSpace(s)
-	if s == "" {
-		return 0, false
-	}
-	lower := strings.ToLower(s)
-
-	mult := int64(1)
-	// Order matters: check the two-letter suffixes before the single-letter ones.
-	switch {
-	case strings.HasSuffix(lower, "kb"):
-		mult, lower = 1<<10, strings.TrimSuffix(lower, "kb")
-	case strings.HasSuffix(lower, "mb"):
-		mult, lower = 1<<20, strings.TrimSuffix(lower, "mb")
-	case strings.HasSuffix(lower, "gb"):
-		mult, lower = 1<<30, strings.TrimSuffix(lower, "gb")
-	case strings.HasSuffix(lower, "tb"):
-		mult, lower = 1<<40, strings.TrimSuffix(lower, "tb")
-	case strings.HasSuffix(lower, "b"):
-		mult, lower = 1, strings.TrimSuffix(lower, "b")
-	case strings.HasSuffix(lower, "k"):
-		mult, lower = 1<<10, strings.TrimSuffix(lower, "k")
-	case strings.HasSuffix(lower, "m"):
-		mult, lower = 1<<20, strings.TrimSuffix(lower, "m")
-	case strings.HasSuffix(lower, "g"):
-		mult, lower = 1<<30, strings.TrimSuffix(lower, "g")
-	case strings.HasSuffix(lower, "t"):
-		mult, lower = 1<<40, strings.TrimSuffix(lower, "t")
-	}
-
-	n, err := strconv.ParseInt(strings.TrimSpace(lower), 10, 64)
-	if err != nil || n <= 0 {
-		return 0, false
-	}
-	// Guard against overflow when scaling by the unit multiplier.
-	if mult > 1 && n > (1<<62)/mult {
-		return 0, false
-	}
-	return n * mult, true
-}
diff --git a/internal/runtime/resource_limits_linux.go b/internal/runtime/resource_limits_linux.go
index ff67509..f37be47 100644
--- a/internal/runtime/resource_limits_linux.go
+++ b/internal/runtime/resource_limits_linux.go
@@ -34,7 +34,7 @@ func applyNativeResourceLimits(cmd *exec.Cmd, res catalog.ResourceLimits) {
 	if res.OomScoreAdj != nil {
 		_ = writeOomScoreAdj(pid, *res.OomScoreAdj)
 	}
-	if maxBytes, ok := parseMemBytes(res.MemLimit); ok {
+	if maxBytes, err := parseMemoryBytes(res.MemLimit); err == nil && maxBytes > 0 {
 		_ = applyCgroupMemoryMax(pid, maxBytes)
 	}
 }
diff --git a/internal/runtime/resource_limits_test.go b/internal/runtime/resource_limits_test.go
deleted file mode 100644
index 0a767ca..0000000
--- a/internal/runtime/resource_limits_test.go
+++ /dev/null
@@ -1,34 +0,0 @@
-package runtime
-
-import "testing"
-
-func TestParseMemBytes(t *testing.T) {
-	ok := map[string]int64{
-		"1073741824": 1073741824,
-		"512":        512,
-		"768m":       768 << 20,
-		"768M":       768 << 20,
-		"2g":         2 << 30,
-		"2G":         2 << 30,
-		"512mb":      512 << 20,
-		"1gb":        1 << 30,
-		"64k":        64 << 10,
-		"4kb":        4 << 10,
-		"1t":         1 << 40,
-		"1024b":      1024,
-		"  256m  ":   256 << 20, // surrounding whitespace tolerated
-	}
-	for in, want := range ok {
-		got, valid := parseMemBytes(in)
-		if !valid || got != want {
-			t.Errorf("parseMemBytes(%q) = (%d, %v), want (%d, true)", in, got, valid, want)
-		}
-	}
-
-	bad := []string{"", "   ", "abc", "m", "12x", "-5m", "0", "0g", "1.5g", "g2"}
-	for _, in := range bad {
-		if got, valid := parseMemBytes(in); valid {
-			t.Errorf("parseMemBytes(%q) = (%d, true), want invalid", in, got)
-		}
-	}
-}
diff --git a/internal/runtime/resource_limits_windows.go b/internal/runtime/resource_limits_windows.go
index 7f540af..913c19c 100644
--- a/internal/runtime/resource_limits_windows.go
+++ b/internal/runtime/resource_limits_windows.go
@@ -22,7 +22,7 @@ func applyNativeResourceLimits(cmd *exec.Cmd, res catalog.ResourceLimits) {
 	if cmd == nil || cmd.Process == nil {
 		return
 	}
-	if maxBytes, ok := parseMemBytes(res.MemLimit); ok {
+	if maxBytes, err := parseMemoryBytes(res.MemLimit); err == nil && maxBytes > 0 {
 		_ = assignProcessMemoryLimit(cmd.Process.Pid, maxBytes)
 	}
 }
diff --git a/internal/runtime/resource_limits_windows_test.go b/internal/runtime/resource_limits_windows_test.go
new file mode 100644
index 0000000..d55fd7e
--- /dev/null
+++ b/internal/runtime/resource_limits_windows_test.go
@@ -0,0 +1,31 @@
+//go:build windows
+
+package runtime
+
+import (
+	"os/exec"
+	"testing"
+
+	"github.com/GeiserX/CashPilot-Desktop/internal/catalog"
+)
+
+// TestAssignProcessMemoryLimitInvalidPidErrors exercises the Job Object path on a real
+// Windows host. This file is windows-only, so it does not run on the Linux PR CI, but
+// desktop-release.yml runs `go test ./...` on windows-latest at every tag — so this
+// executes for real there. CreateJobObject + SetInformationJobObject succeed, then
+// OpenProcess fails on a pid no process owns, so the helper returns that error. This
+// proves the Job-Object API wiring compiles + runs without capping any real process.
+func TestAssignProcessMemoryLimitInvalidPidErrors(t *testing.T) {
+	// 0x7FFFFFFF is not a live pid; OpenProcess should fail and the error propagate.
+	if err := assignProcessMemoryLimit(0x7FFFFFFF, 64<<20); err == nil {
+		t.Fatal("assignProcessMemoryLimit(invalid pid) = nil, want an error from OpenProcess")
+	}
+}
+
+// TestApplyNativeResourceLimitsNilCmdNoPanic proves the best-effort guard: a nil cmd, or a
+// not-yet-started cmd (nil Process) with a MemLimit set, is a safe no-op and never panics
+// — a limit we cannot apply must never take down the caller.
+func TestApplyNativeResourceLimitsNilCmdNoPanic(t *testing.T) {
+	applyNativeResourceLimits(nil, catalog.ResourceLimits{})
+	applyNativeResourceLimits(&exec.Cmd{}, catalog.ResourceLimits{MemLimit: "256m"})
+}
diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go
index b355fff..fe5d739 100644
--- a/internal/runtime/runtime.go
+++ b/internal/runtime/runtime.go
@@ -7,7 +7,9 @@ import (
 	"errors"
 	"fmt"
 	"io"
+	"math"
 	"os/exec"
+	"regexp"
 	goruntime "runtime"
 	"strconv"
 	"strings"
@@ -642,7 +644,14 @@ func applyResourceLimits(hostConfig *container.HostConfig, res catalog.ResourceL
 // trailing "b", e.g. "768m" or "2gb") multiplies by 1024, 1024^2, 1024^3 or
 // 1024^4. So "768m" is 768*1024*1024 = 805306368 bytes and "2g" is 2147483648. A
 // fractional mantissa ("1.5g") is allowed and truncated toward zero. It returns an
-// error for an empty string, a non-positive value, or an unparseable number.
+// error for an empty string, a non-positive value, an unparseable number, or a
+// value that would overflow a 64-bit byte count.
+//
+// This is the single canonical size parser for catalog.ResourceLimits.MemLimit /
+// MemReservation, shared by both the Docker path (this file's
+// applyResourceLimits, which fails fast on error) and the native process path
+// (resource_limits_linux.go / resource_limits_windows.go, which treat an error as
+// "skip the cap" and keep the earner running best-effort).
 func parseMemoryBytes(s string) (int64, error) {
 	raw := strings.ToLower(strings.TrimSpace(s))
 	if raw == "" {
@@ -674,7 +683,14 @@ func parseMemoryBytes(s string) (int64, error) {
 	if value <= 0 {
 		return 0, fmt.Errorf("%q must be a positive size", s)
 	}
-	return int64(value * float64(multiplier)), nil
+	// Guard against overflowing a 64-bit byte count before the float->int64
+	// conversion, which would otherwise silently produce an unspecified/wrapped
+	// result for very large mantissas or unit multipliers (e.g. "1e300t").
+	bytes := value * float64(multiplier)
+	if bytes > math.MaxInt64 {
+		return 0, fmt.Errorf("%q overflows a 64-bit byte count", s)
+	}
+	return int64(bytes), nil
 }
 
 func managedContainerVolumes(ctx context.Context, cli *client.Client, name string) ([]string, error) {
@@ -698,12 +714,27 @@ func isNamedVolume(source string) bool {
 	return source != "" && !strings.HasPrefix(source, "/") && !strings.HasPrefix(source, ".") && !strings.HasPrefix(source, "~")
 }
 
+// substituteVarPattern matches a single ${VAR} placeholder. Compiled once at package
+// scope (rather than per substitute call) since it never changes between calls.
+var substituteVarPattern = regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*)\}`)
+
+// substitute expands every ${VAR} placeholder in value using env, in a single pass
+// over the ORIGINAL string. This is deliberate: env is a map, so ranging over it
+// iterates in random order, and repeatedly doing strings.ReplaceAll over an
+// accumulating result would let a substituted value that itself contains a literal
+// "${OTHER}" get re-expanded on a later iteration — a correctness bug whose outcome
+// depended on map iteration order. A single regexp pass expands each placeholder
+// exactly once from the original text, so a replacement value is never re-scanned.
+// A placeholder naming a var not present in env is left unchanged (matches prior
+// ReplaceAll behavior, which only ever touched placeholders for keys present in env).
 func substitute(value string, env map[string]string) string {
-	out := value
-	for key, val := range env {
-		out = strings.ReplaceAll(out, "${"+key+"}", val)
-	}
-	return out
+	return substituteVarPattern.ReplaceAllStringFunc(value, func(match string) string {
+		name := match[2 : len(match)-1] // strip "${" and "}"
+		if val, ok := env[name]; ok {
+			return val
+		}
+		return match
+	})
 }
 
 // buildCommandArgs turns a maintainer command template into the argv slice passed to
diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go
index 750c5a6..72ede27 100644
--- a/internal/runtime/runtime_test.go
+++ b/internal/runtime/runtime_test.go
@@ -690,31 +690,50 @@ func oomPtr(i int) *int { return &i }
 // docker.resources mem_limit/mem_reservation strings: k/m/g/t are 1024-based (so
 // "768m" is 768 MiB, not 768 MB), an optional trailing "b", surrounding
 // whitespace, mixed case and a fractional mantissa are accepted, and
-// empty/non-positive/garbage values are rejected so a bad service definition fails
-// fast instead of deploying an earner unbounded.
+// empty/non-positive/garbage/overflowing values are rejected so a bad service
+// definition fails fast instead of deploying an earner unbounded.
+//
+// This is the single canonical parser for both the Docker path (applyResourceLimits,
+// which fails fast on error) and the native process path (resource_limits_linux.go /
+// resource_limits_windows.go, which treat an error as "skip the cap" and run the
+// earner best-effort uncapped) — so this test also covers the size grammar the
+// native cgroup/Job-Object callers rely on (formerly a separate parseMemBytes in
+// resource_limits.go / resource_limits_test.go, now unified here).
 func TestParseMemoryBytes(t *testing.T) {
 	cases := []struct {
 		in      string
 		want    int64
 		wantErr bool
 	}{
-		{in: "768m", want: 768 * 1024 * 1024},     // mysterium
-		{in: "2g", want: 2 * 1024 * 1024 * 1024},  // storj
-		{in: "1536m", want: 1536 * 1024 * 1024},   // anyone-protocol
-		{in: "256m", want: 256 * 1024 * 1024},     // honeygain/earnapp/proxyrack
-		{in: "128m", want: 128 * 1024 * 1024},     // expendable earners
-		{in: "512k", want: 512 * 1024},            // kibibytes
-		{in: "1024", want: 1024},                  // bare byte count, no suffix
-		{in: "2gb", want: 2 * 1024 * 1024 * 1024}, // explicit trailing "b"
-		{in: "768M", want: 768 * 1024 * 1024},     // case-insensitive suffix
-		{in: " 256m ", want: 256 * 1024 * 1024},   // surrounding whitespace
-		{in: "1.5g", want: 1536 * 1024 * 1024},    // fractional mantissa
-		{in: "", wantErr: true},                   // empty
-		{in: "b", wantErr: true},                  // unit only, no number
-		{in: "m", wantErr: true},                  // unit only, no number
-		{in: "abc", wantErr: true},                // not a number
-		{in: "0m", wantErr: true},                 // non-positive
-		{in: "-5m", wantErr: true},                // negative
+		{in: "768m", want: 768 * 1024 * 1024},        // mysterium
+		{in: "2g", want: 2 * 1024 * 1024 * 1024},     // storj
+		{in: "1536m", want: 1536 * 1024 * 1024},      // anyone-protocol
+		{in: "256m", want: 256 * 1024 * 1024},        // honeygain/earnapp/proxyrack
+		{in: "128m", want: 128 * 1024 * 1024},        // expendable earners
+		{in: "512k", want: 512 * 1024},               // kibibytes
+		{in: "1024", want: 1024},                     // bare byte count, no suffix
+		{in: "1024b", want: 1024},                    // bare byte count, explicit trailing "b"
+		{in: "2gb", want: 2 * 1024 * 1024 * 1024},    // explicit trailing "b"
+		{in: "1gb", want: 1024 * 1024 * 1024},        // explicit trailing "b"
+		{in: "512mb", want: 512 * 1024 * 1024},       // explicit trailing "b"
+		{in: "4kb", want: 4 * 1024},                  // explicit trailing "b"
+		{in: "1t", want: 1024 * 1024 * 1024 * 1024},  // tebibytes
+		{in: "1tb", want: 1024 * 1024 * 1024 * 1024}, // tebibytes, explicit trailing "b"
+		{in: "768M", want: 768 * 1024 * 1024},        // case-insensitive suffix
+		{in: " 256m ", want: 256 * 1024 * 1024},      // surrounding whitespace
+		{in: "1.5g", want: 1536 * 1024 * 1024},       // fractional mantissa
+		{in: "", wantErr: true},                      // empty
+		{in: "   ", wantErr: true},                   // whitespace only
+		{in: "b", wantErr: true},                      // unit only, no number
+		{in: "m", wantErr: true},                      // unit only, no number
+		{in: "abc", wantErr: true},                    // not a number
+		{in: "12x", wantErr: true},                    // trailing character isn't a known unit
+		{in: "g2", wantErr: true},                     // unit-like character isn't trailing
+		{in: "0", wantErr: true},                      // non-positive
+		{in: "0m", wantErr: true},                      // non-positive
+		{in: "0g", wantErr: true},                      // non-positive
+		{in: "-5m", wantErr: true},                     // negative
+		{in: "100000000000000000000000t", wantErr: true}, // overflows a 64-bit byte count
 	}
 	for _, tc := range cases {
 		got, err := parseMemoryBytes(tc.in)
diff --git a/internal/store/store.go b/internal/store/store.go
index 95f2eb3..ec799f7 100644
--- a/internal/store/store.go
+++ b/internal/store/store.go
@@ -9,6 +9,7 @@ import (
 	"encoding/json"
 	"fmt"
 	"io"
+	"log"
 	"math"
 	"os"
 	"path/filepath"
@@ -299,7 +300,9 @@ func (s *Store) ListDeployments() []Deployment {
 }
 
 func (s *Store) RecordEvent(slug, event, detail string) {
-	_, _ = s.db.Exec(`INSERT INTO runtime_events(slug, event, detail, created_at) VALUES(?, ?, ?, datetime('now'))`, slug, event, detail)
+	if _, err := s.db.Exec(`INSERT INTO runtime_events(slug, event, detail, created_at) VALUES(?, ?, ?, datetime('now'))`, slug, event, detail); err != nil {
+		log.Printf("store: record event %s/%s: %v", slug, event, err)
+	}
 }
 
 func (s *Store) SaveEarnings(record EarningsRecord) (EarningsRecord, error) {
@@ -621,6 +624,7 @@ func (s *Store) ListFleetDevices() []FleetDevice {
 		var device FleetDevice
 		var servicesRaw string
 		if err := rows.Scan(&device.ID, &device.Name, &device.Kind, &device.Endpoint, &device.OS, &device.Arch, &device.Status, &servicesRaw, &device.LastSeen, &device.CreatedAt); err == nil {
+			// best-effort: a corrupt services blob just yields no badges
 			_ = json.Unmarshal([]byte(servicesRaw), &device.Services)
 			out = append(out, device)
 		}
@@ -741,6 +745,7 @@ func (s *Store) migrate() error {
 			error TEXT NOT NULL DEFAULT '',
 			created_at TEXT NOT NULL
 		);
+		CREATE INDEX IF NOT EXISTS idx_earnings_platform_created ON earnings(platform, created_at);
 		CREATE TABLE IF NOT EXISTS runtime_events (
 			id INTEGER PRIMARY KEY AUTOINCREMENT,
 			slug TEXT NOT NULL,
diff --git a/metrics.go b/metrics.go
index 67ccbe8..9173daa 100644
--- a/metrics.go
+++ b/metrics.go
@@ -70,7 +70,12 @@ func (a *App) renderMetrics() string {
 	// delta math is never duplicated. Values are in the configured display currency
 	// (USD by default); computeEarningsSummary is internally nil-guarded and returns
 	// an empty (all-zero) summary when its dependencies are not wired.
-	summary := a.computeEarningsSummary()
+	var summary EarningsSummary
+	if a.store != nil {
+		summary = a.computeEarningsSummary(a.store.ListLatestEarnings())
+	} else {
+		summary = a.computeEarningsSummary(nil)
+	}
 	mw.family("cashpilot_earnings_usd_total", "Total earnings across all platforms, in the display currency (USD by default).")
 	mw.sample("cashpilot_earnings_usd_total", summary.Total)
 	mw.family("cashpilot_earnings_usd_today", "Earnings accrued today, in the display currency (USD by default).")