Skip to content

Commit 09a289e

Browse files
authored
Merge pull request #178 from compoundingtech/fix/offline-peer-idle-cost
Prove bounded offline peer cost
2 parents 9b425d6 + a7b11a2 commit 09a289e

3 files changed

Lines changed: 159 additions & 7 deletions

File tree

docs/fabric-sync-process-plan.md

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -415,13 +415,15 @@ Each implementation pull request merges when its required tests and CI pass.
415415
Silber.cos owns the step 7 activation gate and every release and deployment
416416
gate.
417417

418-
Before any release, run every ignored test that needs a real machine. Record
419-
the machine, commit, command, and result.
418+
Before any release, run every named real-machine gate. Some gates are ignored
419+
tests, and some are matched live measurements. Record the machine, commit,
420+
command, and result.
420421

421422
| Platform | Test | Exact proof |
422423
| --- | --- | --- |
423424
| macOS | `a_real_launchd_supervisor_rolls_back_and_removes_its_job` | An isolated real launchd job detects a deliberately broken pair. The pair-aware reader restores both members. The plist and loaded job disappear. |
424425
| Linux | `a_real_systemd_supervisor_rolls_back_and_removes_its_jobs` | An isolated real systemd timer starts its service over a deliberately broken pair. The pair-aware reader restores both members. Both units disappear. |
426+
| Linux | `offline_peer_cost_matches_control` | A configured peer that never answers does not move healthy-peer latency, CPU use, or resident memory outside a matched control window. |
425427

426428
These tests use isolated paths and services. They do not prove the production
427429
service names, install paths, home permissions, or service definitions.
@@ -455,6 +457,24 @@ expected exactly one `fabric` member. It changed no executable or staging file.
455457
This clean refusal enforces the Release A order for that deployed reader. It is
456458
not a property of the Release A pair-aware reader.
457459

460+
The first `offline_peer_cost_matches_control` gate passed on hetz at exact tag
461+
`v0.2.4+9b425d6` on 2026-09-05. The treatment added one peer that never answered
462+
and then restored the exact prior file digest. It sent 300 healthy-peer pings
463+
over 91.663 seconds. All pings passed, and none took more than one second.
464+
465+
The matched resource traces each sampled the same daemon PID once per second
466+
for 379.095 seconds. Treatment used 5.925% of one core and had a 155,824 KiB RSS
467+
span. Control used 11.942% and had a 196,048 KiB span. Normal machine work was
468+
larger than the effect under test. Thus, the offline peer's cost was below this
469+
machine's noise floor. The daemon did not restart during the gate.
470+
471+
For each release, run the treatment and control on the same Linux machine and
472+
deployed commit. Record CPU time, RSS minimum and maximum, and 300 healthy-peer
473+
ping times. Sample each resource window once per second for at least six
474+
minutes. Restore the exact prior `peers.toml` digest after treatment. The gate
475+
fails if treatment exceeds the control by more than the control's own 60-second
476+
variation. It also fails if a healthy ping fails or exceeds one second.
477+
458478
Add each future real-machine test to this named list when the test is added.
459479
The measurement-only ignored tests are not release gates unless this list names
460480
them.

docs/failure-modes.md

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,11 @@
33
fabric connects two machines that are not on the same network. Networks fail, so
44
this page is about what fabric does when they do.
55

6-
**Every number here was measured**, on one machine running two fabric daemons
7-
against each other, by the test named in the last column. Where there is no
8-
test, the row says `NOT PROVEN` and stays in the table. A page that lists only
9-
the failures we happened to test would read as a complete list of what can go
10-
wrong, and it would not be one.
6+
**Every number here was measured.** Most measurements use one machine running
7+
two fabric daemons, with the test named in the last column. A fleet measurement
8+
names its machine and window. Where there is no test, the row says `NOT PROVEN`
9+
and stays in the table. A page that lists only the failures we happened to test
10+
would read as a complete list of what can go wrong, and it would not be one.
1111

1212
## The two questions
1313

