Files
Greg ShuflinandClaude Sonnet 5 15b0db7a9d
Build Debug APK / build (push) Successful in 5m51s
Spelling: British -> American across in-app copy, docs, and code
Rewrites every British spelling found in the codebase to American: neighbour,
colour, catalogue, behaviour, honour, flavour, grey, judgement,
acknowledgement, licence, defence, organise/optimise/initialise/recognise/
analyse, artefact, cancelled/cancelling, labelled/labelling, modelled/
modelling, and centre/centred/centring all become their American forms —
consistently in prose (docs, ADRs, comments) and in identifiers, including
the FFI-shared CatalogueSync/Cancelled enum variants and catalogue_* fields
that cross the Rust/Kotlin boundary.

Left untouched: external API names that only look like a spelling variant
(kotlinx.coroutines.CancellationException/NonCancellable, Compose Material3's
labelLarge) and the EFF diceware wordlist used for key derivation.

Adds a Code Style rule to AGENTS.md/CLAUDE.md requiring American spelling in
user-facing copy and documentation going forward, with that same carve-out.

Verified: cargo build --workspace, all 1410 Rust tests, Kotlin compile,
fmt-rust/fmt-kotlin, lint-kotlin-fast (detekt), and the lint-gui-layout/
lint-sql-params/lint-p2p-admission custom checks all pass clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017fHWzCF6oNiCV4ue1bBf1t
2026-09-01 03:01:16 -07:00

643 lines
29 KiB
Makefile

