Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 19 additions & 9 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,21 +48,31 @@ jobs:
set -euo pipefail
mkdir -p dist/package
cp "target/${{ matrix.target }}/release/fabric" dist/package/fabric
chmod 755 dist/package/fabric
cp "target/${{ matrix.target }}/release/fabric-sync" dist/package/fabric-sync
chmod 755 dist/package/fabric dist/package/fabric-sync
archive="dist/fabric-${{ matrix.target }}.tar.gz"
# Release A is the reader-only transition. Step 7 restores the pair.
tar -czf "$archive" -C dist/package fabric
tar -czf "$archive" -C dist/package fabric fabric-sync
members="$(tar -tzf "$archive")"
if [[ "$members" != "fabric" ]]; then
echo "transition archive must contain exactly fabric" >&2
if [[ "$members" != $'fabric\nfabric-sync' ]]; then
echo "release archive must contain exactly fabric and fabric-sync" >&2
printf 'archive members:\n%s\n' "$members" >&2
exit 1
fi
if command -v shasum >/dev/null 2>&1; then
shasum -a 256 "$archive" > "$archive.sha256"
else
sha256sum "$archive" > "$archive.sha256"
single_archive="dist/fabric-only-${{ matrix.target }}.tar.gz"
tar -czf "$single_archive" -C dist/package fabric
single_members="$(tar -tzf "$single_archive")"
if [[ "$single_members" != "fabric" ]]; then
echo "fabric-only archive must contain exactly fabric" >&2
printf 'archive members:\n%s\n' "$single_members" >&2
exit 1
fi
for artifact in "$archive" "$single_archive"; do
if command -v shasum >/dev/null 2>&1; then
shasum -a 256 "$artifact" > "$artifact.sha256"
else
sha256sum "$artifact" > "$artifact.sha256"
fi
done

- uses: actions/upload-artifact@v4
with:
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "fabric"
version = "0.2.3"
version = "0.2.4"
edition = "2024"
description = "Local socket facade for iroh-backed cross-machine transports"
repository = "https://github.com/compoundingtech/fabric"
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,13 @@ and `fabric-sync` (not dot-prefixed paths). Verify that shape before extracting:

The process-extraction plan defines one fabric-only transition release before
the first paired deployment. That gated reader release is the only exception.
The updater accepts that one-member shape and the paired shape. It rejects all
other member sets. A one-member install removes an installed companion and
keeps both prior binaries for rollback.

Current releases publish the pair at `fabric-<target>.tar.gz`. Release `0.2.4`
also publishes `fabric-only-<target>.tar.gz` for a strict one-member reader.
That compatibility asset requires `fabric update --url ... --sha256 ...`.

`fabric update` arms a detached verifier before it replaces either binary. On
macOS, launchd owns a transient one-shot job across terminal loss and system
Expand Down
20 changes: 16 additions & 4 deletions docs/fabric-sync-process-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ pair-aware rollback reader and the macOS supervisor. It contains no companion.
The reader restores both old processes when a companion existed. It removes the
companion binary and OS service when no companion existed. The first paired
archive is the later writer. This gate is per machine, so a roaming machine such
as Bluey first receives the transition release when it returns.
as Bluey first receives a fabric-only archive when it returns.

The reader-before-writer rule applies to every update artifact. If a new release
writes an artifact that an old rollback binary must read, the old binary must
Expand All @@ -195,6 +195,13 @@ definitions, generation records, durable state, and future install metadata all
follow this rule. A release plan must identify the reader for each new artifact
before it permits the writer.

Release `v0.2.3+bef869a` is a published one-member route. Release `0.2.4`
publishes the canonical paired archive for the strict `0.2.2` server readers.
It also publishes a distinct one-member compatibility archive for Bluey's
strict `0.2.1` reader. Bluey selects that asset with an explicit URL and hash,
so it needs only one supervised update. The `0.2.4` updater accepts both valid
shapes, and later releases can return to paired archives only.

### Paired-install rollback inventory

Every future paired-install change must add its machine effects to this table.
Expand Down Expand Up @@ -330,6 +337,11 @@ Release archives contain exactly `fabric` and `fabric-sync`. The installer and
updater verify both members, both hashes, and equal versions. Rollback stores a
matched pair.