@@ -33,6 +33,7 @@ nothing tells you to look.
3333
| **The far machine restarts** — you restart your dev server while a browser is connected | The open connection does not survive because the process that owned it is gone. A new request during the outage fails within Fabric's three-second initial-connect bound. A client can then retry. A new request works when the peer returns. See "Whose problem is a page that stops updating" below. | During the outage 3.006 s; after restart 91.681 ms; one 9.87 s focused run on 2026-09-02 | `a_peer_restarting_mid_session_restores_service_without_intervention` |
3434
| The direct path between the machines dies while a relay is available | `NOT PROVEN.` Two daemons on one machine cannot lose a direct path they never had, so this cannot be forced in a test here. It is not hypothetical: on the three-machine fleet today, 1,569 connections used a direct path and 1,463 used a relay, so both are in constant use. Proving the switch needs two real machines. | Unmeasured | `NOT PROVEN` |
3535
| A machine's address changes mid-session, as a laptop moving between networks does | The session survives without restarting the process, and the machine keeps its identity. Proven for one kind of tunnel. | Not separately measured | `generic_tunnel_survives_client_endpoint_recycle_without_process_restart`. **`NOT PROVEN` for TCP tunnels specifically.** |
36+
| A configured peer stays offline | Its failed connection attempt stays isolated. Healthy peer streams still open. Failed probes retain no connection. | Under 250 ms in the regression test. On hetz, 300 of 300 healthy pings passed over 91.663 seconds. | `offline_peer_cost_is_bounded_and_healthy_peer_stays_fast` |
3637

3738
## What you see while it is broken
3839

@@ -79,6 +80,24 @@ network is back, for the same reason, while a new request is immediate.
7980
application notices is up to the application; fabric restores the tunnel but
8081
cannot resurrect a socket the far process no longer has.
8182

83+
**An offline peer still gets a health probe every 20 seconds.** This is extra
84+
work, but its fleet cost was not detectable on 2026-09-05. A 91.663-second
85+
treatment had six failed probes. All 300 healthy-peer pings passed, with no ping
86+
above one second.
87+
88+
Two matched resource traces each used 380 one-second samples over 379.095
89+
seconds. The offline-peer treatment used 5.925% of one core. The no-offline-peer
90+
control used 11.942%, because unrelated work made the control busier. Treatment
91+
RSS spanned 155,824 KiB. Control RSS spanned 196,048 KiB. Both traces crossed the
92+
daemon's 128 MiB allocator sawtooth. These results show no attributable cost at
93+
this fleet size. They do not show that a failed probe costs nothing.
94+
95+
Remove a truly retired peer from `peers.toml` on every machine. This file is a
96+
local allow list, so removal on one machine does not remove trust elsewhere.
97+
`fabric doctor` can report an unreachable peer, but it cannot know that the peer
98+
was retired. The fleet has no authoritative peer set today. An operator must
99+
compare every machine's `peers.toml` to find this drift.
100+
82101
## Why `send-file` is not shaped like scp
83102

84103
`scp` lets the sender choose where a file lands on the far machine. **fabric

src/mux.rs

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1280,6 +1280,119 @@ mod tests {
12801280
Ok(())
12811281
}
12821282