# Default recipe: list all available recipes
_default:
just --list
PERSONAL_TEST_ENSEMBLE_PK := "cosponsor·curry scientist·lung trial·bunny symptom·unread"
# Must match `ndkVersion` in android/app/build.gradle.kts.
ANDROID_NDK_VERSION := "28.0.13004108"
# The browser extension's recipes live next to the extension, in
# browser-extension/justfile: `just extension build`, `just extension check`,
# `just extension dev-path`. just runs a module's recipes with the working
# directory set to the module's own directory, which is what lets that file
# refer to `src/` and `dist/` rather than repeating `browser-extension/`.
[doc: "Browser extension recipes — `just extension build`/`check`/`dev-path`"]
mod extension 'browser-extension/justfile'
# Report which optional dev capabilities (GUI, Android toolchain, Nix) this
# machine has. Development happens across several machines with different
# capabilities — run this first rather than assuming.
[doc: "Report GUI/Android/Nix capability on this machinerun first, don't assume from a prior session or machine"]
[group: "dev"]
doctor:
#!/usr/bin/env bash
set -euo pipefail
echo "== Machine capability check =="
if [ -n "${WAYLAND_DISPLAY:-}${DISPLAY:-}" ]; then
echo "GUI: yes — use \`just build-gui\` directly"
else
echo "GUI: no (no WAYLAND_DISPLAY/DISPLAY) — use \`just check-gui-nix\` to type-check (fast, incremental); \`just build-gui-nix\` for a full hermetic build"
fi
has_nix=0
command -v nix >/dev/null 2>&1 && has_nix=1
jdk="/opt/android-studio/jbr"
sdk="$HOME/Android/Sdk"
ndk="$sdk/ndk/{{ANDROID_NDK_VERSION}}"
if [ -d "$jdk" ] && [ -d "$sdk" ] && [ -d "$ndk" ]; then
echo "Android: yes — use \`just build-synchronicity-lib-android\`/\`just android-build\`/\`just gradlew <task>\` directly"
elif [ "$has_nix" = 1 ]; then
echo "Android: no (missing one of $jdk, $sdk, $ndk) — use \`just build-android-nix\` to build the debug APK hermetically instead"
else
echo "Android: no (missing one of $jdk, $sdk, $ndk) — Android/uniffi changes can't be built or verified on this machine; say so rather than attempting it"
fi
if [ "$has_nix" = 1 ]; then
echo "Nix: yes — \`just build-gui-nix\`/\`just build-android-nix\` available as hermetic build fallbacks"
else
echo "Nix: no"
fi
# Build the synchronicity_lib Rust crate for Android. This is the single
# source of truth for the cargo invocation and NDK toolchain env vars — the
# `buildRustLib` Gradle task (android/app/build.gradle.kts) delegates to this
# recipe rather than duplicating it, then copies the resulting .so into
# jniLibs itself.
#
# JAVA_HOME/ANDROID_HOME/NDK_HOME default to a local Android Studio install's
# layout, but are left alone if already set — `nix develop` (flake.nix's
# devShell) exports all three pointing at its own hermetic JDK/SDK/NDK, which
# `build-android-nix` relies on this recipe not clobbering.
[doc: "Build the synchronicity_lib Rust crate for Android (also invoked by Gradle's buildRustLib task)"]
[group: "build"]
build-synchronicity-lib-android:
#!/usr/bin/env bash
set -euo pipefail
export JAVA_HOME="${JAVA_HOME:-/opt/android-studio/jbr}"
export ANDROID_HOME="${ANDROID_HOME:-$HOME/Android/Sdk}"
export NDK_HOME="${NDK_HOME:-$ANDROID_HOME/ndk/{{ANDROID_NDK_VERSION}}}"
if [ ! -d "$NDK_HOME" ]; then
echo "Android NDK not found at: $NDK_HOME" >&2
echo "Install it via Android Studio: Tools > SDK Manager > SDK Tools tab > check \"NDK (Side by side)\" > Apply" >&2
exit 1
fi
export PATH="$NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin:$PATH"
export TARGET_CC="$NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android31-clang"
export TARGET_AR="$NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar"
if [ ! -x "$TARGET_CC" ]; then
echo "Android NDK compiler not found at: $TARGET_CC" >&2
exit 1
fi
if [ ! -x "$TARGET_AR" ]; then
echo "Android NDK archiver not found at: $TARGET_AR" >&2
exit 1
fi
export RUSTFLAGS="-C linker=$TARGET_CC -C link-arg=-Wl,-z,max-page-size=16384"
cd rust && cargo build --target aarch64-linux-android --package synchronicity_lib --release
# Generate Kotlin types using uniffi. Depends on build-synchronicity-lib-android
# so it never silently regenerates bindings from a stale .so.
#
# The bindings are a build output, not source. Gradle's per-variant
# `generate<Variant>UniffiBindings` task (see android/app/build.gradle.kts) calls
# this recipe with the output path AGP chose for it, under the Android module's
# `build/generated/`. The default below is only for running the recipe
# standalone — e.g. to read the Kotlin FFI surface without a Gradle build — and
# is deliberately under `build/` too, so `gradlew clean` clears it.
[doc: "Generate Kotlin types from the Rust FFI surface using uniffi"]
[group: "build"]
uniffi-bindgen-generate out_dir="android/app/build/generated/source/uniffi": build-synchronicity-lib-android
#!/usr/bin/env bash
set -euo pipefail
mkdir -p "{{out_dir}}"
# Absolute, because the cargo invocation below runs from rust/.
out_abs="$(cd "{{out_dir}}" && pwd)"
# --no-format: skip uniffi's built-in ktlint pass. The project formats Kotlin
# with ktfmt, which never sees this directory (it is under build/), so we
# leave uniffi's raw output as-is rather than running a second, conflicting
# formatter over it.
cd rust && cargo run --bin uniffi-bindgen -- generate \
--library "${CARGO_TARGET_DIR:-target}/aarch64-linux-android/release/libsynchronicity.so" \
--language kotlin \
--no-format \
--out-dir "$out_abs"
# JAVA_HOME defaults to a local Android Studio install but is left alone if
# already set, so this also works unmodified under `nix develop` — see
# build-android-nix. AAPT2_PATH is unset outside a Nix shell, so AAPT2_ARGS is
# empty there and Gradle resolves aapt2 itself as usual.
[doc: "Build Android app (debug)"]
[group: "build"]
android-build:
#!/usr/bin/env bash
export JAVA_HOME="${JAVA_HOME:-/opt/android-studio/jbr}"
AAPT2_ARGS=()
[ -n "${AAPT2_PATH:-}" ] && AAPT2_ARGS=(-Pandroid.aapt2FromMavenOverride="$AAPT2_PATH")
cd android && ./gradlew "${AAPT2_ARGS[@]}" assembleDebug
# Build the Android debug APK hermetically via Nix: no local Android Studio
# install required, just `nix`. `flake.nix`'s devShell provides the JDK, the
# Android SDK/NDK (via androidenv), and a Rust toolchain with the
# aarch64-linux-android target, exporting ANDROID_HOME/ANDROID_SDK_ROOT (which
# Gradle falls back to when android/local.properties has no sdk.dir) and
# AAPT2_PATH (picked up by android-build/gradlew/lint-kotlin* below) instead
# of writing android/local.properties — that file is also what Android Studio
# uses interactively, and overwriting it with this ephemeral Nix store path
# broke Studio's own SDK/AVD resolution. `build-apk-ci` uses the same
# toolchain but does write local.properties itself, which is fine there: it
# runs in a throwaway CI checkout with no Studio session to clobber. Runs
# `buildRustLib`/`generate<Variant>UniffiBindings` (via android-build's `./gradlew
# assembleDebug`, which depends on them) inside that shell, so this also
# regenerates the uniffi Kotlin bindings from a hermetically-built .so.
# Output lands at android/app/build/outputs/apk/debug/app-debug.apk.
[doc: "Build the Android debug APK hermetically via Nix (SDK/NDK/JDK from flake.nix, no local Android Studio needed)"]
[group: "build"]
build-android-nix:
nix develop --command just android-build
# Run any gradlew command with the correct JDK. As with android-build,
# JAVA_HOME is left alone if already set, so this works unmodified under
# `nix develop` (e.g. `nix develop --command just gradlew test` on a machine
# with no local Android Studio install).
[group: "android"]
gradlew *args:
#!/usr/bin/env bash
export JAVA_HOME="${JAVA_HOME:-/opt/android-studio/jbr}"
AAPT2_ARGS=()
[ -n "${AAPT2_PATH:-}" ] && AAPT2_ARGS=(-Pandroid.aapt2FromMavenOverride="$AAPT2_PATH")
cd android && ./gradlew "${AAPT2_ARGS[@]}" {{args}}
# Run the PC graphical application
[group: "run"]
[env("RUST_LOG", "info")]
gui *args:
cargo run --manifest-path rust/gui-app/Cargo.toml --bin syn-gui -- {{args}}
# Build the gpui desktop GUI (debug, local toolchain). Prefer build-gui-nix
# when there's no GUI session or the sandbox may be missing a native dep.
[doc: "Build the gpui desktop GUI (debug, local toolchain)"]
[group: "build"]
build-gui:
cargo build --manifest-path rust/gui-app/Cargo.toml
# Run the cli from source
[group: "run-cli"]
[env("RUST_LOG", "info")]
cli *args:
cargo run --manifest-path rust/Cargo.toml --bin syn-cli -- {{args}}
# test/store-dirs/ is gitignored and not created by any recipe — run
# `just init-test-ensemble-cli test/store-dirs/canonical` once beforehand.
[doc: "Run the cli against a manually-initialized store at test/store-dirs/canonical"]
[group: "run-cli"]
canon-cli *args:
just cli -s test/store-dirs/canonical {{args}}
# Run the command line program without cargo from the target/ directory
[group: "run-cli"]
[env("RUST_LOG", "info")]
cli-fast *args:
./rust/target/debug/syn-cli {{args}}
# Run the CLI daemon in the foreground.
#
# `--store` is a *global* flag and `--foreground` belongs to `daemon start`, so
# the caller's args go before the subcommand and the flag goes after it. Passing
# them the other way round — `daemon --foreground --store DIR`, which this
# recipe did until now — is refused by bpaf with "flag `--foreground` is not
# valid in this context", taking `test-env-daemon-a`/`-b` down with it.
[group: "run-cli"]
[env("RUST_LOG", "info")]
daemon-cli *args:
just cli {{args}} daemon start --foreground
# Launch the TUI
[group: "run"]
[env("RUST_LOG", "info")]
tui *args:
cargo run --manifest-path rust/Cargo.toml --bin syn-tui -- {{args}}
# Build all Rust binaries (native)
[group: "build"]
build-native:
cd rust && cargo build
# Build all Rust binaries in release mode (native)
[group: "build"]
build-native-release:
cd rust && cargo build --release
# Preferred way to verify GUI changes actually compile — a plain `cargo build -p gui-app` depends on system libs (fontconfig, alsa, libxcb, mupdf's bindgen/libclang, ...) that a dev sandbox may be missing, while this flake declares them all. Result lands at ./result/bin/syn-gui."
[doc: "Build the gpui desktop GUI (rust/gui-app) hermetically via Nix."]
[group: "build"]
build-gui-nix:
nix build .#syn-gui --max-jobs auto --cores 0
# Type-check the gpui desktop GUI inside the nix devShell.
#
# Prefer this over `build-gui-nix` while iterating: the hermetic build starts
# from a clean sandbox every time, so it rebuilds the whole workspace in
# release just to check a one-line change. This runs a normal incremental
# `cargo check` against `rust/target`, with the same native libraries the
# packaged build links against — seconds instead of minutes, after the first
# run warms the cache. Use `build-gui-nix` for final verification, or when you
# need a binary you can actually run.
[doc: "Type-check the gpui GUI incrementally in the nix devShell"]
[group: "build"]
check-gui-nix *args:
nix develop -c cargo check --manifest-path rust/gui-app/Cargo.toml {{args}}
# Run all formatters
[group: "format"]
fmt-all: fmt-kotlin fmt-rust
# Format Kotlin code with ktfmt. JAVA_HOME is left alone if already set, so
# this works under `nix develop` — see build-android-nix.
[group: "format"]
fmt-kotlin:
#!/usr/bin/env bash
export JAVA_HOME="${JAVA_HOME:-/opt/android-studio/jbr}"
cd android && ./gradlew ktfmtFormat
# Check Kotlin formatting (fails if unformatted)
[group: "format"]
fmt-kotlin-check:
#!/usr/bin/env bash
export JAVA_HOME="${JAVA_HOME:-/opt/android-studio/jbr}"
cd android && ./gradlew ktfmtCheck
# Lint Kotlin with detekt, WITH type resolution. There is deliberately no baseline
# file (see android/app/build.gradle.kts) — all debt was burned down, so any finding
# fails the build. JAVA_HOME is left alone if already set, so this works under
# `nix develop` — see build-android-nix.
#
# detektDebug rather than the plain `detekt` task because six of the rules this
# project has configured `active: true` — IgnoredReturnValue, ForbiddenMethodCall,
# UnsafeCallOnNullableType, UseOrEmpty, RedundantSuspendModifier, NoNameShadowing —
# are @RequiresTypeResolution and are SILENTLY SKIPPED on the plain task, which has
# no classpath. They looked enabled for a long time while enforcing nothing.
#
# The cost: detektDebug needs a compiled classpath, so it depends on the Rust
# cross-build (~15 min cold, seconds warm). Use `lint-kotlin-fast` for the quick
# structural-only pass; it cannot see the six rules above.
[doc: "Lint Kotlin with detekt + type resolution (no baselineany finding fails)"]
[group: "format"]
lint-kotlin: lint-kotlin-rethrow
#!/usr/bin/env bash
export JAVA_HOME="${JAVA_HOME:-/opt/android-studio/jbr}"
AAPT2_ARGS=()
[ -n "${AAPT2_PATH:-}" ] && AAPT2_ARGS=(-Pandroid.aapt2FromMavenOverride="$AAPT2_PATH")
cd android && ./gradlew "${AAPT2_ARGS[@]}" detektDebug
# Structural-only detekt: no classpath, so no Rust build and no type-resolution
# rules (see lint-kotlin). Seconds rather than minutes; use it while iterating,
# but `lint-kotlin` is what has to pass.
[doc: "Lint Kotlin with detekt, structural rules only (fast, no type resolution)"]
[group: "format"]
lint-kotlin-fast: lint-kotlin-rethrow
#!/usr/bin/env bash
export JAVA_HOME="${JAVA_HOME:-/opt/android-studio/jbr}"
AAPT2_ARGS=()
[ -n "${AAPT2_PATH:-}" ] && AAPT2_ARGS=(-Pandroid.aapt2FromMavenOverride="$AAPT2_PATH")
cd android && ./gradlew "${AAPT2_ARGS[@]}" detekt
# The one hole ADR 0022 left open in the never-crash design. `getOrElse { throw it }`
# is the same hazard as the banned `getOrThrow` — it turns a Result failure back into a
# thrown exception that nothing requires a caller to catch — but it is a `throw`
# expression rather than a method call, so detekt's ForbiddenMethodCall cannot express
# it and no built-in rule covers it. The ADR converted the one occurrence
# (PodcastRepository) by hand and recorded that nothing prevented its return. This does.
#
# grep rather than a custom detekt rule module: the module is a RuleSetProvider, a
# Gradle subproject and a test suite to keep green across detekt upgrades, which ADR
# 0022 already weighed and rejected for a larger class of finding than this one. This
# needs no classpath, so it also runs under lint-kotlin-fast and costs milliseconds.
#
# Results.kt is excluded because its `bind()` IS the sanctioned rethrow: a private
# ShortCircuit exception caught by the enclosing `result { }`, which is what gives the
# codebase its `?`-equivalent. That is the only one there should ever be.
[doc: "Fail on `getOrElse { throw }` — the crash class ForbiddenMethodCall can't see"]
[group: "format"]
lint-kotlin-rethrow:
#!/usr/bin/env bash
set -uo pipefail
# -P for the lazy class, -z so one file is one record and the lambda may span lines;
# [^}]* stops the match at the lambda's own closing brace.
pattern='getOrElse\s*\{[^}]*\bthrow\b'
hits=$(grep -rlPz "$pattern" --include='*.kt' --exclude='Results.kt' android/app/src || true)
[ -z "$hits" ] && exit 0
echo "error: getOrElse { ... throw ... } rethrows past the Result boundary (ADR 0022)."
echo " Consume the failure instead: bind() inside launchReportingFailure, or"
echo " getOrElse { report; return }."
echo
while IFS= read -r f; do
echo " $f"
grep -Pzo "$pattern" "$f" | tr '\0' '\n' | sed 's/^/ /'
echo
done <<< "$hits"
exit 1
# Fail on `.flex_1()` paired with `.max_w()` in one gpui element chain — the
# grow-then-clamp shape that lays children out against the max-content width
# and never replaces them (a 630px search box with 2px of input; a node-name
# field too narrow to read). Clippy can't see it: both calls are perfectly
# ordinary on their own. `.flex_col()` in the same chain is allowed — there
# `flex_1` grows height and the cap is on the cross axis.
[doc: "Fail on gpui chains that grow a width and then cap it (see sizing.rs `layout` docs)"]
[group: "format"]
lint-gui-layout:
python3 scripts/lint-gui-layout.py
# A P2P protocol that skips the delegation-cert handshake is reachable by any
# node that knows the ensemble *public* key — which is not secret. The type
# system already makes this hard (protocols implement `EnsembleProtocol`, not
# iroh's `ProtocolHandler`; outbound code holds an `EnsembleEndpoint`, not a
# raw `Endpoint`); this closes the escape hatch of reaching past the wrapper
# to iroh directly. See `rust/lib/src/p2p/admission.rs`.
[doc: "Fail on a P2P protocol that bypasses ensemble admission control"]
[group: "format"]
lint-p2p-admission:
python3 scripts/lint-p2p-admission.py
# A value belongs in a `?`, never in the SQL string. The distinction is
# invisible at the call site — whether `format!(" AND x >= '{s}'")` is a bug
# depends on where `s` came from — and one of these shipped: `SyncRequest.since`
# is chosen by the peer asking to sync, so any ensemble member could close the
# quote and append its own predicate. Clippy can't see it; a `format!` is a
# `format!`. See `scripts/lint-sql-params.py` for the two shapes it flags and
# the `// sql-literal-ok:` escape hatch.
[doc: "Fail on SQL that carries a value as text instead of as a parameter"]
[group: "format"]
lint-sql-params:
python3 scripts/lint-sql-params.py
[doc: "Format rust with cargo fmt"]
[group: "format"]
fmt-rust:
cd rust && cargo fmt
# Lint Rust with clippy across the whole workspace (including gui-app, which
# plain `cargo check`/nextest at the workspace root skips — see default-members
# in rust/Cargo.toml)
#
# Because it covers gui-app, this needs the GUI's native libraries present; on
# a machine without them it dies in a build script before linting anything.
# Use `lint-rust-nix` there.
[doc: "Lint Rust with clippy across the whole workspace"]
[group: "format"]
lint-rust *args:
cd rust && cargo clippy --workspace --all-targets {{args}}
# `lint-rust` inside flake.nix's devShell, which supplies the GUI's native
# libraries — so this works on a headless VM or sandbox where the bare recipe
# can't get past gui-app's build scripts.
[doc: "Lint Rust with clippy in the nix devShell (works without GUI system libs)"]
[group: "format"]
lint-rust-nix *args:
nix develop -c just lint-rust {{args}}
# Apply clippy's machine-applicable suggestions across the workspace.
#
# Only rewrites what clippy is confident about (needless borrows, useless
# conversions, collapsible ifs); findings that need a judgment call — how to
# factor a complex type, whether an argument list wants a struct — are reported
# but left alone. Review the diff: it is mechanical, not automatically right.
[doc: "Apply clippy's mechanical fixes across the workspace"]
[group: "format"]
lint-rust-fix *args:
nix develop -c cargo clippy --fix --workspace --all-targets --manifest-path rust/Cargo.toml {{args}}
# Run all rust unit tests
[group: "test"]
unit-tests-rust *args:
cd rust && cargo nextest run {{args}}
[doc: "Run gui-app unit tests (excluded from unit-tests-rustsee default-members in rust/Cargo.toml)"]
[group: "test"]
unit-tests-gui *args:
cd rust && cargo nextest run -p gui-app {{args}}
# Run the Kotlin unit tests (android/app/src/test), and take a filter:
#
# just unit-tests-kotlin --tests "AdjacencyTest"
# just unit-tests-kotlin --tests "*.PodcastEqualityTest.a re-read*"
#
# `testDebugUnitTest`, not the aggregate `test` task, and the difference is not
# cosmetic: under AGP 9 `test` is a lifecycle task that owns no test executor,
# so it rejects `--tests` outright ("Unknown command-line option") and there is
# no way to run one test through it. It also runs both variants' suites, which
# is the same tests twice. Like lint-kotlin, this compiles Kotlin and so
# depends on the Rust cross-build for the uniffi bindings — seconds once
# rust/target has the aarch64-linux-android tree, ~15 min on a cold one.
[doc: "Run Kotlin unit tests; takes a --tests filter"]
[group: "test"]
unit-tests-kotlin *args:
#!/usr/bin/env bash
export JAVA_HOME="${JAVA_HOME:-/opt/android-studio/jbr}"
AAPT2_ARGS=()
[ -n "${AAPT2_PATH:-}" ] && AAPT2_ARGS=(-Pandroid.aapt2FromMavenOverride="$AAPT2_PATH")
cd android && ./gradlew "${AAPT2_ARGS[@]}" testDebugUnitTest {{args}}
# Run P2P integration tests (requires internet access for pkarr DNS)
[group: "test"]
integration-tests-p2p:
cd rust && cargo test --test p2p_ping
# Run ensemble gossip bootstrap integration test (requires internet access:
# pkarr DHT + iroh relay via dns.iroh.link)
[doc: "Run ensemble gossip bootstrap integration test (requires internet access)"]
[group: "test"]
integration-tests-ensemble-gossip:
cd rust && cargo test --test ensemble_gossip
# Install desktop entry and icon for Wayland (user-local)
[group: "install"]
install-desktop:
mkdir -p ~/.local/share/applications
mkdir -p ~/.local/share/icons/hicolor/256x256/apps
cp assets/synchronicity.desktop ~/.local/share/applications/synchronicity.desktop
cp assets/synchronicity-icon.png ~/.local/share/icons/hicolor/256x256/apps/synchronicity.png
update-desktop-database ~/.local/share/applications
gtk-update-icon-cache -f -t ~/.local/share/icons/hicolor
@echo "Desktop entry and icon installed."
# Uninstall desktop entry and icon
[group: "install"]
uninstall-desktop:
rm -f ~/.local/share/applications/synchronicity.desktop
rm -f ~/.local/share/icons/hicolor/256x256/apps/synchronicity.png
# Create two test stores (node_a, node_b) with deterministic keypairs for local P2P experimentation.
# Pass `reset=true` to wipe and recreate existing stores.
[doc: "Create two deterministic test stores (node_a, node_b) for local P2P experimentation"]
[group: "dev"]
test-env-setup dir="test-nodes" reset="":
cargo run --manifest-path rust/Cargo.toml --example setup_test_nodes -- {{dir}} {{if reset != "" { "--reset" } else { "" }}}
# Start daemon for test node_a (run test-env-setup first)
[group: "dev"]
test-env-daemon-a dir="test-nodes":
just daemon-cli --store {{dir}}/node_a
# Start daemon for test node_b (run test-env-setup first)
[group: "dev"]
test-env-daemon-b dir="test-nodes":
just daemon-cli --store {{dir}}/node_b
# Start test node_b with a deliberately wrong wall clock, to exercise the
# timestamp bound in `p2p::wire_time` and the peer-health reporting built on it.
#
# `offset` is anything `date` can parse as a relative time — the `faketime`
# wrapper hands it straight to `date`, so it is "+3 days" rather than
# libfaketime's own terse "+3d" (which only its FAKETIME env var understands).
#
# "+3 days" puts node_b past the 24h bound, so node_a drops everything node_b
# writes and its roster row says so. "+13 hours" lands between the warning
# threshold and the bound — still merging, reported in amber. A negative offset
# ("-3 days") is the harmless case the bound deliberately does not police: a
# stamp in the past loses every comparison it takes part in, so node_a should
# say nothing at all.
#
# Three details this gets right that a narrower fake would not:
# * It skews the whole process, so node_b's signed gossip `announced_at_secs`
# *and* every `modified_at` it writes are wrong together — which is the real
# failure rather than a simulation of one.
# * It builds first and fakes only the built binary. Running `cargo` itself
# under a future clock would corrupt its mtime-based freshness check and
# silently mis-decide what to rebuild.
# * `-m` selects libfaketime's thread-safe build (the daemon is tokio, i.e.
# thoroughly multi-threaded), and FAKETIME_DONT_FAKE_MONOTONIC keeps QUIC's
# own timers on the real monotonic clock — without it iroh's timeouts move
# too and the nodes fail to connect for reasons unrelated to the test.
#
# libfaketime comes from the flake (devShells.default), so this needs no system
# package; it falls back to one on PATH for a machine without Nix.
[group: "dev"]
[env("RUST_LOG", "info")]
test-env-daemon-b-skewed dir="test-nodes" offset="+3 days":
#!/usr/bin/env bash
set -euo pipefail
cd rust && cargo build --bin syn-cli && cd ..
run=(faketime -m "{{offset}}" rust/target/debug/syn-cli --store "{{dir}}/node_b" daemon start --foreground)
export FAKETIME_DONT_FAKE_MONOTONIC=1
if command -v faketime >/dev/null 2>&1; then
exec "${run[@]}"
elif command -v nix >/dev/null 2>&1; then
exec nix develop -c "${run[@]}"
else
echo "Need libfaketime: run this from \`nix develop\`, or install it (Arch: pacman -S libfaketime)." >&2
exit 1
fi
# Cap the size of rust/target/ instead of periodically `cargo clean`-ing it.
#
# Cargo never garbage-collects target/: it writes one hash-suffixed artifact per
# distinct build configuration (rustc version, feature set, git-dependency rev)
# and keeps every one forever. Measured on this repo at 144 GB, that meant 849
# stale `syn_gui` objects and 516 `incremental/` session directories, most of
# them months old and unreachable from any current build.
#
# Two mechanisms, because they cover disjoint parts of the tree:
# * `cargo sweep --maxsize` evicts oldest-first from deps/, build/, and
# examples/ until the managed set fits under the cap.
# * `incremental/` is invisible to cargo-sweep, so prune it by age here. It is
# pure cache — deleting a session directory costs one non-incremental
# rebuild of that crate, nothing else.
#
# Run it on a schedule (see `install-gc-timer`), not by hand after you've
# already run out of disk.
[doc: "Cap rust/target/ at `size` and prune incremental caches older than `days`"]
[group: "dev"]
gc-target size="25GB" days="14":
#!/usr/bin/env bash
set -euo pipefail
cd "{{justfile_directory()}}"
before=$(du -sb rust/target 2>/dev/null | cut -f1)
cargo sweep --maxsize {{size}} rust
# Located by search, not by glob: cross-compiled targets nest one level
# deeper (target/aarch64-linux-android/release/incremental) than native
# ones (target/debug/incremental). -mindepth 1 so an empty incremental/ is
# a no-op rather than an attempt to delete the directory itself.
while IFS= read -r inc; do
find "$inc" -maxdepth 1 -mindepth 1 -type d -mtime +{{days}} -exec rm -rf {} +
done < <(find rust/target -type d -name incremental -prune)
after=$(du -sb rust/target 2>/dev/null | cut -f1)
awk -v b="$before" -v a="$after" 'BEGIN {
printf "rust/target: %.1f GiB -> %.1f GiB (freed %.1f GiB)\n",
b/1073741824, a/1073741824, (b-a)/1073741824
}'
# Show where rust/target/ is actually going, worst offenders first. Run this
# before reaching for `cargo clean` — the answer is usually a few hundred stale
# hash-suffixed binaries, not anything you need.
[doc: "Report what is consuming rust/target/"]
[group: "dev"]
target-usage:
#!/usr/bin/env bash
set -euo pipefail
cd "{{justfile_directory()}}"
du -sh rust/target 2>/dev/null
echo "--- by subdirectory ---"
du -sh rust/target/*/ 2>/dev/null | sort -rh | head -8
echo "--- largest artifacts ---"
find rust/target -type f -size +200M -printf "%s\t%p\n" 2>/dev/null \
| sort -rn | head -10 \
| awk -F'\t' '{printf "%7.2f GiB %s\n", $1/1073741824, $2}'
echo "--- stale incremental sessions ---"
find $(find rust/target -type d -name incremental -prune) \
-maxdepth 1 -mindepth 1 -type d -mtime +14 2>/dev/null \
| wc -l | xargs printf "%s directories older than 14 days\n"
# Install a systemd *user* timer that runs `gc-target` weekly.
#
# The point is that reclaiming disk stops being something you remember to do
# after a build already failed. User-level (not system) so it needs no root and
# runs as the account that owns the target directory; `Persistent=true` so a
# machine that was off on the scheduled day sweeps at next login instead of
# skipping the week.
[doc: "Install a weekly systemd user timer running `gc-target`"]
[group: "install"]
install-gc-timer size="25GB" days="14":
#!/usr/bin/env bash
set -euo pipefail
unit_dir="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
mkdir -p "$unit_dir"
cat > "$unit_dir/synchronicity-gc-target.service" <<EOF
[Unit]
Description=Cap synchronicity rust/target/ size
[Service]
Type=oneshot
WorkingDirectory={{justfile_directory()}}
ExecStart=$(command -v just) gc-target {{size}} {{days}}
Nice=19
IOSchedulingClass=idle
EOF
cat > "$unit_dir/synchronicity-gc-target.timer" <<EOF
[Unit]
Description=Weekly cap on synchronicity rust/target/ size
[Timer]
OnCalendar=weekly
Persistent=true
[Install]
WantedBy=timers.target
EOF
systemctl --user daemon-reload
systemctl --user enable --now synchronicity-gc-target.timer
@echo "Installed. Next run: $(systemctl --user list-timers synchronicity-gc-target.timer --no-pager 2>/dev/null | sed -n 2p)"
# Remove the weekly gc-target timer installed by `install-gc-timer`.
[group: "install"]
uninstall-gc-timer:
#!/usr/bin/env bash
set -euo pipefail
unit_dir="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
systemctl --user disable --now synchronicity-gc-target.timer 2>/dev/null || true
rm -f "$unit_dir/synchronicity-gc-target.service" "$unit_dir/synchronicity-gc-target.timer"
systemctl --user daemon-reload