The updater also accepts the earlier fabric-only release shape. A one-member
install removes an installed companion and keeps the complete prior set for
rollback. It rejects all other member sets. This tolerant reader prevents a
future archive-shape change from requiring another strict-reader flag day.

The main daemon still runs embedded sync. Removing the companion binary reverts
this step without changing behavior.

Expand Down Expand Up @@ -375,9 +387,9 @@ Deploy one machine first and prove both mixed directions. Continue one machine
at a time. The release gate remains with Silber.cos. Reverting the complete
binary pair restores the embedded owner and reads the unchanged state.

No paired archive may reach a machine until that machine runs the fabric-only
transition release and has proved its rollback reader. Bluey follows the same
gate when it returns.
No paired archive may reach a machine until that machine runs a fabric-only
release and has proved its rollback reader. Bluey follows the same gate when it
returns. Its distinct `0.2.4` compatibility asset satisfies this reader gate.

### 8. Remove the dormant embedded engine

Expand Down
36 changes: 31 additions & 5 deletions now.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ The living handoff for whoever owns fabric next (there was none before; keep thi
current). This records what is DONE, what is IN FLIGHT, and what is NEXT — the
things the repo history alone does not carry.

_Last updated: 2026-09-05 by Silber.fabric-codex. Silber and hetz run Release A.
Bluey was away and last reported the earlier fleet build. Ask Silber.cos before
each release or deployment._
_Last updated: 2026-09-05 by Silber.fabric-codex. Silber and hetz run
`0.2.2+a2f8a73`, measured by Silber.cos on 2026-09-05. Bluey is away. Nathan
last saw Bluey on `0.2.1+48208e4` at about 22:30 on 2026-09-04. Treat that as
last-known, not current. Ask Silber.cos before each release or deployment._

For extraction steps 6 and 8, merge on green without asking for a separate
Silber.cos approval. Silber.cos holds the step 7 activation gate and every
Expand Down Expand Up @@ -54,8 +55,9 @@ it.
Release A must be a fabric-only transition release. It carries pair-aware
rollback and the macOS supervisor, but its archive has no companion. Deploy and
prove Release A on each machine before that machine receives its first paired
archive. Bluey also receives Release A first when it returns. Silber.cos owns
both release and deployment gates.
archive. Bluey can instead receive the fabric-only `0.2.4` compatibility asset
when it returns. That asset installs the same tolerant reader before Bluey sees
a paired archive. Silber.cos owns both release and deployment gates.

The Release A reader must handle OS service state as well as binary paths. It
must restart both old services when a companion rollback exists. It must remove
Expand Down Expand Up @@ -108,6 +110,30 @@ problem. An enabled service with a missing binary now asks for a matched pair.
PR #171 merged the transition doctor fix as `475807e`. It passed the Nix,
macOS, and deterministic checks. It is not released or deployed.

PR #172 isolated one peer's slow connection open from healthy peers. A recovery
regression exposed two separate races. PR #174 corrected the connection race as
`2d629f4`. Issue #175 retains the older sync rename race, which remains unfixed.

PR #173 set version `0.2.3` and merged as `bef869a`. Release
`v0.2.3+bef869a` is published with one-member archives. The checksums and the
macOS binary passed. No server installed it. The `0.2.2` updater refused it
before staging because that reader requires exactly two archive members.

PR #176 restores paired release packaging. It also makes the updater
accept exactly two valid shapes: `fabric` alone, or `fabric` with
`fabric-sync`. Extra, missing, duplicate, and dot-prefixed members remain
errors. A one-member install removes the companion and keeps the prior pair for
rollback. Both Linux shapes schedule the service restart outside the caller's
cgroup. The one-member restart omits the absent companion. Release `0.2.4` must
use paired archives so the strict `0.2.2` server readers can install it. The
release also publishes a distinct one-member asset
for Bluey's strict `0.2.1` reader. Bluey selects that asset with an explicit URL
and SHA-256, so it can move to `0.2.4` in one supervised update. The explicit
URL path does not check Git commit direction. It checks the caller's hash, the
archive shape, the executable, its reported version, and rollback readiness.
Do not replace or remove `v0.2.3`. Silber.cos must gate the release and every
deployment.