1283+
/// A peer that never answers must keep a fixed amount of manager state and
1284+
/// must not delay a healthy peer while 32 repeated probes fail.
1285+
///
1286+
/// The live control on hetz used 4.81 CPU-seconds over 93.339 seconds. A
1287+
/// 342.087-second window with one offline peer used 17.09 CPU-seconds, or
1288+
/// 4.996% of one core against the control's 5.153%. RSS followed the same
1289+
/// bounded 128 MiB allocator sawtooth with and without the offline peer.
1290+
/// This test pins the deterministic causes behind that result: each probe
1291+
/// owns one bounded attempt, failed probes retain no connection, and a
1292+
/// healthy peer never waits for the offline peer's gate.
1293+
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1294+
async fn offline_peer_cost_is_bounded_and_healthy_peer_stays_fast() -> Result<()> {
1295+
const FAILED_PROBES: usize = 32;
1296+
const FAILED_PROBE_WINDOW: Duration = Duration::from_millis(350);
1297+
const HEALTHY_STREAM_BUDGET: Duration = Duration::from_millis(250);
1298+
1299+
let client = Endpoint::builder(presets::N0).bind().await?;
1300+
let (opened_tx, _opened_rx) = mpsc::unbounded_channel();
1301+
let manager = Arc::new(PeerConnections::new(client.id(), opened_tx));
1302+
let healthy = Router::builder(
1303+
Endpoint::builder(presets::N0)
1304+
.alpns(vec![MUX_ALPN.to_vec()])
1305+
.bind()
1306+
.await?,
1307+
)
1308+
.accept(
1309+
MUX_ALPN,
1310+
MuxEcho {
1311+
connections: Arc::new(AtomicUsize::new(0)),
1312+
headers: Arc::new(Mutex::new(Vec::new())),
1313+
},
1314+
)
1315+
.spawn();
1316+
healthy.endpoint().online().await;
1317+
let healthy_addr = healthy.endpoint().addr();
1318+
let offline_addr = EndpointAddr::new(iroh::SecretKey::generate().public());
1319+
1320+
let mut first = manager
1321+
.open_mux_stream(&client, 0, &healthy_addr, "control", StreamActivity::Probe)
1322+
.await?;
1323+
prove_measurement_stream(&mut first).await?;
1324+
drop(first);
1325+
let attempts_before = manager.mux_connect_attempts.load(Ordering::SeqCst);
1326+
1327+
for probe in 0..FAILED_PROBES {
1328+
let offline_manager = manager.clone();
1329+
let offline_client = client.clone();
1330+
let offline_addr = offline_addr.clone();
1331+
let offline = tokio::spawn(async move {
1332+
tokio::time::timeout(
1333+
FAILED_PROBE_WINDOW,
1334+
offline_manager.open_mux_stream(
1335+
&offline_client,
1336+
0,
1337+
&offline_addr,
1338+
"offline",
1339+
StreamActivity::Probe,
1340+
),
1341+
)
1342+
.await
1343+
});
1344+
1345+
let expected_attempts = attempts_before + probe + 1;
1346+
tokio::time::timeout(Duration::from_secs(1), async {
1347+
while manager.mux_connect_attempts.load(Ordering::SeqCst) < expected_attempts {
1348+
tokio::task::yield_now().await;
1349+
}
1350+
})
1351+
.await
1352+
.with_context(|| format!("offline probe {probe} never started"))?;
1353+
1354+
let mut stream = tokio::time::timeout(
1355+
HEALTHY_STREAM_BUDGET,
1356+
manager.open_mux_stream(
1357+
&client,
1358+
0,
1359+
&healthy_addr,
1360+
"healthy",
1361+
StreamActivity::Probe,
1362+
),
1363+
)
1364+
.await
1365+
.with_context(|| {
1366+
format!("offline probe {probe} delayed the healthy peer past the budget")
1367+
})??;
1368+
prove_measurement_stream(&mut stream).await?;
1369+
drop(stream);
1370+
1371+
let offline = offline.await?;
1372+
assert!(
1373+
!matches!(offline, Ok(Ok(_))),
1374+
"the offline peer unexpectedly accepted probe {probe}"
1375+
);
1376+
}
1377+
1378+
assert_eq!(
1379+
manager.mux_connect_attempts.load(Ordering::SeqCst) - attempts_before,
1380+
FAILED_PROBES,
1381+
"a probe must own one connection attempt and no hidden retry task"
1382+
);
1383+
assert_eq!(
1384+
manager.conns.lock().await.len(),
1385+
1,
1386+
"failed probes must retain no connection beside the healthy one"
1387+
);
1388+
assert!(manager.legacy_notices.lock().await.is_empty());
1389+
assert!(manager.legacy_fallbacks.lock().await.is_empty());
1390+
1391+
healthy.shutdown().await?;
1392+
client.close().await;
1393+
Ok(())
1394+
}
1395+
12831396
async fn assert_stream_failure_recovery(
12841397
failures: usize,
12851398
expect_same_connection: bool,

0 commit comments

Comments
 (0)