From 51b24002aa5395eb8766f03ef8bdaa3fe2b600af Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Wed, 22 Jul 2026 18:59:27 +0800 Subject: [PATCH] feat: prune Cargo target caches, speed relay rebuilds, and relieve sync quotas Keep desktop target caches latest-only after dev/build, reuse BuildKit Cargo mounts for relay redeploys, auto-evict oldest cloud session backups on quota pressure, surface clearer sync errors, and temporarily hide the Pages nav entry. --- .dockerignore | 4 + .../2026-07-22-cargo-target-latest-only-gc.md | 39 ++ ...7-22-cargo-target-latest-only-gc-design.md | 56 +++ package.json | 1 + scripts/cargo-target-gc.mjs | 453 ++++++++++++++++++ scripts/cargo-target-gc.test.mjs | 174 +++++++ scripts/desktop-tauri-build.mjs | 14 + scripts/dev.cjs | 75 ++- src/apps/cli/src/account_sync.rs | 31 +- src/apps/desktop/AGENTS-CN.md | 4 + src/apps/desktop/AGENTS.md | 4 + .../desktop/src/api/remote_connect_api.rs | 130 +++-- src/apps/relay-server/Cargo.toml | 2 +- src/apps/relay-server/Dockerfile | 71 +-- src/apps/relay-server/README.md | 5 + src/apps/relay-server/deploy.sh | 9 +- src/crates/services/relay-service/Cargo.toml | 4 +- src/crates/services/relay-service/src/db.rs | 102 +++- .../services/relay-service/src/routes/sync.rs | 41 +- .../src/remote_connect/account.rs | 83 ++++ .../src/remote_ssh/relay_deploy.rs | 18 +- .../src/app/components/NavPanel/MainNav.tsx | 7 +- .../RemoteConnectDialog/AccountPanel.tsx | 38 +- .../src/features/relay-deploy/README.md | 3 +- src/web-ui/src/locales/en-US/common.json | 4 +- src/web-ui/src/locales/zh-CN/common.json | 4 +- src/web-ui/src/locales/zh-TW/common.json | 4 +- 27 files changed, 1254 insertions(+), 126 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-22-cargo-target-latest-only-gc.md create mode 100644 docs/superpowers/specs/2026-07-22-cargo-target-latest-only-gc-design.md create mode 100644 scripts/cargo-target-gc.mjs create mode 100644 scripts/cargo-target-gc.test.mjs diff --git a/.dockerignore b/.dockerignore index 3ef58448fd..7e46665a23 100644 --- a/.dockerignore +++ b/.dockerignore @@ -12,3 +12,7 @@ !src/crates/services/relay-service/Cargo.toml !src/crates/services/relay-service/src/ !src/crates/services/relay-service/src/** +!src/crates/services/page-function-runtime/ +!src/crates/services/page-function-runtime/Cargo.toml +!src/crates/services/page-function-runtime/src/ +!src/crates/services/page-function-runtime/src/** diff --git a/docs/superpowers/plans/2026-07-22-cargo-target-latest-only-gc.md b/docs/superpowers/plans/2026-07-22-cargo-target-latest-only-gc.md new file mode 100644 index 0000000000..c3152e58fd --- /dev/null +++ b/docs/superpowers/plans/2026-07-22-cargo-target-latest-only-gc.md @@ -0,0 +1,39 @@ +# Cargo Target Latest-Only GC Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** On desktop:dev exit and desktop:build end, prune Cargo target caches so only the latest useful artifacts remain for the active profile. + +**Architecture:** A shared Node module `scripts/cargo-target-gc.mjs` owns prune logic and CLI. `scripts/dev.cjs` and `scripts/desktop-tauri-build.mjs` call it best-effort after the session/build ends. + +**Tech Stack:** Node.js (`node:test`, `fs`, `path`, `child_process`) + +## File map + +| File | Responsibility | +|---|---| +| `scripts/cargo-target-gc.mjs` | Prune incremental / fingerprint / deps; CLI + exported API | +| `scripts/cargo-target-gc.test.mjs` | Fixture tests for keep-latest rules | +| `scripts/dev.cjs` | Call GC after desktop / preview exit | +| `scripts/desktop-tauri-build.mjs` | Call GC after tauri build | +| `package.json` | `target:gc` script | +| `src/apps/desktop/AGENTS.md` (+ CN) | One short note on GC behavior | + +## Tasks + +### Task 1: Core GC module + tests + +- [x] Write failing tests for incremental keep-latest and fingerprint/deps orphan cleanup +- [x] Implement `scripts/cargo-target-gc.mjs` +- [x] Pass `node --test scripts/cargo-target-gc.test.mjs` + +### Task 2: Wire desktop entrypoints + +- [x] Hook `dev.cjs` desktop + preview shutdown/`finally` +- [x] Hook `desktop-tauri-build.mjs` after build +- [x] Add `pnpm run target:gc` + +### Task 3: Docs + verify + +- [x] Brief AGENTS note +- [x] Re-run unit tests; dry-run against real target if present diff --git a/docs/superpowers/specs/2026-07-22-cargo-target-latest-only-gc-design.md b/docs/superpowers/specs/2026-07-22-cargo-target-latest-only-gc-design.md new file mode 100644 index 0000000000..6690564baa --- /dev/null +++ b/docs/superpowers/specs/2026-07-22-cargo-target-latest-only-gc-design.md @@ -0,0 +1,56 @@ +# Cargo Target Latest-Only GC + +**Date:** 2026-07-22 +**Status:** Approved (timing: exit of `desktop:dev` / end of `desktop:build`) + +## Problem + +`target/debug` grows without bound across `pnpm run desktop:dev` / build sessions (observed ~139GB). Growth is dominated by: + +1. Multiple `incremental/-/` roots for the same crate (rustc only GCs sessions *inside* one hash root). +2. Stale `.fingerprint` / `deps` artifacts left after feature or unit-graph changes. + +## Goal + +After a desktop dev session exits or a desktop build finishes, keep only the latest *useful* cache for the active profile so disk usage stops ratcheting upward, without `cargo clean` and without disabling incremental compilation. + +## Non-goals + +- Changing default `profile.dev` debuginfo (optional later). +- GC on every incremental rebuild during a live `tauri dev` session. +- Guaranteeing a single hash per third-party crate when Cargo legitimately needs two units (lib vs build-dep). + +## Design + +### Trigger (option B) + +| Entry | When GC runs | +|---|---| +| `pnpm run desktop:dev` | After `tauri dev` exits (including Ctrl+C), in a `finally` path | +| `pnpm run desktop:preview:debug` | On preview shutdown | +| `pnpm run desktop:build*` (`scripts/desktop-tauri-build.mjs`) | After `tauri build` returns (success or fail; GC is best-effort) | +| `pnpm run target:gc` | Manual | + +Skip GC when `BITFUN_TARGET_GC=0`. Dry-run when `BITFUN_TARGET_GC_DRY_RUN=1`. + +Skip when another `cargo` / `rustc` process still appears active (avoid deleting in-use artifacts). + +### What is pruned + +For `target///` (default host triple omitted; profile `debug` for dev, build profile from argv): + +1. **incremental** — group directories by crate prefix (name before final `-`); keep the newest mtime; delete older roots. Inside a kept root, keep the newest finalized `s-*` session when multiple remain. +2. **.fingerprint** — group by package stem; keep newest **1** for `bitfun_*` / workspace-app stems, newest **2** for other packages (build-dep dual units). Delete older groups. +3. **deps** — delete artifacts whose trailing hash no longer appears in any remaining fingerprint directory name. Do not delete by “crate name latest only” (unsafe for dual feature units). + +### Safety + +- Never delete the profile root or final binaries by name (`bitfun-desktop`, `.app`, etc.) except via normal cargo replacement. +- GC failures must not fail the user command (log and continue). +- No dependency on `cargo-sweep` (macOS atime is unreliable). + +## Verification + +- Unit tests for grouping / keep-latest / deps orphan deletion on a temp fixture. +- `node --test scripts/cargo-target-gc.test.mjs` +- Manual: run GC dry-run against real `target/debug`, confirm incremental crate counts drop to 1 per prefix without requiring a full clean rebuild afterward for desktop:dev. diff --git a/package.json b/package.json index a64b51543c..44292efac6 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "desktop:dev": "node scripts/dev.cjs desktop", "desktop:preview:debug": "node scripts/dev.cjs desktop-preview", "desktop:dev:raw": "cross-env-shell CI=true \"cd src/apps/desktop && tauri dev\"", + "target:gc": "node scripts/cargo-target-gc.mjs", "desktop:build": "node scripts/desktop-tauri-build.mjs", "desktop:build:fast": "node scripts/desktop-tauri-build.mjs --debug --no-bundle", "desktop:build:release-fast": "node scripts/desktop-tauri-build.mjs --no-bundle -- --profile release-fast --features devtools", diff --git a/scripts/cargo-target-gc.mjs b/scripts/cargo-target-gc.mjs new file mode 100644 index 0000000000..6917fd535b --- /dev/null +++ b/scripts/cargo-target-gc.mjs @@ -0,0 +1,453 @@ +#!/usr/bin/env node +/** + * Prune Cargo target caches so each crate/package keeps only the latest useful + * artifacts for a profile. Used after desktop:dev exits and desktop:build ends. + * + * Env: + * BITFUN_TARGET_GC=0 disable + * BITFUN_TARGET_GC_DRY_RUN=1 report only + */ +import { execFileSync } from 'node:child_process'; +import { + existsSync, + lstatSync, + readdirSync, + rmSync, + statSync, +} from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const DEFAULT_ROOT = join(__dirname, '..'); +const FINGERPRINT_HASH_RE = /^(.+)-([0-9a-f]{16})$/; +const DEPS_HASH_RE = /^.+?-([0-9a-f]{16})(?:[.-]|$)/; +const SESSION_DIR_RE = /^s-/; + +export function splitIncrementalCrateDir(name) { + const idx = name.lastIndexOf('-'); + if (idx <= 0 || idx === name.length - 1) { + return null; + } + return { + crate: name.slice(0, idx), + hash: name.slice(idx + 1), + }; +} + +export function splitFingerprintDir(name) { + const match = FINGERPRINT_HASH_RE.exec(name); + if (!match) { + return null; + } + return { + stem: match[1], + hash: match[2], + }; +} + +export function extractDepsArtifactHash(filename) { + const match = DEPS_HASH_RE.exec(filename); + return match ? match[1] : null; +} + +export function fingerprintKeepCount(stem) { + if (stem.startsWith('bitfun-') || stem.startsWith('bitfun_')) { + return 1; + } + // Third-party crates may need lib + build-dep units with different hashes. + return 2; +} + +function safeStatMtimeMs(path) { + try { + return statSync(path).mtimeMs; + } catch { + return 0; + } +} + +function listDirs(dir) { + if (!existsSync(dir)) { + return []; + } + return readdirSync(dir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && !entry.isSymbolicLink()) + .map((entry) => entry.name); +} + +function listFiles(dir) { + if (!existsSync(dir)) { + return []; + } + return readdirSync(dir, { withFileTypes: true }) + .filter((entry) => entry.isFile() && !entry.isSymbolicLink()) + .map((entry) => entry.name); +} + +function removePath(path, dryRun, removed) { + removed.push(path); + if (!dryRun) { + rmSync(path, { force: true, recursive: true }); + } +} + +/** + * Keep the newest `keep` entries by mtime; return paths to delete. + */ +export function selectStaleByMtime(entries, keep) { + if (keep < 1) { + throw new Error('keep must be >= 1'); + } + if (entries.length <= keep) { + return []; + } + const sorted = [...entries].sort((a, b) => b.mtimeMs - a.mtimeMs); + return sorted.slice(keep).map((entry) => entry.path); +} + +export function planIncrementalPrune(incrementalDir, { keepSessions = 1 } = {}) { + const toDelete = []; + const groups = new Map(); + + for (const name of listDirs(incrementalDir)) { + const split = splitIncrementalCrateDir(name); + if (!split) { + continue; + } + const path = join(incrementalDir, name); + const list = groups.get(split.crate) || []; + list.push({ path, mtimeMs: safeStatMtimeMs(path), name }); + groups.set(split.crate, list); + } + + for (const entries of groups.values()) { + toDelete.push(...selectStaleByMtime(entries, 1)); + } + + const keptRoots = listDirs(incrementalDir) + .map((name) => join(incrementalDir, name)) + .filter((path) => !toDelete.includes(path)); + + for (const root of keptRoots) { + const sessions = listDirs(root) + .filter((name) => SESSION_DIR_RE.test(name) && !name.endsWith('-working')) + .map((name) => ({ + path: join(root, name), + mtimeMs: safeStatMtimeMs(join(root, name)), + })); + toDelete.push(...selectStaleByMtime(sessions, keepSessions)); + + // Drop abandoned working dirs older than a few seconds if unlocked leftovers remain. + for (const name of listDirs(root)) { + if (!name.endsWith('-working')) { + continue; + } + const path = join(root, name); + if (Date.now() - safeStatMtimeMs(path) > 60_000) { + toDelete.push(path); + } + } + } + + return toDelete; +} + +export function planFingerprintPrune(fingerprintDir) { + const toDelete = []; + const groups = new Map(); + + for (const name of listDirs(fingerprintDir)) { + const split = splitFingerprintDir(name); + if (!split) { + continue; + } + const path = join(fingerprintDir, name); + const list = groups.get(split.stem) || []; + list.push({ + path, + mtimeMs: safeStatMtimeMs(path), + hash: split.hash, + stem: split.stem, + }); + groups.set(split.stem, list); + } + + const keptHashes = new Set(); + for (const [stem, entries] of groups.entries()) { + const keep = fingerprintKeepCount(stem); + const sorted = [...entries].sort((a, b) => b.mtimeMs - a.mtimeMs); + for (const entry of sorted.slice(0, keep)) { + keptHashes.add(entry.hash); + } + for (const entry of sorted.slice(keep)) { + toDelete.push(entry.path); + } + } + + return { toDelete, keptHashes }; +} + +export function planDepsOrphanPrune(depsDir, keptHashes) { + const toDelete = []; + for (const name of listFiles(depsDir)) { + const hash = extractDepsArtifactHash(name); + if (!hash) { + continue; + } + if (!keptHashes.has(hash)) { + toDelete.push(join(depsDir, name)); + } + } + // Also remove empty-looking unit directories if any exist under deps. + for (const name of listDirs(depsDir)) { + const hash = extractDepsArtifactHash(name); + if (hash && !keptHashes.has(hash)) { + toDelete.push(join(depsDir, name)); + } + } + return toDelete; +} + +export function resolveProfileDir(targetDir, { profile = 'debug', triple = null } = {}) { + if (triple) { + return join(targetDir, triple, profile); + } + return join(targetDir, profile); +} + +function sleepMs(ms) { + if (process.platform === 'win32') { + // ping waits about 1s per iteration with -n 2; good enough for retry backoff. + try { + execFileSync('ping', ['127.0.0.1', '-n', '2'], { stdio: 'ignore' }); + } catch { + // ignore + } + return; + } + try { + execFileSync('sleep', [String(Math.max(0.1, ms / 1000))], { stdio: 'ignore' }); + } catch { + // ignore + } +} + +export function isCompilerBusy({ exec = execFileSync, platform = process.platform } = {}) { + try { + if (platform === 'win32') { + const out = exec( + 'cmd.exe', + ['/d', '/s', '/c', 'tasklist /FI "IMAGENAME eq cargo.exe" & tasklist /FI "IMAGENAME eq rustc.exe"'], + { encoding: 'utf8' } + ); + return /\bcargo\.exe\b/i.test(out) || /\brustc\.exe\b/i.test(out); + } + const cargo = exec('pgrep', ['-x', 'cargo'], { encoding: 'utf8' }).trim(); + if (cargo) { + return true; + } + } catch { + // pgrep exit 1 => no match + } + try { + if (platform !== 'win32') { + const rustc = exec('pgrep', ['-x', 'rustc'], { encoding: 'utf8' }).trim(); + return Boolean(rustc); + } + } catch { + // no rustc + } + return false; +} + +export function collectGcPlan(profileDir) { + const incrementalDir = join(profileDir, 'incremental'); + const fingerprintDir = join(profileDir, '.fingerprint'); + const depsDir = join(profileDir, 'deps'); + + const incremental = planIncrementalPrune(incrementalDir); + const fingerprintPlan = planFingerprintPrune(fingerprintDir); + const deps = planDepsOrphanPrune(depsDir, fingerprintPlan.keptHashes); + + return { + incremental, + fingerprint: fingerprintPlan.toDelete, + deps, + all: [...incremental, ...fingerprintPlan.toDelete, ...deps], + }; +} + +export function runCargoTargetGc(options = {}) { + const { + rootDir = DEFAULT_ROOT, + targetDir = process.env.CARGO_TARGET_DIR + ? resolve(rootDir, process.env.CARGO_TARGET_DIR) + : join(rootDir, 'target'), + profile = 'debug', + triple = null, + dryRun = ['1', 'true', 'yes'].includes( + String(process.env.BITFUN_TARGET_GC_DRY_RUN || options.dryRun || '').toLowerCase() + ), + enabled = !['0', 'false', 'no'].includes( + String(process.env.BITFUN_TARGET_GC ?? '1').toLowerCase() + ), + skipIfBusy = true, + logger = console, + } = options; + + if (!enabled) { + return { skipped: true, reason: 'disabled', removed: [] }; + } + + if (skipIfBusy) { + const busyDeadline = Date.now() + 15_000; + while (isCompilerBusy()) { + if (Date.now() >= busyDeadline) { + logger.info?.('[target-gc] Skipping: cargo/rustc still running'); + return { skipped: true, reason: 'compiler-busy', removed: [] }; + } + sleepMs(500); + } + } + + const profileDir = resolveProfileDir(targetDir, { profile, triple }); + if (!existsSync(profileDir)) { + return { skipped: true, reason: 'missing-profile-dir', removed: [], profileDir }; + } + + // Refuse to operate on unexpected paths. + try { + if (!lstatSync(profileDir).isDirectory()) { + return { skipped: true, reason: 'not-a-directory', removed: [], profileDir }; + } + } catch { + return { skipped: true, reason: 'stat-failed', removed: [], profileDir }; + } + + const plan = collectGcPlan(profileDir); + const removed = []; + for (const path of plan.all) { + try { + removePath(path, dryRun, removed); + } catch (error) { + logger.warn?.( + `[target-gc] Failed to remove ${path}: ${error.message || String(error)}` + ); + } + } + + const summary = { + skipped: false, + dryRun, + profileDir, + removed, + counts: { + incremental: plan.incremental.length, + fingerprint: plan.fingerprint.length, + deps: plan.deps.length, + total: plan.all.length, + }, + }; + + if (summary.counts.total > 0) { + logger.info?.( + `[target-gc] ${dryRun ? 'Would remove' : 'Removed'} ${summary.counts.total} stale cache path(s) ` + + `(incremental=${summary.counts.incremental}, fingerprint=${summary.counts.fingerprint}, deps=${summary.counts.deps}) ` + + `under ${profileDir}` + ); + } else { + logger.info?.(`[target-gc] No stale cache paths under ${profileDir}`); + } + + return summary; +} + +export function parseGcArgs(argv) { + const args = { profile: 'debug', triple: null, dryRun: false, help: false }; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === '--help' || arg === '-h') { + args.help = true; + } else if (arg === '--dry-run') { + args.dryRun = true; + } else if (arg === '--profile') { + args.profile = argv[i + 1] || args.profile; + i += 1; + } else if (arg.startsWith('--profile=')) { + args.profile = arg.slice('--profile='.length); + } else if (arg === '--target') { + args.triple = argv[i + 1] || null; + i += 1; + } else if (arg.startsWith('--target=')) { + args.triple = arg.slice('--target='.length); + } + } + return args; +} + +function printHelp() { + console.log(`Usage: node scripts/cargo-target-gc.mjs [--profile debug] [--target TRIPLE] [--dry-run] + +Prune stale Cargo incremental / fingerprint / deps caches for one profile. + +Environment: + BITFUN_TARGET_GC=0 disable + BITFUN_TARGET_GC_DRY_RUN=1 dry-run +`); +} + +export function profileFromTauriBuildArgs(args) { + if (args.includes('--debug')) { + return 'debug'; + } + const inline = args.find((arg) => arg.startsWith('--profile=')); + if (inline) { + return inline.slice('--profile='.length); + } + const idx = args.indexOf('--profile'); + if (idx >= 0 && args[idx + 1]) { + return args[idx + 1]; + } + return 'release'; +} + +export function targetFromTauriBuildArgs(args) { + const inline = args.find((arg) => arg.startsWith('--target=')); + if (inline) { + return inline.slice('--target='.length); + } + const idx = args.indexOf('--target'); + if (idx >= 0 && args[idx + 1]) { + return args[idx + 1]; + } + return null; +} + +export function runGcBestEffort(options = {}) { + try { + return runCargoTargetGc(options); + } catch (error) { + const logger = options.logger || console; + logger.warn?.( + `[target-gc] Skipped due to error: ${error.message || String(error)}` + ); + return { skipped: true, reason: 'error', error, removed: [] }; + } +} + +const isMain = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url); + +if (isMain) { + const args = parseGcArgs(process.argv.slice(2)); + if (args.help) { + printHelp(); + process.exit(0); + } + const result = runCargoTargetGc({ + profile: args.profile, + triple: args.triple, + dryRun: args.dryRun, + }); + process.exit(result.skipped && result.reason === 'error' ? 1 : 0); +} diff --git a/scripts/cargo-target-gc.test.mjs b/scripts/cargo-target-gc.test.mjs new file mode 100644 index 0000000000..46a4eaee4b --- /dev/null +++ b/scripts/cargo-target-gc.test.mjs @@ -0,0 +1,174 @@ +import assert from 'node:assert/strict'; +import { existsSync, mkdirSync, rmSync, utimesSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import test from 'node:test'; +import { + collectGcPlan, + extractDepsArtifactHash, + fingerprintKeepCount, + profileFromTauriBuildArgs, + runCargoTargetGc, + selectStaleByMtime, + splitFingerprintDir, + splitIncrementalCrateDir, + targetFromTauriBuildArgs, +} from './cargo-target-gc.mjs'; + +function fixtureRoot() { + const root = join(tmpdir(), `bitfun-target-gc-${process.pid}-${Date.now()}`); + mkdirSync(root, { recursive: true }); + return { + root, + cleanup: () => rmSync(root, { force: true, recursive: true }), + }; +} + +function touchDir(path, mtimeMs) { + mkdirSync(path, { recursive: true }); + const date = new Date(mtimeMs); + utimesSync(path, date, date); +} + +function touchFile(path, mtimeMs) { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, 'x'); + const date = new Date(mtimeMs); + utimesSync(path, date, date); +} + +test('split helpers parse cargo cache names', () => { + assert.deepEqual(splitIncrementalCrateDir('bitfun_core-3vwcc7dt79hqo'), { + crate: 'bitfun_core', + hash: '3vwcc7dt79hqo', + }); + assert.deepEqual(splitFingerprintDir('aes-gcm-150676ea617fdfd1'), { + stem: 'aes-gcm', + hash: '150676ea617fdfd1', + }); + assert.equal(extractDepsArtifactHash('libsyn-0ddb3bc374064a9b.rlib'), '0ddb3bc374064a9b'); + assert.equal( + extractDepsArtifactHash('bitfun_core-023db5b6b08d0150.02dradpmwvb53dgn1eck0186y.rcgu.o'), + '023db5b6b08d0150' + ); + assert.equal(fingerprintKeepCount('bitfun-core'), 1); + assert.equal(fingerprintKeepCount('syn'), 2); +}); + +test('selectStaleByMtime keeps newest entries', () => { + const stale = selectStaleByMtime( + [ + { path: 'a', mtimeMs: 1 }, + { path: 'b', mtimeMs: 3 }, + { path: 'c', mtimeMs: 2 }, + ], + 1 + ); + assert.deepEqual(new Set(stale), new Set(['a', 'c'])); +}); + +test('collectGcPlan keeps latest incremental and fingerprint caches', () => { + const { root, cleanup } = fixtureRoot(); + try { + const profileDir = join(root, 'debug'); + const now = Date.now(); + + touchDir(join(profileDir, 'incremental', 'bitfun_core-oldhash1'), now - 3_000); + touchDir(join(profileDir, 'incremental', 'bitfun_core-newhash2'), now); + touchDir( + join(profileDir, 'incremental', 'bitfun_core-newhash2', 's-old-session'), + now - 2_000 + ); + touchDir( + join(profileDir, 'incremental', 'bitfun_core-newhash2', 's-new-session'), + now + ); + + touchDir(join(profileDir, '.fingerprint', 'bitfun-core-aaaaaaaaaaaaaaaa'), now - 3_000); + touchDir(join(profileDir, '.fingerprint', 'bitfun-core-bbbbbbbbbbbbbbbb'), now); + touchDir(join(profileDir, '.fingerprint', 'syn-1111111111111111'), now - 4_000); + touchDir(join(profileDir, '.fingerprint', 'syn-2222222222222222'), now - 2_000); + touchDir(join(profileDir, '.fingerprint', 'syn-3333333333333333'), now); + + touchFile(join(profileDir, 'deps', 'libbitfun_core-aaaaaaaaaaaaaaaa.rlib'), now - 3_000); + touchFile(join(profileDir, 'deps', 'libbitfun_core-bbbbbbbbbbbbbbbb.rlib'), now); + touchFile(join(profileDir, 'deps', 'libsyn-1111111111111111.rlib'), now - 4_000); + touchFile(join(profileDir, 'deps', 'libsyn-2222222222222222.rlib'), now - 2_000); + touchFile(join(profileDir, 'deps', 'libsyn-3333333333333333.rlib'), now); + + const plan = collectGcPlan(profileDir); + + assert.ok(plan.incremental.some((path) => path.endsWith('bitfun_core-oldhash1'))); + assert.ok(plan.incremental.some((path) => path.includes(`${join('bitfun_core-newhash2', 's-old-session')}`))); + assert.ok( + plan.fingerprint.some((path) => path.endsWith('bitfun-core-aaaaaaaaaaaaaaaa')) + ); + assert.ok(plan.fingerprint.some((path) => path.endsWith('syn-1111111111111111'))); + assert.ok(!plan.fingerprint.some((path) => path.endsWith('syn-2222222222222222'))); + assert.ok(!plan.fingerprint.some((path) => path.endsWith('syn-3333333333333333'))); + + assert.ok(plan.deps.some((path) => path.endsWith('libbitfun_core-aaaaaaaaaaaaaaaa.rlib'))); + assert.ok(plan.deps.some((path) => path.endsWith('libsyn-1111111111111111.rlib'))); + assert.ok(!plan.deps.some((path) => path.endsWith('libsyn-2222222222222222.rlib'))); + assert.ok(!plan.deps.some((path) => path.endsWith('libbitfun_core-bbbbbbbbbbbbbbbb.rlib'))); + } finally { + cleanup(); + } +}); + +test('runCargoTargetGc removes planned paths and respects dry-run', () => { + const { root, cleanup } = fixtureRoot(); + try { + const targetDir = join(root, 'target'); + const profileDir = join(targetDir, 'debug'); + const now = Date.now(); + touchDir(join(profileDir, 'incremental', 'bitfun_demo-old'), now - 1_000); + touchDir(join(profileDir, 'incremental', 'bitfun_demo-new'), now); + touchDir(join(profileDir, '.fingerprint', 'bitfun-demo-aaaaaaaaaaaaaaaa'), now - 1_000); + touchDir(join(profileDir, '.fingerprint', 'bitfun-demo-bbbbbbbbbbbbbbbb'), now); + touchFile(join(profileDir, 'deps', 'libbitfun_demo-aaaaaaaaaaaaaaaa.rlib'), now - 1_000); + touchFile(join(profileDir, 'deps', 'libbitfun_demo-bbbbbbbbbbbbbbbb.rlib'), now); + + const dry = runCargoTargetGc({ + rootDir: root, + targetDir, + profile: 'debug', + dryRun: true, + skipIfBusy: false, + logger: { info() {}, warn() {} }, + }); + assert.equal(dry.dryRun, true); + assert.ok(dry.counts.total >= 2); + assert.ok(existsSync(join(profileDir, 'incremental', 'bitfun_demo-old'))); + + const live = runCargoTargetGc({ + rootDir: root, + targetDir, + profile: 'debug', + dryRun: false, + skipIfBusy: false, + logger: { info() {}, warn() {} }, + }); + assert.equal(live.skipped, false); + assert.equal(existsSync(join(profileDir, 'incremental', 'bitfun_demo-old')), false); + assert.equal(existsSync(join(profileDir, 'incremental', 'bitfun_demo-new')), true); + assert.equal( + existsSync(join(profileDir, 'deps', 'libbitfun_demo-aaaaaaaaaaaaaaaa.rlib')), + false + ); + assert.equal( + existsSync(join(profileDir, 'deps', 'libbitfun_demo-bbbbbbbbbbbbbbbb.rlib')), + true + ); + } finally { + cleanup(); + } +}); + +test('tauri build argv helpers resolve profile and target', () => { + assert.equal(profileFromTauriBuildArgs(['--debug']), 'debug'); + assert.equal(profileFromTauriBuildArgs(['--profile', 'release-fast']), 'release-fast'); + assert.equal(profileFromTauriBuildArgs([]), 'release'); + assert.equal(targetFromTauriBuildArgs(['--target', 'aarch64-apple-darwin']), 'aarch64-apple-darwin'); + assert.equal(targetFromTauriBuildArgs([]), null); +}); diff --git a/scripts/desktop-tauri-build.mjs b/scripts/desktop-tauri-build.mjs index c70e26b144..1ef44ad220 100644 --- a/scripts/desktop-tauri-build.mjs +++ b/scripts/desktop-tauri-build.mjs @@ -73,6 +73,20 @@ async function main() { patchDmgExtras(ROOT); } + // Keep only the latest useful Cargo caches for this build profile after tauri build ends. + try { + const { profileFromTauriBuildArgs, runGcBestEffort, targetFromTauriBuildArgs } = await import( + './cargo-target-gc.mjs' + ); + runGcBestEffort({ + rootDir: ROOT, + profile: profileFromTauriBuildArgs(forward), + triple: targetFromTauriBuildArgs(forward), + }); + } catch (error) { + console.warn(`[target-gc] skipped: ${error.message || String(error)}`); + } + process.exit(r.status ?? 1); } diff --git a/scripts/dev.cjs b/scripts/dev.cjs index 211f5212aa..cdeafa069c 100644 --- a/scripts/dev.cjs +++ b/scripts/dev.cjs @@ -15,6 +15,7 @@ const { printSuccess, printInfo, printError, + printWarning, printStep, printComplete, printBlank, @@ -283,6 +284,44 @@ async function waitForPort(port, hosts = DEV_SERVER_HOSTS, timeoutMs = 30000) { throw new Error(`Port ${port} did not become ready within ${timeoutMs}ms`); } +async function runDesktopTargetGcBestEffort(profile = 'debug') { + try { + const { runGcBestEffort } = await import( + pathToFileURL(path.join(__dirname, 'cargo-target-gc.mjs')).href + ); + printInfo('Pruning stale Cargo target caches (keep latest only)'); + runGcBestEffort({ + rootDir: ROOT_DIR, + profile, + logger: { + info: (message) => printInfo(message), + warn: (message) => printError(message), + }, + }); + } catch (error) { + printError(`Target GC skipped: ${error.message || String(error)}`); + } +} + +async function runDesktopTargetGc(profile = 'debug') { + try { + const { runGcBestEffort } = await import( + pathToFileURL(path.join(__dirname, 'cargo-target-gc.mjs')).href + ); + printInfo('Pruning stale Cargo target caches (keep latest only)...'); + runGcBestEffort({ + rootDir: ROOT_DIR, + profile, + logger: { + info: (message) => printInfo(message), + warn: (message) => printWarning(message), + }, + }); + } catch (error) { + printWarning(`target-gc skipped: ${error.message || String(error)}`); + } +} + async function ensureDesktopOpenSslIfNeeded() { if (process.platform !== 'win32') { return; @@ -452,23 +491,24 @@ async function startDesktopPreview() { } }; - const shutdown = (exitCode = 0) => { + const shutdown = async (exitCode = 0) => { if (shuttingDown) { return; } shuttingDown = true; cleanup(); + await runDesktopTargetGc('debug'); process.exit(exitCode); }; process.on('SIGINT', () => { printInfo('Stopping desktop preview...'); - shutdown(0); + void shutdown(0); }); process.on('SIGTERM', () => { printInfo('Stopping desktop preview...'); - shutdown(0); + void shutdown(0); }); if (await isPortOpen(DEV_SERVER_PORT)) { @@ -509,7 +549,7 @@ async function startDesktopPreview() { await waitForPort(DEV_SERVER_PORT); } catch (error) { printError(error.message || String(error)); - shutdown(1); + await shutdown(1); } printSuccess(`Web UI dev server is ready on http://localhost:${DEV_SERVER_PORT}`); @@ -523,14 +563,14 @@ async function startDesktopPreview() { appProcess.on('error', (error) => { printError(`Desktop preview failed to start: ${error.message || String(error)}`); - shutdown(1); + void shutdown(1); }); appProcess.on('exit', (code, signal) => { if (!shuttingDown) { printInfo(`Desktop preview exited (code=${code ?? 'null'}, signal=${signal ?? 'null'})`); } - shutdown(code ?? 0); + void shutdown(code ?? 0); }); printSuccess('Desktop preview is running'); @@ -710,15 +750,20 @@ async function main() { await ensureDesktopOpenSslIfNeeded(); const desktopDir = path.join(ROOT_DIR, 'src/apps/desktop'); const tauriConfig = path.join(desktopDir, 'tauri.dev.conf.json'); - if (process.platform === 'win32') { - // Running the generated .cmd shim directly via spawn is flaky on Windows. - // Use cmd.exe with an explicit args array so the desktop app directory - // stays the Tauri project root without pnpm workspace path rewriting. - const tauriBin = path.join(ROOT_DIR, 'node_modules', '.bin', 'tauri.cmd'); - await runWindowsCommandArgs(tauriBin, ['dev', '--config', tauriConfig], desktopDir, process.env); - } else { - const tauriBin = path.join(ROOT_DIR, 'node_modules', '.bin', 'tauri'); - await spawnCommand(tauriBin, ['dev', '--config', tauriConfig], desktopDir); + try { + if (process.platform === 'win32') { + // Running the generated .cmd shim directly via spawn is flaky on Windows. + // Use cmd.exe with an explicit args array so the desktop app directory + // stays the Tauri project root without pnpm workspace path rewriting. + const tauriBin = path.join(ROOT_DIR, 'node_modules', '.bin', 'tauri.cmd'); + await runWindowsCommandArgs(tauriBin, ['dev', '--config', tauriConfig], desktopDir, process.env); + } else { + const tauriBin = path.join(ROOT_DIR, 'node_modules', '.bin', 'tauri'); + await spawnCommand(tauriBin, ['dev', '--config', tauriConfig], desktopDir); + } + } finally { + // Option B: prune only when the desktop:dev session ends, not on each rebuild. + await runDesktopTargetGc('debug'); } } else if (mode === 'desktop-preview') { await ensureDesktopDebugBinaryForPreview(forceDesktopPreviewRebuild); diff --git a/src/apps/cli/src/account_sync.rs b/src/apps/cli/src/account_sync.rs index 47be5869fc..c094e9b7b2 100644 --- a/src/apps/cli/src/account_sync.rs +++ b/src/apps/cli/src/account_sync.rs @@ -350,6 +350,7 @@ pub(crate) async fn run_auto_sync( emit_progress("exporting_sessions", 20, Some(0), Some(upload_total), None).await; let mut uploaded: Vec<(String, String, i64)> = Vec::new(); + let mut upload_errors: Vec = Vec::new(); for (chunk_idx, chunk) in pending_uploads.chunks(UPLOAD_CONCURRENCY_CHUNK).enumerate() { let mut handles = Vec::new(); for (session_id, bundle_json, hash) in chunk { @@ -390,11 +391,13 @@ pub(crate) async fn run_auto_sync( } Ok((session_id, _, Ok(Err(e)))) => { tracing::warn!("Auto-sync upload {session_id} failed: {e}"); + upload_errors.push(format!("{session_id}: {e}")); let _ = done_base; } Ok((_, _, Err(e))) => return Err(e), Err(e) => { tracing::warn!("Auto-sync upload task join failed: {e}"); + upload_errors.push(format!("upload task join failed: {e}")); } } } @@ -416,7 +419,7 @@ pub(crate) async fn run_auto_sync( } let _ = sync_state::save(&acct_session.user_id, &sync_state_local); - ensure_session_backup_complete(upload_total, exported)?; + ensure_session_backup_complete(upload_total, exported, &upload_errors)?; tracing::info!("Auto-sync: settings={settings_synced} exported={exported} imported=0"); emit_progress("done", 100, Some(exported), Some(0), None).await; @@ -428,12 +431,20 @@ pub(crate) async fn run_auto_sync( }) } -fn ensure_session_backup_complete(total: usize, uploaded: usize) -> Result<()> { +fn ensure_session_backup_complete( + total: usize, + uploaded: usize, + upload_errors: &[String], +) -> Result<()> { if uploaded == total { return Ok(()); } + let detail = upload_errors + .first() + .map(|err| err.as_str()) + .unwrap_or("retry will resume remaining sessions"); Err(anyhow!( - "session backup incomplete: uploaded {uploaded} of {total}; retry will resume remaining sessions" + "session backup incomplete: uploaded {uploaded} of {total}; {detail}" )) } @@ -464,10 +475,14 @@ mod tests { #[test] fn partial_session_backup_is_not_reported_as_success() { - assert!(ensure_session_backup_complete(4, 4).is_ok()); - assert!(ensure_session_backup_complete(4, 1) - .unwrap_err() - .to_string() - .contains("uploaded 1 of 4")); + assert!(ensure_session_backup_complete(4, 4, &[]).is_ok()); + assert!(ensure_session_backup_complete( + 4, + 1, + &["s1: relay returned HTTP 507 Insufficient Storage".into()] + ) + .unwrap_err() + .to_string() + .contains("HTTP 507")); } } diff --git a/src/apps/desktop/AGENTS-CN.md b/src/apps/desktop/AGENTS-CN.md index 4546119a98..7a205a4756 100644 --- a/src/apps/desktop/AGENTS-CN.md +++ b/src/apps/desktop/AGENTS-CN.md @@ -53,6 +53,10 @@ pnpm run desktop:build:fast | `pnpm run desktop:build:release-fast` | 类 Release 构建,降低 LTO;需要 release 行为但无法等待完整 LTO 时使用 | | `pnpm run desktop:build:nsis:fast` | Windows 安装器,使用 `release-fast` profile;快速验证安装器 | +## Target 缓存 GC + +`desktop:dev`(退出时)、`desktop:preview:debug`(关闭时)以及 `desktop:build*` 会裁剪过期的 `target//{incremental,.fingerprint,deps}`,每种缓存只保留最新可用副本,避免跨会话堆积。手动执行:`pnpm run target:gc -- --profile debug`。禁用:`BITFUN_TARGET_GC=0`;演练:`BITFUN_TARGET_GC_DRY_RUN=1`。 + `release-fast` profile(`Cargo.toml`):继承 `release`,但关闭 LTO、`codegen-units` 提高到 16、启用增量编译。编译速度显著提升,代价是二进制体积增大和边际运行时性能下降。 ## DevTools feature(模型规则) diff --git a/src/apps/desktop/AGENTS.md b/src/apps/desktop/AGENTS.md index e582482261..ab85d39c29 100644 --- a/src/apps/desktop/AGENTS.md +++ b/src/apps/desktop/AGENTS.md @@ -61,6 +61,10 @@ pnpm run desktop:build:fast | `pnpm run desktop:build:release-fast` | Release-like build with reduced LTO; use when you need release behavior but can't wait for full LTO | | `pnpm run desktop:build:nsis:fast` | Windows installer using `release-fast` profile; for quick installer validation | +## Target cache GC + +`desktop:dev` (on exit), `desktop:preview:debug` (on shutdown), and `desktop:build*` run a latest-only prune of stale `target//{incremental,.fingerprint,deps}` artifacts so caches do not accumulate across sessions. Manual: `pnpm run target:gc -- --profile debug`. Disable with `BITFUN_TARGET_GC=0`; dry-run with `BITFUN_TARGET_GC_DRY_RUN=1`. + `release-fast` profile (`Cargo.toml`): inherits `release` but disables LTO, increases `codegen-units` to 16, enables incremental compilation. Significantly faster at the cost of binary size and marginal runtime performance. ## DevTools feature (model rule) diff --git a/src/apps/desktop/src/api/remote_connect_api.rs b/src/apps/desktop/src/api/remote_connect_api.rs index ec5bca3bfc..b3c2f0e176 100644 --- a/src/apps/desktop/src/api/remote_connect_api.rs +++ b/src/apps/desktop/src/api/remote_connect_api.rs @@ -3706,58 +3706,70 @@ async fn account_auto_sync_inner( ); let completed = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let uploaded: Vec<(String, String, i64)> = stream::iter(pending_uploads) - .map(|(session_id, bundle_json, hash)| { - let client = AccountClient::new(); - let relay_url = relay_url.clone(); - let acct_session = acct_session.clone(); - let completed = completed.clone(); - async move { - if ensure_account_auto_sync_current(sync_operation_id).is_err() { - return None; - } - let result = match await_account_auto_sync( - sync_operation_id, - client.upload_session(&relay_url, &acct_session, &session_id, &bundle_json), - ) - .await - { - Ok(result) => result, - Err(_) => return None, - }; - let done = completed.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1; - let percent = if upload_total == 0 { - 95u8 - } else { - 20 + ((75 * done) / upload_total) as u8 - }; - if ensure_account_auto_sync_current(sync_operation_id).is_err() { - return None; - } - emit_sync_progress( - sync_operation_id, - "exporting_sessions", - percent.min(95), - Some(done), - Some(upload_total), - Some(session_id.as_str()), - ); - match result { - Ok(version) => Some((session_id, hash, version)), - Err(e) => { - log::warn!("Auto-sync upload {session_id} failed: {e}"); - None + let upload_outcomes: Vec> = + stream::iter(pending_uploads) + .map(|(session_id, bundle_json, hash)| { + let client = AccountClient::new(); + let relay_url = relay_url.clone(); + let acct_session = acct_session.clone(); + let completed = completed.clone(); + async move { + if ensure_account_auto_sync_current(sync_operation_id).is_err() { + return Err("account sync cancelled".to_string()); + } + let result = match await_account_auto_sync( + sync_operation_id, + client.upload_session(&relay_url, &acct_session, &session_id, &bundle_json), + ) + .await + { + Ok(result) => result, + Err(e) => return Err(e), + }; + match result { + Ok(version) => { + let done = + completed.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1; + let percent = if upload_total == 0 { + 95u8 + } else { + 20 + ((75 * done) / upload_total) as u8 + }; + if ensure_account_auto_sync_current(sync_operation_id).is_err() { + return Err("account sync cancelled".to_string()); + } + emit_sync_progress( + sync_operation_id, + "exporting_sessions", + percent.min(95), + Some(done), + Some(upload_total), + Some(session_id.as_str()), + ); + Ok((session_id, hash, version)) + } + Err(e) => { + log::warn!("Auto-sync upload {session_id} failed: {e}"); + Err(format!("{session_id}: {e}")) + } } } - } - }) - .buffer_unordered(UPLOAD_CONCURRENCY) - .filter_map(|r| async move { r }) - .collect() - .await; + }) + .buffer_unordered(UPLOAD_CONCURRENCY) + .collect() + .await; ensure_account_auto_sync_current(sync_operation_id)?; + let mut uploaded = Vec::new(); + let mut upload_errors = Vec::new(); + for outcome in upload_outcomes { + match outcome { + Ok(item) => uploaded.push(item), + Err(err) => upload_errors.push(err), + } + } + let exported = uploaded.len(); let mut max_uploaded_version = sync_state_local.last_session_since; for (session_id, hash, version) in uploaded { @@ -3771,7 +3783,7 @@ async fn account_auto_sync_inner( } let _ = sync_state::save(&acct_session.user_id, &sync_state_local); - ensure_session_backup_complete(upload_total, exported)?; + ensure_session_backup_complete(upload_total, exported, &upload_errors)?; log::info!("Auto-sync: settings={settings_synced} exported={exported} imported=0"); emit_sync_progress( @@ -3789,12 +3801,20 @@ async fn account_auto_sync_inner( }) } -fn ensure_session_backup_complete(total: usize, uploaded: usize) -> Result<(), String> { +fn ensure_session_backup_complete( + total: usize, + uploaded: usize, + upload_errors: &[String], +) -> Result<(), String> { if uploaded == total { return Ok(()); } + let detail = upload_errors + .first() + .map(|err| err.as_str()) + .unwrap_or("retry will resume remaining sessions"); Err(format!( - "session backup incomplete: uploaded {uploaded} of {total}; retry will resume remaining sessions" + "session backup incomplete: uploaded {uploaded} of {total}; {detail}" )) } @@ -4582,9 +4602,15 @@ mod sync_state_tests { #[test] fn partial_session_backup_is_not_reported_as_success() { - assert!(ensure_session_backup_complete(3, 3).is_ok()); - let error = ensure_session_backup_complete(3, 2).unwrap_err(); + assert!(ensure_session_backup_complete(3, 3, &[]).is_ok()); + let error = ensure_session_backup_complete( + 3, + 2, + &["s1: relay returned HTTP 507 Insufficient Storage".into()], + ) + .unwrap_err(); assert!(error.contains("uploaded 2 of 3")); + assert!(error.contains("HTTP 507")); } #[test] diff --git a/src/apps/relay-server/Cargo.toml b/src/apps/relay-server/Cargo.toml index fce66bfa5b..e4268d778e 100644 --- a/src/apps/relay-server/Cargo.toml +++ b/src/apps/relay-server/Cargo.toml @@ -23,7 +23,7 @@ path = "src/bin/relay_admin.rs" bitfun-relay-service = { path = "../../crates/services/relay-service" } axum = "0.8" tower-http = { version = "0.6.11", features = ["fs"] } -tokio = { version = "1.52", features = ["full"] } +tokio = { version = "1.52", default-features = false, features = ["rt-multi-thread", "macros", "net", "time", "sync"] } anyhow = "1.0" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/src/apps/relay-server/Dockerfile b/src/apps/relay-server/Dockerfile index 830e2d0098..a5a92b05f0 100644 --- a/src/apps/relay-server/Dockerfile +++ b/src/apps/relay-server/Dockerfile @@ -1,8 +1,16 @@ +# syntax=docker/dockerfile:1.7 # BitFun Relay Server standalone Docker build. # The reduced repository-root context contains only the relay app and services. +# +# Rebuild caching: +# - Cargo registry/git/target use BuildKit cache mounts so redeploys reuse +# downloaded crates and compiled dependency objects across builds. +# - Binaries are copied out of the target cache mount into /out within the same +# RUN (cache mounts are not part of the image filesystem). -# Pin builder to bookworm so the linked glibc matches the runtime image. -FROM rust:1-bookworm AS builder +# Pin a minor toolchain so floating `rust:1-bookworm` updates do not bust the +# entire builder cache on every upstream image refresh. +FROM rust:1.96-bookworm AS builder WORKDIR /build/src/apps/relay-server @@ -10,12 +18,13 @@ WORKDIR /build/src/apps/relay-server # docker compose build --build-arg CARGO_BUILD_JOBS=1 # Note: empty value must NOT be set as ENV — cargo chokes on empty string. ARG CARGO_BUILD_JOBS= -ENV DEBIAN_FRONTEND=noninteractive +ENV DEBIAN_FRONTEND=noninteractive \ + CARGO_TERM_COLOR=always \ + CARGO_REGISTRIES_CRATES_IO_PROTOCOL=sparse RUN apt-get update \ && apt-get install -y --no-install-recommends \ pkg-config \ - libssl-dev \ build-essential \ ca-certificates \ clang \ @@ -23,13 +32,18 @@ RUN apt-get update \ python3 \ && rm -rf /var/lib/apt/lists/* +RUN mkdir -p /out + COPY src/apps/relay-server/Cargo.toml ./Cargo.toml COPY src/crates/services/relay-service/Cargo.toml ../../crates/services/relay-service/Cargo.toml COPY src/crates/services/page-function-runtime/Cargo.toml ../../crates/services/page-function-runtime/Cargo.toml -# Build placeholders first so unchanged dependencies remain cached. -# BuildKit exposes ARG as an env during RUN; cargo rejects CARGO_BUILD_JOBS="". -RUN mkdir -p src/bin \ +# Warm dependency objects into the BuildKit target cache. Placeholder sources +# live only in this layer; real sources are copied afterward. +RUN --mount=type=cache,id=bitfun-relay-cargo-registry,target=/usr/local/cargo/registry,sharing=locked \ + --mount=type=cache,id=bitfun-relay-cargo-git,target=/usr/local/cargo/git,sharing=locked \ + --mount=type=cache,id=bitfun-relay-target,target=/build/src/apps/relay-server/target,sharing=locked \ + mkdir -p src/bin \ ../../crates/services/relay-service/src \ ../../crates/services/page-function-runtime/src \ && printf 'fn main() {}\n' > src/main.rs \ @@ -38,36 +52,39 @@ RUN mkdir -p src/bin \ && printf '// placeholder\n' > ../../crates/services/relay-service/src/lib.rs \ && printf '// placeholder\n' > ../../crates/services/page-function-runtime/src/lib.rs \ && { [ -n "${CARGO_BUILD_JOBS:-}" ] || unset CARGO_BUILD_JOBS; } \ - && cargo build --release - -# Cargo emits libbitfun_*-*.rlib/.rmeta — a bare crate-name glob misses those -# and leaves the empty placeholder crate for the real rebuild. -RUN rm -rf src \ + && cargo build --release \ + && rm -rf src \ ../../crates/services/relay-service/src \ - ../../crates/services/page-function-runtime/src \ - target/release/bitfun-relay-server \ - target/release/relay-admin \ - target/release/deps/*bitfun_relay_service* \ - target/release/deps/*bitfun_page_function_runtime* \ - target/release/deps/*bitfun_relay_server* \ - target/release/deps/*relay_admin* \ - target/release/.fingerprint/bitfun-relay-service-* \ - target/release/.fingerprint/bitfun-page-function-runtime-* \ - target/release/.fingerprint/bitfun-relay-server-* \ - target/release/.fingerprint/relay-admin-* + ../../crates/services/page-function-runtime/src COPY src/apps/relay-server/src/ ./src/ COPY src/crates/services/relay-service/src/ ../../crates/services/relay-service/src/ COPY src/crates/services/page-function-runtime/src/ ../../crates/services/page-function-runtime/src/ -RUN touch ../../crates/services/relay-service/src/lib.rs \ +# Rebuild workspace crates against cached dependency objects, then copy +# release binaries out of the cache mount into the image. +RUN --mount=type=cache,id=bitfun-relay-cargo-registry,target=/usr/local/cargo/registry,sharing=locked \ + --mount=type=cache,id=bitfun-relay-cargo-git,target=/usr/local/cargo/git,sharing=locked \ + --mount=type=cache,id=bitfun-relay-target,target=/build/src/apps/relay-server/target,sharing=locked \ + rm -f target/release/bitfun-relay-server \ + target/release/relay-admin \ + target/release/deps/*bitfun_relay_service* \ + target/release/deps/*bitfun_page_function_runtime* \ + target/release/deps/*bitfun_relay_server* \ + target/release/deps/*relay_admin* \ + && rm -rf target/release/.fingerprint/bitfun-relay-service-* \ + target/release/.fingerprint/bitfun-page-function-runtime-* \ + target/release/.fingerprint/bitfun-relay-server-* \ + target/release/.fingerprint/relay-admin-* \ + && touch ../../crates/services/relay-service/src/lib.rs \ ../../crates/services/page-function-runtime/src/lib.rs \ src/lib.rs \ src/main.rs \ src/bin/relay_admin.rs \ && { [ -n "${CARGO_BUILD_JOBS:-}" ] || unset CARGO_BUILD_JOBS; } \ && cargo build --release \ - && (strip target/release/bitfun-relay-server target/release/relay-admin || true) + && cp target/release/bitfun-relay-server target/release/relay-admin /out/ \ + && (strip /out/bitfun-relay-server /out/relay-admin || true) FROM debian:bookworm-slim @@ -78,8 +95,8 @@ RUN apt-get update \ && rm -rf /var/lib/apt/lists/* WORKDIR /app -COPY --from=builder /build/src/apps/relay-server/target/release/bitfun-relay-server /app/bitfun-relay-server -COPY --from=builder /build/src/apps/relay-server/target/release/relay-admin /app/relay-admin +COPY --from=builder /out/bitfun-relay-server /app/bitfun-relay-server +COPY --from=builder /out/relay-admin /app/relay-admin RUN mkdir -p /app/static /app/data /app/room-web ENV RELAY_PORT=9700 diff --git a/src/apps/relay-server/README.md b/src/apps/relay-server/README.md index 5a37873b04..a67f903f84 100644 --- a/src/apps/relay-server/README.md +++ b/src/apps/relay-server/README.md @@ -36,6 +36,11 @@ memory VPS (common on arm64), use: RELAY_CARGO_BUILD_JOBS=1 bash deploy.sh ``` +`deploy.sh` enables Docker BuildKit so the Dockerfile can reuse Cargo +registry/git/`target` cache mounts across redeploys. Keep BuildKit enabled +(`DOCKER_BUILDKIT=1`, the deploy default) and avoid `docker builder prune` +unless you intentionally want a cold rebuild. + ## Two operating modes | Mode | When | What you get | diff --git a/src/apps/relay-server/deploy.sh b/src/apps/relay-server/deploy.sh index 86e2553094..ffe9584491 100755 --- a/src/apps/relay-server/deploy.sh +++ b/src/apps/relay-server/deploy.sh @@ -87,18 +87,23 @@ else BUILD_ARGS+=(--build-arg "CARGO_BUILD_JOBS=${RELAY_CARGO_BUILD_JOBS}") echo " Using CARGO_BUILD_JOBS=${RELAY_CARGO_BUILD_JOBS}" fi + # BuildKit is required for Dockerfile cargo registry/git/target cache mounts. # Plain progress so nohup/file-redirected deploys still stream build lines. + export DOCKER_BUILDKIT=1 + export COMPOSE_DOCKER_CLI_BUILD=1 export BUILDKIT_PROGRESS="${BUILDKIT_PROGRESS:-plain}" + echo " Using Docker BuildKit (cargo cache mounts enabled)" # Do not pass --platform unless the user explicitly set DOCKER_DEFAULT_PLATFORM; # native builds on amd64/arm64 servers are the supported path. # Compose V2 wants --progress as a global flag; honor BITFUN_DOCKER_MODE from common.sh. case "${BITFUN_DOCKER_MODE:-direct}" in sudo) - sudo docker compose --progress=plain build "${BUILD_ARGS[@]}" + sudo env DOCKER_BUILDKIT=1 COMPOSE_DOCKER_CLI_BUILD=1 BUILDKIT_PROGRESS="${BUILDKIT_PROGRESS}" \ + docker compose --progress=plain build "${BUILD_ARGS[@]}" ;; sg) # shellcheck disable=SC2086 - sg docker -c "docker compose --progress=plain build ${BUILD_ARGS[*]}" + sg docker -c "env DOCKER_BUILDKIT=1 COMPOSE_DOCKER_CLI_BUILD=1 BUILDKIT_PROGRESS='${BUILDKIT_PROGRESS}' docker compose --progress=plain build ${BUILD_ARGS[*]}" ;; *) if [ "${#COMPOSE[@]}" -ge 2 ] && [ "${COMPOSE[0]}" = "docker" ] && [ "${COMPOSE[1]}" = "compose" ]; then diff --git a/src/crates/services/relay-service/Cargo.toml b/src/crates/services/relay-service/Cargo.toml index 5b959a203f..eb763e0b12 100644 --- a/src/crates/services/relay-service/Cargo.toml +++ b/src/crates/services/relay-service/Cargo.toml @@ -14,7 +14,9 @@ path = "src/lib.rs" # relay application without copying the root workspace manifest into Docker. axum = { version = "0.8", features = ["json", "ws"] } tower-http = { version = "0.6.11", features = ["cors"] } -tokio = { version = "1.52", features = ["full"] } +# Keep the runtime surface explicit: relay needs multi-thread RT, timers, +# channels/semaphores, and TCP — not tokio's full feature set. +tokio = { version = "1.52", default-features = false, features = ["rt-multi-thread", "macros", "net", "time", "sync"] } futures-util = "0.3.31" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/src/crates/services/relay-service/src/db.rs b/src/crates/services/relay-service/src/db.rs index 3dc922b1ff..b5e4b67b8c 100644 --- a/src/crates/services/relay-service/src/db.rs +++ b/src/crates/services/relay-service/src/db.rs @@ -869,6 +869,10 @@ pub struct SyncSessionRow { impl SyncSessionRow { /// Upsert an encrypted session blob. Last-writer-wins via version. + /// + /// Enforces optional per-user active session count and total encrypted-byte + /// quotas. Product defaults are effectively unlimited (`i32::MAX`); pass + /// lower ceilings when an operator needs to bound account storage. pub async fn upsert_with_quota( pool: &DbPool, user_id: &str, @@ -950,6 +954,54 @@ impl SyncSessionRow { Ok(()) } + /// Soft-delete oldest active sessions (excluding `keep_session_id`) until + /// inserting/replacing a blob of `needed_bytes` would satisfy count/byte quotas. + /// + /// Used when a fresh upsert is rejected so recent backups can displace LRU + /// cloud sessions instead of failing the whole sync with HTTP 507. + pub async fn make_room_for_upsert( + pool: &DbPool, + user_id: &str, + keep_session_id: &str, + needed_bytes: i64, + max_sessions: i64, + max_total_bytes: i64, + ) -> Result { + if needed_bytes > max_total_bytes { + return Ok(0); + } + let candidates = sqlx::query_as::<_, SyncSessionRow>( + "SELECT session_id, encrypted_data, nonce, version, updated_at, deleted \ + FROM sync_sessions \ + WHERE user_id = ? AND deleted = 0 AND session_id <> ? \ + ORDER BY updated_at ASC, version ASC, session_id ASC", + ) + .bind(user_id) + .bind(keep_session_id) + .fetch_all(pool) + .await + .map_err(|e| anyhow!("list sync sessions for quota relief: {e}"))?; + + let mut bytes: i64 = candidates + .iter() + .map(|row| row.encrypted_data.len() as i64) + .sum(); + let mut count = candidates.len() as i64; + let mut evicted = 0usize; + + for row in candidates { + if count < max_sessions && bytes.saturating_add(needed_bytes) <= max_total_bytes { + break; + } + Self::delete(pool, user_id, &row.session_id).await?; + bytes = bytes.saturating_sub(row.encrypted_data.len() as i64); + count = count.saturating_sub(1); + evicted += 1; + } + + Ok(evicted) + } + /// Fetch one non-deleted session blob by id. pub async fn get( pool: &DbPool, @@ -2238,22 +2290,22 @@ mod tests { .unwrap(); assert!( - SyncSessionRow::upsert_with_quota(&pool, "u1", "s1", "1234", "n", 1, 1, 5,) + SyncSessionRow::upsert_with_quota(&pool, "u1", "s1", "1234", "n", 1, 1, 5) .await .unwrap() ); assert!( - SyncSessionRow::upsert_with_quota(&pool, "u1", "s1", "12345", "n", 2, 1, 5,) + SyncSessionRow::upsert_with_quota(&pool, "u1", "s1", "12345", "n", 2, 1, 5) .await .unwrap() ); assert!( - !SyncSessionRow::upsert_with_quota(&pool, "u1", "s2", "1", "n", 1, 1, 5,) + !SyncSessionRow::upsert_with_quota(&pool, "u1", "s2", "1", "n", 1, 1, 5) .await .unwrap() ); assert!( - !SyncSessionRow::upsert_with_quota(&pool, "u1", "s1", "123456", "n", 3, 1, 5,) + !SyncSessionRow::upsert_with_quota(&pool, "u1", "s1", "123456", "n", 3, 1, 5) .await .unwrap() ); @@ -2265,6 +2317,48 @@ mod tests { assert_eq!(stored.encrypted_data, "12345"); } + #[tokio::test] + async fn sync_session_make_room_evicts_oldest_until_upsert_fits() { + let pool = setup().await; + UserRow::create(&pool, "u1", "alice", "s", "ks", "{}", "hash", "wmk") + .await + .unwrap(); + + assert!( + SyncSessionRow::upsert_with_quota(&pool, "u1", "old", "1234", "n", 1, 2, 8) + .await + .unwrap() + ); + assert!( + SyncSessionRow::upsert_with_quota(&pool, "u1", "mid", "1234", "n", 2, 2, 8) + .await + .unwrap() + ); + assert!( + !SyncSessionRow::upsert_with_quota(&pool, "u1", "new", "1234", "n", 3, 2, 8) + .await + .unwrap() + ); + + let evicted = SyncSessionRow::make_room_for_upsert(&pool, "u1", "new", 4, 2, 8) + .await + .unwrap(); + assert!(evicted >= 1); + assert!( + SyncSessionRow::upsert_with_quota(&pool, "u1", "new", "1234", "n", 3, 2, 8) + .await + .unwrap() + ); + assert!(SyncSessionRow::get(&pool, "u1", "old") + .await + .unwrap() + .is_none()); + assert!(SyncSessionRow::get(&pool, "u1", "new") + .await + .unwrap() + .is_some()); + } + #[tokio::test] async fn legacy_global_device_schema_is_migrated_without_ambiguous_tokens() { let db_path = std::env::temp_dir().join(format!( diff --git a/src/crates/services/relay-service/src/routes/sync.rs b/src/crates/services/relay-service/src/routes/sync.rs index d61afcf28a..e36c50cd7b 100644 --- a/src/crates/services/relay-service/src/routes/sync.rs +++ b/src/crates/services/relay-service/src/routes/sync.rs @@ -23,8 +23,15 @@ pub const SYNC_BODY_LIMIT: usize = 64 * 1024 * 1024; const MAX_ENCRYPTED_BLOB_BYTES: usize = 48 * 1024 * 1024; const MAX_SESSION_ID_BYTES: usize = 256; const MAX_NONCE_BYTES: usize = 256; -const MAX_SYNC_SESSIONS_PER_USER: i64 = 2048; -const MAX_SYNC_SESSION_BYTES_PER_USER: i64 = 128 * 1024 * 1024; + +// Per-account sync-session quotas. +// +// Defaults use i32::MAX so BitFun's own deployments do not hit artificial +// product caps. Keep these knobs (and the upsert/make-room enforcement paths) +// so self-hosted / open-source operators can lower them to bound each user's +// cloud session backup footprint. +const MAX_SYNC_SESSIONS_PER_USER: i64 = i32::MAX as i64; +const MAX_SYNC_SESSION_BYTES_PER_USER: i64 = i32::MAX as i64; fn valid_session_id(value: &str) -> bool { !value.trim().is_empty() @@ -136,7 +143,7 @@ async fn sessions_upsert( return Err(StatusCode::BAD_REQUEST); } let db = state.db.as_ref().ok_or(StatusCode::NOT_IMPLEMENTED)?; - let stored = SyncSessionRow::upsert_with_quota( + let mut stored = SyncSessionRow::upsert_with_quota( db, &auth.user_id, &body.session_id, @@ -148,6 +155,34 @@ async fn sessions_upsert( ) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + if !stored { + // Prefer keeping the session being uploaded: evict LRU cloud backups + // until this blob fits the configured per-user quotas, then retry once. + let evicted = SyncSessionRow::make_room_for_upsert( + db, + &auth.user_id, + &body.session_id, + body.encrypted_data.len() as i64, + MAX_SYNC_SESSIONS_PER_USER, + MAX_SYNC_SESSION_BYTES_PER_USER, + ) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + if evicted > 0 { + stored = SyncSessionRow::upsert_with_quota( + db, + &auth.user_id, + &body.session_id, + &body.encrypted_data, + &body.nonce, + body.version, + MAX_SYNC_SESSIONS_PER_USER, + MAX_SYNC_SESSION_BYTES_PER_USER, + ) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + } + } if !stored { return Err(StatusCode::INSUFFICIENT_STORAGE); } diff --git a/src/crates/services/services-integrations/src/remote_connect/account.rs b/src/crates/services/services-integrations/src/remote_connect/account.rs index a085cf9191..df09674f2e 100644 --- a/src/crates/services/services-integrations/src/remote_connect/account.rs +++ b/src/crates/services/services-integrations/src/remote_connect/account.rs @@ -426,12 +426,50 @@ impl AccountClient { /// Upload (or replace) a single encrypted session blob for this device. /// Returns the `version` written into the upsert body (client-generated LWW clock). + /// + /// When the relay reports HTTP 507 (account sync quota full), evict the + /// oldest remote session backup (excluding this id) and retry. This keeps + /// recent local backups flowing on relays that do not yet auto-evict. pub async fn upload_session( &self, relay_url: &str, session: &AccountSession, session_id: &str, plaintext: &str, + ) -> Result { + const MAX_QUOTA_RELIEF_ATTEMPTS: usize = 32; + let mut last_quota_error: Option = None; + for _ in 0..MAX_QUOTA_RELIEF_ATTEMPTS { + match self + .upload_session_once(relay_url, session, session_id, plaintext) + .await + { + Ok(version) => return Ok(version), + Err(err) if is_insufficient_storage_error(&err) => { + last_quota_error = Some(err); + if !self + .evict_oldest_remote_session(relay_url, session, session_id) + .await? + { + break; + } + } + Err(err) => return Err(err), + } + } + Err(last_quota_error.unwrap_or_else(|| { + anyhow!( + "relay returned HTTP 507 Insufficient Storage (the configured account or asset quota is full)" + ) + })) + } + + async fn upload_session_once( + &self, + relay_url: &str, + session: &AccountSession, + session_id: &str, + plaintext: &str, ) -> Result { let (data, nonce) = Self::seal(session, plaintext)?; let version = chrono::Utc::now().timestamp_millis(); @@ -454,6 +492,34 @@ impl AccountClient { Ok(version) } + /// Soft-delete the oldest remote sync session other than `keep_session_id`. + /// Returns `true` when a session was removed. + async fn evict_oldest_remote_session( + &self, + relay_url: &str, + session: &AccountSession, + keep_session_id: &str, + ) -> Result { + let mut entries = self + .list_session_entries(relay_url, session, 0) + .await? + .into_iter() + .filter(|entry| entry.session_id != keep_session_id) + .collect::>(); + if entries.is_empty() { + return Ok(false); + } + entries.sort_by(|a, b| { + a.version + .cmp(&b.version) + .then_with(|| a.session_id.cmp(&b.session_id)) + }); + let victim = entries.remove(0); + self.delete_session(relay_url, session, &victim.session_id) + .await?; + Ok(true) + } + /// List encrypted session blobs updated after `since` without decrypting. /// `session_id` / `version` are plaintext metadata; payload stays sealed. pub async fn list_session_entries( @@ -815,6 +881,13 @@ struct SessionEntry { version: i64, } +fn is_insufficient_storage_error(err: &anyhow::Error) -> bool { + let msg = err.to_string(); + msg.contains("HTTP 507") + || msg.contains("Insufficient Storage") + || msg.to_ascii_lowercase().contains("quota is full") +} + /// Decrypted session sync blob with relay version metadata. #[derive(Debug, Clone)] pub struct FetchedSession { @@ -901,6 +974,16 @@ mod tests { .is_err()); } + #[test] + fn detects_insufficient_storage_errors_for_quota_relief() { + assert!(is_insufficient_storage_error(&anyhow!( + "relay returned HTTP 507 Insufficient Storage (the configured account or asset quota is full)" + ))); + assert!(!is_insufficient_storage_error(&anyhow!( + "relay returned HTTP 413 Payload Too Large" + ))); + } + #[test] fn relay_endpoint_accepts_http_servers_and_rejects_ambiguous_urls() { let endpoint = diff --git a/src/crates/services/services-integrations/src/remote_ssh/relay_deploy.rs b/src/crates/services/services-integrations/src/remote_ssh/relay_deploy.rs index 567b2a1ec6..12b6ab7513 100644 --- a/src/crates/services/services-integrations/src/remote_ssh/relay_deploy.rs +++ b/src/crates/services/services-integrations/src/remote_ssh/relay_deploy.rs @@ -919,23 +919,28 @@ bitfun_docker() { bitfun_run_deploy_sh() { local dir="$1" local port="${RELAY_PORT:-9700}" + # DOCKER_BUILDKIT is required for Dockerfile cargo registry/git/target mounts. case "${BITFUN_DOCKER_MODE:-direct}" in sudo) if sudo -n true >/dev/null 2>&1; then sudo -n -E env RELAY_PORT="$port" RELAY_CARGO_BUILD_JOBS="${RELAY_CARGO_BUILD_JOBS:-}" \ - BUILDKIT_PROGRESS=plain DOCKER_CONFIG="${DOCKER_CONFIG:-}" \ + DOCKER_BUILDKIT=1 COMPOSE_DOCKER_CLI_BUILD=1 BUILDKIT_PROGRESS=plain \ + DOCKER_CONFIG="${DOCKER_CONFIG:-}" \ bash "$dir/deploy.sh" else sudo -E env RELAY_PORT="$port" RELAY_CARGO_BUILD_JOBS="${RELAY_CARGO_BUILD_JOBS:-}" \ - BUILDKIT_PROGRESS=plain DOCKER_CONFIG="${DOCKER_CONFIG:-}" \ + DOCKER_BUILDKIT=1 COMPOSE_DOCKER_CLI_BUILD=1 BUILDKIT_PROGRESS=plain \ + DOCKER_CONFIG="${DOCKER_CONFIG:-}" \ bash "$dir/deploy.sh" fi ;; sg) - sg docker -c "env RELAY_PORT='$port' RELAY_CARGO_BUILD_JOBS='${RELAY_CARGO_BUILD_JOBS:-}' BUILDKIT_PROGRESS=plain DOCKER_CONFIG='${DOCKER_CONFIG:-}' bash '$dir/deploy.sh'" + sg docker -c "env RELAY_PORT='$port' RELAY_CARGO_BUILD_JOBS='${RELAY_CARGO_BUILD_JOBS:-}' DOCKER_BUILDKIT=1 COMPOSE_DOCKER_CLI_BUILD=1 BUILDKIT_PROGRESS=plain DOCKER_CONFIG='${DOCKER_CONFIG:-}' bash '$dir/deploy.sh'" ;; *) - env RELAY_PORT="$port" BUILDKIT_PROGRESS=plain bash "$dir/deploy.sh" + env RELAY_PORT="$port" RELAY_CARGO_BUILD_JOBS="${RELAY_CARGO_BUILD_JOBS:-}" \ + DOCKER_BUILDKIT=1 COMPOSE_DOCKER_CLI_BUILD=1 BUILDKIT_PROGRESS=plain \ + bash "$dir/deploy.sh" ;; esac } @@ -1038,7 +1043,8 @@ fi if command -v stdbuf >/dev/null 2>&1; then RUNNER=(stdbuf -oL -eL bash); else RUNNER=(bash); fi echo ">>> Starting background task (log: $LOG)" | tee -a "$LOG" nohup env BITFUN_DOCKER_MODE="$BITFUN_DOCKER_MODE" DOCKER_CONFIG="$DOCKER_CONFIG" \ - RELAY_CARGO_BUILD_JOBS="${{RELAY_CARGO_BUILD_JOBS:-}}" BUILDKIT_PROGRESS=plain \ + RELAY_CARGO_BUILD_JOBS="${{RELAY_CARGO_BUILD_JOBS:-}}" \ + DOCKER_BUILDKIT=1 COMPOSE_DOCKER_CLI_BUILD=1 BUILDKIT_PROGRESS=plain \ "${{RUNNER[@]}}" "$BODY" >"$LOG" 2>&1 < /dev/null & echo $! >"$PIDF" rm -f "$PREPARE_FLAG" @@ -1229,6 +1235,8 @@ set -euo pipefail {helpers} {sync} export DOCKER_CONFIG="${{DOCKER_CONFIG:-$HOME/.bitfun/docker-config}}" +export DOCKER_BUILDKIT=1 +export COMPOSE_DOCKER_CLI_BUILD=1 export BUILDKIT_PROGRESS=plain BITFUN_DOCKER_MODE="${{BITFUN_DOCKER_MODE:-direct}}" if [ "$BITFUN_DOCKER_MODE" = "direct" ] && ! docker info >/dev/null 2>&1; then diff --git a/src/web-ui/src/app/components/NavPanel/MainNav.tsx b/src/web-ui/src/app/components/NavPanel/MainNav.tsx index 82b4822a2d..41e2d1af26 100644 --- a/src/web-ui/src/app/components/NavPanel/MainNav.tsx +++ b/src/web-ui/src/app/components/NavPanel/MainNav.tsx @@ -13,7 +13,8 @@ import React, { useCallback, useState, useMemo, useEffect, useRef } from 'react'; import { createPortal } from 'react-dom'; -import { Plus, FolderOpen, FolderPlus, History, Check, User, Users, Puzzle, Blocks, ChevronDown, Search, PanelsTopLeft } from 'lucide-react'; +import { Plus, FolderOpen, FolderPlus, History, Check, User, Users, Puzzle, Blocks, ChevronDown, Search } from 'lucide-react'; +// import { PanelsTopLeft } from 'lucide-react'; // temporarily hidden: Pages nav entry import { Tooltip } from '@/component-library'; import { useApp } from '../../hooks/useApp'; import { useSceneManager } from '../../hooks/useSceneManager'; @@ -75,7 +76,7 @@ const MainNav: React.FC = ({ const activeTabId = useSceneStore(s => s.activeTabId); const setSelectedAssistantWorkspaceId = useMyAgentStore((s) => s.setSelectedAssistantWorkspaceId); const { t } = useI18n('common'); - const { t: tPages } = useI18n('scenes/pages'); + // const { t: tPages } = useI18n('scenes/pages'); // temporarily hidden: Pages nav entry const { currentWorkspace, loading: workspaceLoading, @@ -681,6 +682,7 @@ const MainNav: React.FC = ({ {/* ── Bottom: MiniApp ───────────────────────── */}
+ {/* Temporarily hide Pages entry + */}
) => string, + error: unknown, +): string { + if (isNonRetryableSyncError(error)) { + const msg = error instanceof Error ? error.message : String(error ?? ''); + if ( + msg.toLowerCase().includes('http 413') + || msg.toLowerCase().includes('payload too large') + ) { + return t('accountLogin.syncPayloadTooLarge'); + } + return t('accountLogin.syncQuotaFull'); + } + return t('accountLogin.syncFailed'); +} + function syncPhaseLabel( t: (key: string, options?: Record) => string, phase: AccountSyncPhase, @@ -121,6 +151,7 @@ export const AccountPanel: React.FC = ({ const { enterPeerMode } = usePeerDeviceMode(); const syncStatus = useAccountSyncStore((s) => s.status); const syncProgress = useAccountSyncStore((s) => s.progress); + const lastSyncError = useAccountSyncStore((s) => s.lastError); const lastSyncIsFirstLogin = useAccountSyncStore((s) => s.lastSyncIsFirstLogin); const setSyncing = useAccountSyncStore((s) => s.setSyncing); const setSyncDone = useAccountSyncStore((s) => s.setDone); @@ -502,6 +533,9 @@ export const AccountPanel: React.FC = ({ if (!isCurrentOperation()) return; lastError = e; log.warn(`Auto-sync attempt ${attempt}/${maxAttempts} failed`, e); + if (isNonRetryableSyncError(e)) { + break; + } if (attempt < maxAttempts) { info(t('accountLogin.syncRetrying', { attempt, max: maxAttempts })); await new Promise((resolve) => setTimeout(resolve, 2000 * attempt)); @@ -538,7 +572,7 @@ export const AccountPanel: React.FC = ({ if (!isCurrentOperation()) return; log.error('Auto-sync failed', e); setSyncFailed(e instanceof Error ? e.message : String(e)); - warning(t('accountLogin.syncFailed')); + warning(syncFailureMessage(t, e)); } finally { if (isCurrentOperation()) { syncInFlightRef.current = false; @@ -1006,7 +1040,7 @@ export const AccountPanel: React.FC = ({ syncProgress.total, )} {syncStatus === 'done' && t('accountLogin.syncDoneShort')} - {syncStatus === 'failed' && t('accountLogin.syncFailed')} + {syncStatus === 'failed' && syncFailureMessage(t, lastSyncError)} {syncStatus === 'failed' && (