A retained hetz-to-Silber window measured 300 direct probes over 358.498
seconds. All probes passed. External p50 was 45.013 ms, p95 was 2.117 seconds,
and p99 was 2.675 seconds. Nineteen probes exceeded two seconds. Eighteen of
Expand Down
113 changes: 73 additions & 40 deletions src/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -186,19 +186,27 @@ fn control_socket_state(
/// That is the same trap as installing at `command -v fabric`, entered from the
/// other side.
pub fn install_at(home: &FabricHome, exe: &Path, options: ServiceInstallOptions) -> Result<()> {
install_at_with_verification(home, exe, options, InstallVerification::Immediate)
install_at_with_verification(home, exe, options, InstallVerification::Immediate, true)
}

pub(crate) fn install_at_for_update(
home: &FabricHome,
exe: &Path,
options: ServiceInstallOptions,
companion_exists: bool,
detached_supervisor: bool,
) -> Result<()> {
let verification = if detached_supervisor {
InstallVerification::DetachedSupervisor
} else {
InstallVerification::Immediate
};
install_at_with_verification(
home,
exe,
options,
InstallVerification::DetachedSupervisor,
verification,
companion_exists,
)
}

Expand All @@ -207,6 +215,7 @@ fn install_at_with_verification(
exe: &Path,
options: ServiceInstallOptions,
verification: InstallVerification,
companion_exists: bool,
) -> Result<()> {
// The managed OS-service is a PROD-only concept, under a single global label.
// Installing it against a dev/custom home would register a SECOND service on
Expand All @@ -230,14 +239,23 @@ fn install_at_with_verification(
let allow_exec = resolve_allow_exec(home, options.allow_exec)?;
let memory_max_mb = resolve_memory_max_mb(home, options.memory_max_mb)?;
let spec = ServiceSpec::new(exe, home.root(), allow_shell, allow_exec, memory_max_mb)?;
require_sync_companion(&spec)?;
if companion_exists {
require_sync_companion(&spec)?;
}
match ServiceManager::current()? {
#[cfg(target_os = "linux")]
ServiceManager::SystemdUser => install_systemd_user(&spec)?,
ServiceManager::SystemdUser => {
install_systemd_user(&spec, companion_exists)?;
}
#[cfg(target_os = "macos")]
ServiceManager::LaunchdUser => {
if !companion_exists {
remove_launchd_sync_for_rollback()?;
}
install_launchd_user(home, &spec)?;
install_launchd_sync_user(home, &spec)?;
if companion_exists {
install_launchd_sync_user(home, &spec)?;
}
}
}

Expand Down Expand Up @@ -427,34 +445,34 @@ fn resolve_allow_exec(home: &FabricHome, requested: Option<bool>) -> Result<bool
/// No `--unit` name is passed on purpose. systemd names the transient unit
/// itself, so two updates close together cannot collide on a name that already
/// exists — which would fail the second one for a reason nobody would guess.
fn systemd_restart_argv() -> (&'static str, Vec<String>) {
(
"systemd-run",
vec![
"--user".into(),
// THE VERIFIER TRUSTS THIS DELAY, so systemd must honour it.
//
// `fabric update` schedules this restart at +3s and a verifier at
// +12s that waits 45s for the new version and rolls back if it does
// not see it. systemd defaults to AccuracySec=1min and batches
// timers, so without this line the restart can fire up to a minute
// late while the verifier fires on time, sees the OLD daemon for its
// whole window, and rolls a good update back to the previous binary
// — cleanly, with the only record in the journal. The verifier's own
// timer already sets this; the restart it verifies must too, or the
// two delays encode an order systemd is free to ignore. Finding 6 of
// the 2026-08-29 review.
"--timer-property=AccuracySec=1s".into(),
// Long enough that the caller returns before its cgroup goes away,
// short enough that an operator is not left waiting on it.
"--on-active=3".into(),
"systemctl".into(),
"--user".into(),
"restart".into(),
SERVICE_NAME.into(),
SYNC_SERVICE_NAME.into(),
],
)
fn systemd_restart_argv(companion_exists: bool) -> (&'static str, Vec<String>) {
let mut args = vec![
"--user".into(),
// THE VERIFIER TRUSTS THIS DELAY, so systemd must honour it.
//
// `fabric update` schedules this restart at +3s and a verifier at
// +12s that waits 45s for the new version and rolls back if it does
// not see it. systemd defaults to AccuracySec=1min and batches
// timers, so without this line the restart can fire up to a minute
// late while the verifier fires on time, sees the OLD daemon for its
// whole window, and rolls a good update back to the previous binary
// — cleanly, with the only record in the journal. The verifier's own
// timer already sets this; the restart it verifies must too, or the
// two delays encode an order systemd is free to ignore. Finding 6 of
// the 2026-08-29 review.
"--timer-property=AccuracySec=1s".into(),
// Long enough that the caller returns before its cgroup goes away,
// short enough that an operator is not left waiting on it.
"--on-active=3".into(),
"systemctl".into(),
"--user".into(),
"restart".into(),
SERVICE_NAME.into(),
];
if companion_exists {
args.push(SYNC_SERVICE_NAME.into());
}
("systemd-run", args)
}

/// Resolve the memory ceiling, in the same shape as the two allow flags above.
Expand Down Expand Up @@ -711,26 +729,35 @@ impl ServiceManager {
}

#[cfg(target_os = "linux")]
fn install_systemd_user(spec: &ServiceSpec) -> Result<()> {
fn install_systemd_user(spec: &ServiceSpec, companion_exists: bool) -> Result<()> {
let unit_path = systemd_user_unit_path()?;
let sync_unit_path = systemd_sync_user_unit_path()?;
if !companion_exists {
remove_systemd_sync_for_rollback()?;
}
if let Some(parent) = unit_path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
fs::write(&unit_path, render_systemd_user_unit(spec))
.with_context(|| format!("failed to write {}", unit_path.display()))?;
fs::write(&sync_unit_path, render_systemd_sync_user_unit(spec)?)
.with_context(|| format!("failed to write {}", sync_unit_path.display()))?;
if companion_exists {
fs::write(&sync_unit_path, render_systemd_sync_user_unit(spec)?)
.with_context(|| format!("failed to write {}", sync_unit_path.display()))?;
}

run_command("systemctl", &["--user", "daemon-reload"])?;
run_command("systemctl", &["--user", "enable", SERVICE_NAME])?;
run_command("systemctl", &["--user", "enable", SYNC_SERVICE_NAME])?;
let (program, args) = systemd_restart_argv();
if companion_exists {
run_command("systemctl", &["--user", "enable", SYNC_SERVICE_NAME])?;
}
let (program, args) = systemd_restart_argv(companion_exists);
let args: Vec<&str> = args.iter().map(String::as_str).collect();
run_command(program, &args)?;
println!("unit\t{}", unit_path.display());
println!("sync-unit\t{}", sync_unit_path.display());
if companion_exists {
println!("sync-unit\t{}", sync_unit_path.display());
}
// Say scheduled, because it is. Claiming a restart that has not happened yet
// would make a failed start look like a successful install.
println!("restart\tscheduled");
Expand Down Expand Up @@ -1666,7 +1693,7 @@ mod tests {
/// the effect would be the thing that got killed.
#[test]
fn the_linux_service_restart_is_detached_from_the_caller() {
let (program, args) = systemd_restart_argv();
let (program, args) = systemd_restart_argv(true);
assert_eq!(
program, "systemd-run",
"the restart is issued in place, so it kills its own caller"
Expand All @@ -1693,6 +1720,12 @@ mod tests {
args.contains(&SYNC_SERVICE_NAME),
"the same detached action must restart the companion: {args:?}"
);

let (_, single_args) = systemd_restart_argv(false);
assert!(
!single_args.iter().any(|arg| arg == SYNC_SERVICE_NAME),
"a fabric-only release must not restart the absent companion: {single_args:?}"
);
}

#[test]
Expand Down
Loading
Loading