rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5 heartwood61b58056cd8793298130cf90fa3eb52135ad04d1
{
"request": "trigger",
"version": 1,
"event_type": "patch",
"repository": {
"id": "rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5",
"name": "heartwood",
"description": "Radicle Heartwood Protocol & Stack",
"private": false,
"default_branch": "master",
"delegates": [
"did:key:z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT",
"did:key:z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM",
"did:key:z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz"
]
},
"action": "Updated",
"patch": {
"id": "c7b70595cad4a6689f8a1d03344e08a011f11b8a",
"author": {
"id": "did:key:z6MkfbK946evvPVhWkibuyDGfGGkQcpEGaWdTUCASuuwUP2n",
"alias": null
},
"title": "cli: Reduce error to warning when displaying fetch results",
"state": {
"status": "open",
"conflicts": []
},
"before": "3129de3b95e18391932368b100b25c13f4102e46",
"after": "61b58056cd8793298130cf90fa3eb52135ad04d1",
"commits": [
"61b58056cd8793298130cf90fa3eb52135ad04d1",
"28295515fb6a09d5f7998b81f120d8911504f500",
"094ffd12e0f1f5773625b7584454a33629e601a7",
"757298bc80a88082eedb9ee5e708822b97505b06",
"6eddb418d533cd324021b5f4fb87c41ea0056497",
"9a4a9edbdcd71813de4bf6390b6f6d91a86d5a86",
"ed1c1d2253101ca462aa0cf0cdd0604912918d8d",
"19d0b69a58867c9a80d20ef371e49843d9200285",
"c7c1229630c4377fd69c448b877261c783c5efeb"
],
"target": "3032ba1298c3ef21f14cb5f1146e29882f368997",
"labels": [],
"assignees": [],
"revisions": [
{
"id": "c7b70595cad4a6689f8a1d03344e08a011f11b8a",
"author": {
"id": "did:key:z6MkfbK946evvPVhWkibuyDGfGGkQcpEGaWdTUCASuuwUP2n",
"alias": null
},
"description": "That a fetch operation is not able to reach all nodes isn't\nnecessarily an error, but more an important warning. Since\nthe fetch may succeed in the background later on, it just\nfailed to reach all the nodes during the current operation.\n\nAn error is more easily understood that it failed completely,\nbut that's not necessarily the case here. So a warning\nseems more reasonable at this phase, as it's something to\nbeware of but it might be fine.\n\nIssue: 92ecb7691\nIssue: https://radicle.network/nodes/index.radicle.garden/rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5/issues/92ecb7691f7b7c4d0e6fef8fe8e2f203cc806bf1",
"base": "3129de3b95e18391932368b100b25c13f4102e46",
"oid": "c7c1229630c4377fd69c448b877261c783c5efeb",
"timestamp": 1791302981
},
{
"id": "8c8acdf1081a63115f49a25d291c40bbba7b4a4b",
"author": {
"id": "did:key:z6MkfbK946evvPVhWkibuyDGfGGkQcpEGaWdTUCASuuwUP2n",
"alias": null
},
"description": "",
"base": "3129de3b95e18391932368b100b25c13f4102e46",
"oid": "61b58056cd8793298130cf90fa3eb52135ad04d1",
"timestamp": 1791897431
}
]
}
}
{
"response": "triggered",
"run_id": {
"id": "bed9d408-fa6b-4018-b3fa-ee56c19690dc"
},
"info_url": "https://cci.rad.levitte.org//bed9d408-fa6b-4018-b3fa-ee56c19690dc.html"
}
Started at: 2026-10-13 15:17:23.488676+02:00
Commands:
$ rad clone rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5 .
✓ Creating checkout in ./...
✓ Remote cloudhead@z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT added
✓ Remote-tracking branch cloudhead@z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT/master created for z6MksFqXN3Yhqk8pTJdUGLwATkRfQvwZXPqR2qMEhbS9wzpT
✓ Remote fintohaps@z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM added
✓ Remote-tracking branch fintohaps@z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM/master created for z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM
✓ Remote lorenz@z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz added
✓ Remote-tracking branch lorenz@z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz/master created for z6MkkPvBfjP4bQmco5Dm7UGsX2ruDBieEHi8n9DVJWX5sTEz
✓ Repository successfully cloned under /opt/radcis/ci.rad.levitte.org/cci/state/bed9d408-fa6b-4018-b3fa-ee56c19690dc/w/
╭────────────────────────────────────╮
│ heartwood │
│ Radicle Heartwood Protocol & Stack │
│ 194 issues · 47 patches │
╰────────────────────────────────────╯
Run `cd ./.` to go to the repository directory.
Exit code: 0
$ rad patch checkout c7b70595cad4a6689f8a1d03344e08a011f11b8a
✓ Switched to branch patch/c7b7059 at revision 8c8acdf
✓ Branch patch/c7b7059 setup to track rad/patches/c7b70595cad4a6689f8a1d03344e08a011f11b8a
Exit code: 0
$ git config advice.detachedHead false
Exit code: 0
$ git checkout 61b58056cd8793298130cf90fa3eb52135ad04d1
HEAD is now at 61b58056 cli: Give a hint what to do when fetching fails
Exit code: 0
$ rad patch show c7b70595cad4a6689f8a1d03344e08a011f11b8a -p
╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Title cli: Reduce error to warning when displaying fetch results │
│ Patch c7b70595cad4a6689f8a1d03344e08a011f11b8a │
│ Author dsommers z6MkfbK…uuwUP2n │
│ Head 61b58056cd8793298130cf90fa3eb52135ad04d1 │
│ Base 3129de3b95e18391932368b100b25c13f4102e46 │
│ Target master │
│ Branches patch/c7b7059 │
│ Commits ahead 9, behind 10 │
│ Status open │
│ │
│ That a fetch operation is not able to reach all nodes isn't │
│ necessarily an error, but more an important warning. Since │
│ the fetch may succeed in the background later on, it just │
│ failed to reach all the nodes during the current operation. │
│ │
│ An error is more easily understood that it failed completely, │
│ but that's not necessarily the case here. So a warning │
│ seems more reasonable at this phase, as it's something to │
│ beware of but it might be fine. │
│ │
│ Issue: 92ecb7691 │
│ Issue: │
│ https://radicle.network/nodes/index.radicle.garden/rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5/issues/92ecb7691f7b7c4d0e6fef8fe8e2f203cc806bf1 │
├──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ 61b5805 cli: Give a hint what to do when fetching fails │
│ 2829551 cli: Test explorer URL for annotated tag push │
│ 094ffd1 remote-helper: Peel pushed tags for explorer URL │
│ 757298b Revert "remote-helper: Compute `HEAD` for user namespaces" │
│ 6eddb41 node/db: Index routing by node │
│ 9a4a9ed node/reactor: Terminate transports whose socket is dead │
│ ed1c1d2 protocol/node: count received pack bytes as peer liveness │
│ 19d0b69 node/routing: Enforce routingMaxSize │
│ c7c1229 cli: Reduce error to warning when displaying fetch results │
├──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ ● Revision c7b7059 @ 3129de3..c7c1229 by dsommers z6MkfbK…uuwUP2n 6 days ago │
│ ↑ Revision 8c8acdf @ 3129de3..61b5805 by dsommers z6MkfbK…uuwUP2n 14 seconds ago │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
commit 61b58056cd8793298130cf90fa3eb52135ad04d1
Author: David Sommerseth <dazo@eurephia.org>
Date: Tue Oct 13 15:10:18 2026 +0200
cli: Give a hint what to do when fetching fails
The reason a fetch operation fails might be that the list of preferred
nodes might enlist unavailable nodes. Provide a hint how to resolve
that when fetch operations fail.
Issue: c7b7059
Issue: https://radicle.network/nodes/index.radicle.garden/rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5/issues/92ecb7691f7b7c4d0e6fef8fe8e2f203cc806bf1
Disucssion: https://radicle.zulipchat.com/#narrow/channel/383670-Patches/topic/c7b70595c.3A.20Use.20warning.20when.20displaying.20fetch.20results/near/629870144
diff --git a/crates/radicle-cli/src/commands/sync.rs b/crates/radicle-cli/src/commands/sync.rs
index c071e40148..1b7a75b7d3 100644
--- a/crates/radicle-cli/src/commands/sync.rs
+++ b/crates/radicle-cli/src/commands/sync.rs
@@ -568,6 +568,8 @@ fn display_fetch_result(result: &sync::FetcherResult, verbose: bool) {
if succeeded > 0 {
term::info!("Successfully fetched from the following seeds:");
display_success(results.success(), verbose)
+ } else {
+ term::info!("If nodes are consistently failing, please review configured preferred seed nodes.");
}
}
}
commit 28295515fb6a09d5f7998b81f120d8911504f500
Author: Daniel Norman <daniel@norman.life>
Date: Tue Oct 6 17:44:15 2026 +0200
cli: Test explorer URL for annotated tag push
- Push an annotated tag and expect the tree URL of the tagged commit
- Currently fails: the URL uses the tag object id
diff --git a/crates/radicle-cli/examples/rad-patch-open-explore.md b/crates/radicle-cli/examples/rad-patch-open-explore.md
index 882eca5793..242a7efc21 100644
--- a/crates/radicle-cli/examples/rad-patch-open-explore.md
+++ b/crates/radicle-cli/examples/rad-patch-open-explore.md
@@ -46,3 +46,17 @@ $ git push rad master
To rad://z3yXbb1sR6UG6ixxV2YF9jUP7ABra/z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk
f2de534..b2b6432 master -> master
```
+
+Pushing an annotated tag outputs a URL to the source tree of the tagged commit,
+not the tag object.
+
+``` (stderr)
+$ git tag -a v1.0 -m "Release v1.0"
+$ git push rad v1.0
+✓ Synced with 1 seed(s)
+
+ https://radicle.network/nodes/[..]/rad:z3yXbb1sR6UG6ixxV2YF9jUP7ABra/tree/b2b6432af93f8fe188e32d400263021b602cfec8
+
+To rad://z3yXbb1sR6UG6ixxV2YF9jUP7ABra/z6Mkt67GdsW7715MEfRuP4pSZxJRJh6kj6Y48WRqVv4N1tRk
+ * [new tag] v1.0 -> v1.0
+```
commit 094ffd12e0f1f5773625b7584454a33629e601a7
Author: Daniel Norman <daniel@norman.life>
Date: Tue Oct 6 17:45:15 2026 +0200
remote-helper: Peel pushed tags for explorer URL
- Use the tagged commit for the tree URL, not the tag object
- Print no URL if the ref does not point to a commit
diff --git a/crates/radicle-remote-helper/src/push.rs b/crates/radicle-remote-helper/src/push.rs
index fe98a57436..7d5129607e 100644
--- a/crates/radicle-remote-helper/src/push.rs
+++ b/crates/radicle-remote-helper/src/push.rs
@@ -850,7 +850,17 @@ where
patch_merge_all(old, head, dst, working, &mut patches, signer, &identity)?;
}
}
- Ok(Some(ExplorerResource::Tree { oid: head }))
+ // Annotated tags point to a tag object, but the explorer can only show
+ // the tree of a commit.
+ let resource = working
+ .find_object(head.into(), None)?
+ .peel_to_commit()
+ .ok()
+ .map(|commit| ExplorerResource::Tree {
+ oid: commit.id().into(),
+ });
+
+ Ok(resource)
}
/// Revert all patches that are no longer included in the base branch.
commit 757298bc80a88082eedb9ee5e708822b97505b06
Author: Lorenz Leutgeb <lorenz.leutgeb@radicle.dev>
Date: Thu Oct 8 12:34:56 2026 +0200
Revert "remote-helper: Compute `HEAD` for user namespaces"
This reverts commit 2747c52beb15db057f289a8f96774ee246de5284.
I forgot that this is only supported from Git 2.48 onwards.
diff --git a/crates/radicle-cli/examples/git/git-fetch.md b/crates/radicle-cli/examples/git/git-fetch.md
index 40784ed1d6..652254b7fb 100644
--- a/crates/radicle-cli/examples/git/git-fetch.md
+++ b/crates/radicle-cli/examples/git/git-fetch.md
@@ -19,7 +19,6 @@ From rad://z42hL2jL4XNk6K8oHQaSWfMgCL7ji/z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzq
```
$ git branch -r
- alice@z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/HEAD -> alice@z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/master
alice@z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/alice/1
alice@z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/master
rad/master
diff --git a/crates/radicle-remote-helper/src/list.rs b/crates/radicle-remote-helper/src/list.rs
index 2e4268d66c..0d0fca7781 100644
--- a/crates/radicle-remote-helper/src/list.rs
+++ b/crates/radicle-remote-helper/src/list.rs
@@ -43,20 +43,6 @@ pub(super) fn for_fetch<R: ReadRepository + cob::Store<Namespace = NodeId> + 'st
let mut lines = Vec::new();
if let Some(namespace) = url.namespace {
- if let Some(default_branch) = stored
- .identity_doc()
- .map(|doc| doc.default_branch().ok().map(|branch| branch.to_owned()))
- .ok()
- .flatten()
- && stored
- .reference(&namespace, &default_branch)
- .ok()
- .and_then(|reference| reference.target())
- .is_some()
- {
- lines.push(format!("@{default_branch} HEAD"));
- }
-
// Listing namespaced refs.
for (name, oid) in stored.references_of(&namespace)? {
lines.push(format!("{oid} {name}"));
commit 6eddb418d533cd324021b5f4fb87c41ea0056497
Author: Yorgos Saslis <yorgos.work@proton.me>
Date: Thu Sep 17 17:00:34 2026 +0300
node/db: Index routing by node
`sync_routing` calls `get_inventory(&from)` once per inbound inventory
announcement, to work out which of a peer's previously-announced repos are
no longer in its inventory:
// crates/radicle-protocol/src/service.rs
synced.removed.extend(
self.db.routing().get_inventory(&from)?
.into_iter()
.filter(|rid| !included.contains(rid)),
);
That is `SELECT repo FROM routing WHERE node = ?`. The table's only index is
the implicit one for the primary key `(repo, node)`, whose leading column is
`repo`, so the lookup cannot use it and scans instead. The index fixes that.
diff --git a/crates/radicle/src/node/db.rs b/crates/radicle/src/node/db.rs
index d717ec6fd7..5a417b75f1 100644
--- a/crates/radicle/src/node/db.rs
+++ b/crates/radicle/src/node/db.rs
@@ -40,6 +40,7 @@ const MIGRATIONS: &[&str] = &[
include_str!("db/migrations/7.sql"),
include_str!("db/migrations/8.sql"),
include_str!("db/migrations/9.sql"),
+ include_str!("db/migrations/10.sql"),
];
#[derive(Error, Debug)]
diff --git a/crates/radicle/src/node/db/migrations/10.sql b/crates/radicle/src/node/db/migrations/10.sql
new file mode 100644
index 0000000000..6fb00e727d
--- /dev/null
+++ b/crates/radicle/src/node/db/migrations/10.sql
@@ -0,0 +1,5 @@
+-- `get_inventory` looks routing entries up by node alone, once per inbound
+-- inventory announcement. The table's only index is the implicit one for the
+-- primary key `(repo, node)`, whose leading column is `repo`, so that lookup
+-- scans -- tens of thousands of rows on a seed following a busy network.
+create index if not exists "routing_node" on "routing" ("node");
commit 9a4a9edbdcd71813de4bf6390b6f6d91a86d5a86
Author: Yorgos Saslis <yorgos.work@proton.me>
Date: Wed Sep 23 18:11:50 2026 +0300
node/reactor: Terminate transports whose socket is dead
A transport whose peer has gone away is never terminated, and the
reactor then spins on it at 100% of a core. On every iteration the reactor
[re-registers the socket with whatever interests the transport still
reports][1], `poll` returns it as ready immediately, and the I/O
handlers do nothing with it. The service thread stops serving the
control socket, so `rad node status` times out and the node looks
offline while its container is still up. Restarting a node only helps
until the next peer disconnect.
How the transport loses track of the socket:
1. A peer resets the connection between `poll` returning and the event
being handled. The event carries no error flag, so the reactor
[hands it to the transport instead of reaping it][2].
2. On the writable event the transport [first asks whether the session
is established][3]. That answer is [derived from the session
artifact][4], which for a TCP stream [comes from `getpeername`][5].
On a reset socket that fails with ENOTCONN, so the transport takes
the "handshake in progress" branch.
3. That branch [pokes the session with a zero-length write and
discards the result][6]. The write [passes straight through to the
socket][7], and a zero-length send is exactly what surfaces the
pending ECONNRESET. The one signal that the peer is gone is thrown
away, and the transport [re-arms its write interest][8] instead.
4. On the readable event the read also [passes straight through to
the socket][9] and returns zero bytes, which the transport
[ignores as well][10].
Nothing ever [marks the transport terminated][11], so its [interest
set][12] stays READABLE and WRITABLE on a closed socket forever.
On an affected node, strace shows thousands of failing `getpeername`
calls in 5 seconds, 5 per iteration, with `epoll_pwait` returning 1,200
times a second.
Two changes:
- Propagate a real error from the handshake-branch zero-length write
as a termination instead of discarding it. WouldBlock, Interrupted
and WriteZero stay non-fatal, matching the established branch. A
socket whose error was already consumed elsewhere reports EPIPE
here, so this also reaps transports that are already stuck.
- Treat a zero-byte read as end-of-file once the transport is
[active][13]. From that point reads pass straight through to the
socket. During the handshake the protocol layer [returns zero after
consuming a handshake step][14], so that case keeps its current
behaviour. Without this a half-closed peer also spins, until the
service's [stale connection reaper][15] disconnects it about
[2 minutes][16] later.
The termination event [reaches the wire layer][17], which [unregisters
the transport whether or not it has a peer entry for it][18]. That
covers inbound connections that die mid-handshake, which have no node
ID and so are invisible to the stale reaper.
Tests use real loopback sockets: a peer that resets (SO_LINGER 0), the
same after the pending error has already been taken, and a peer that
half-closes. All three failed before this change.
[1]: https://radicle.network/nodes/seed.radicle.garden/rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5/tree/0e5a56f25f51d07290613a9363bd8c0c9fc8e0be/crates/radicle-node/src/reactor.rs#L350
[2]: https://radicle.network/nodes/seed.radicle.garden/rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5/tree/0e5a56f25f51d07290613a9363bd8c0c9fc8e0be/crates/radicle-node/src/reactor.rs#L444
[3]: https://radicle.network/nodes/seed.radicle.garden/rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5/tree/0e5a56f25f51d07290613a9363bd8c0c9fc8e0be/crates/radicle-node/src/reactor/transport.rs#L206
[4]: https://radicle.network/nodes/seed.radicle.garden/rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5/tree/0e5a56f25f51d07290613a9363bd8c0c9fc8e0be/crates/radicle-node/src/reactor/session.rs#L21
[5]: https://radicle.network/nodes/seed.radicle.garden/rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5/tree/0e5a56f25f51d07290613a9363bd8c0c9fc8e0be/crates/radicle-node/src/reactor/session.rs#L180
[6]: https://radicle.network/nodes/seed.radicle.garden/rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5/tree/0e5a56f25f51d07290613a9363bd8c0c9fc8e0be/crates/radicle-node/src/reactor/transport.rs#L207
[7]: https://radicle.network/nodes/seed.radicle.garden/rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5/tree/0e5a56f25f51d07290613a9363bd8c0c9fc8e0be/crates/radicle-node/src/reactor/session.rs#L109
[8]: https://radicle.network/nodes/seed.radicle.garden/rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5/tree/0e5a56f25f51d07290613a9363bd8c0c9fc8e0be/crates/radicle-node/src/reactor/transport.rs#L208
[9]: https://radicle.network/nodes/seed.radicle.garden/rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5/tree/0e5a56f25f51d07290613a9363bd8c0c9fc8e0be/crates/radicle-node/src/reactor/session.rs#L78
[10]: https://radicle.network/nodes/seed.radicle.garden/rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5/tree/0e5a56f25f51d07290613a9363bd8c0c9fc8e0be/crates/radicle-node/src/reactor/transport.rs#L298
[11]: https://radicle.network/nodes/seed.radicle.garden/rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5/tree/0e5a56f25f51d07290613a9363bd8c0c9fc8e0be/crates/radicle-node/src/reactor/transport.rs#L144
[12]: https://radicle.network/nodes/seed.radicle.garden/rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5/tree/0e5a56f25f51d07290613a9363bd8c0c9fc8e0be/crates/radicle-node/src/reactor/transport.rs#L317
[13]: https://radicle.network/nodes/seed.radicle.garden/rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5/tree/0e5a56f25f51d07290613a9363bd8c0c9fc8e0be/crates/radicle-node/src/reactor/transport.rs#L45
[14]: https://radicle.network/nodes/seed.radicle.garden/rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5/tree/0e5a56f25f51d07290613a9363bd8c0c9fc8e0be/crates/radicle-node/src/reactor/session.rs#L99
[15]: https://radicle.network/nodes/seed.radicle.garden/rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5/tree/0e5a56f25f51d07290613a9363bd8c0c9fc8e0be/crates/radicle-protocol/src/service.rs#L2417
[16]: https://radicle.network/nodes/seed.radicle.garden/rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5/tree/0e5a56f25f51d07290613a9363bd8c0c9fc8e0be/crates/radicle-protocol/src/service.rs#L91
[17]: https://radicle.network/nodes/seed.radicle.garden/rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5/tree/0e5a56f25f51d07290613a9363bd8c0c9fc8e0be/crates/radicle-node/src/wire.rs#L819
[18]: https://radicle.network/nodes/seed.radicle.garden/rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5/tree/0e5a56f25f51d07290613a9363bd8c0c9fc8e0be/crates/radicle-node/src/wire.rs#L356
diff --git a/crates/radicle-node/src/reactor/transport.rs b/crates/radicle-node/src/reactor/transport.rs
index 5343df917e..c0a470bbc9 100644
--- a/crates/radicle-node/src/reactor/transport.rs
+++ b/crates/radicle-node/src/reactor/transport.rs
@@ -204,8 +204,12 @@ impl<S: Session> Transport<S> {
use io::ErrorKind::*;
if !self.session.is_established() {
- let _ = self.session.write(&[]);
- self.write_intent = true;
+ match self.session.write(&[]) {
+ Err(err) if !matches!(err.kind(), WouldBlock | Interrupted | WriteZero) => {
+ events.push(self.terminate(err));
+ }
+ _ => self.write_intent = true,
+ }
return;
}
@@ -295,6 +299,9 @@ impl<S: Session> Transport<S> {
loop {
match self.session.read(self.read_buffer.as_mut()) {
+ Ok(0) if self.state == TransportState::Active => {
+ events.push(self.terminate(UnexpectedEof.into()));
+ }
Ok(0) => {}
Ok(len) => {
events.push(SessionEvent::Data(self.read_buffer[..len].to_vec()));
@@ -364,3 +371,94 @@ impl<S: Session> super::BufferWrite for Transport<S> {
self.write_intent = true;
}
}
+
+#[cfg(all(test, unix, feature = "socket2"))]
+mod tests {
+ use std::net::{Shutdown, TcpListener};
+ use std::time::Duration;
+ use std::{net, thread};
+
+ use mio::net::TcpStream;
+
+ use super::*;
+
+ fn connected_pair() -> (TcpStream, net::TcpStream) {
+ let listener = TcpListener::bind("127.0.0.1:0").unwrap();
+ let local = net::TcpStream::connect(listener.local_addr().unwrap()).unwrap();
+ let (remote, _) = listener.accept().unwrap();
+ local.set_nonblocking(true).unwrap();
+
+ (TcpStream::from_std(local), remote)
+ }
+
+ fn active_transport() -> (Transport<TcpStream>, net::TcpStream) {
+ let (local, remote) = connected_pair();
+ let transport = Transport::with_session(local).unwrap();
+ assert_eq!(transport.state, TransportState::Active);
+
+ (transport, remote)
+ }
+
+ fn reset(remote: net::TcpStream) {
+ socket2::SockRef::from(&remote)
+ .set_linger(Some(Duration::ZERO))
+ .unwrap();
+ drop(remote);
+ thread::sleep(Duration::from_millis(100));
+ }
+
+ fn handle_ready(transport: &mut Transport<TcpStream>) -> Vec<SessionEvent<TcpStream>> {
+ let mut events = Vec::new();
+ transport.handle_io(Interest::WRITABLE, &mut events);
+ transport.handle_io(Interest::READABLE, &mut events);
+ events
+ }
+
+ fn assert_terminated(transport: &Transport<TcpStream>, events: &[SessionEvent<TcpStream>]) {
+ assert!(
+ events
+ .iter()
+ .any(|event| matches!(event, SessionEvent::Terminated(_))),
+ "transport was not terminated"
+ );
+ assert_eq!(transport.state, TransportState::Terminated);
+ assert_eq!(transport.interests(), None);
+ }
+
+ #[test]
+ fn reset_peer_is_terminated() {
+ let (mut transport, remote) = active_transport();
+ reset(remote);
+
+ let events = handle_ready(&mut transport);
+
+ assert_terminated(&transport, &events);
+ }
+
+ #[test]
+ fn reset_peer_is_terminated_after_error_was_consumed() {
+ let (mut transport, remote) = active_transport();
+ reset(remote);
+ let error = transport.session.take_error().unwrap();
+ assert!(
+ error.is_some(),
+ "expected a pending socket error after reset"
+ );
+
+ let events = handle_ready(&mut transport);
+
+ assert_terminated(&transport, &events);
+ }
+
+ #[test]
+ fn half_closed_peer_is_terminated_on_eof() {
+ let (mut transport, remote) = active_transport();
+ remote.shutdown(Shutdown::Write).unwrap();
+ thread::sleep(Duration::from_millis(100));
+
+ let mut events = Vec::new();
+ transport.handle_io(Interest::READABLE, &mut events);
+
+ assert_terminated(&transport, &events);
+ }
+}
commit ed1c1d2253101ca462aa0cf0cdd0604912918d8d
Author: Yorgos Saslis <yorgos.work@proton.me>
Date: Wed Sep 16 10:11:13 2026 +0300
protocol/node: count received pack bytes as peer liveness
`disconnect_unresponsive_peers` drops any session whose `last_active` is
older than STALE_CONNECTION_TIMEOUT (hard-coded to 2 minutes).
`last_active` is written in only two places: `to_connected`, and
`handle_message` (the gossip message handler).
Pack data never reaches `handle_message` which is what marks . It arrives as `FrameData::Git`
and goes straight to the worker thread over a channel. So a peer busy
sending us a large pack appears completely idle, and
after two minutes we hang up on it mid-transfer. gitoxide then sees a
truncated stream and the fetch fails with:
Clone of rad:<rid> from <nid> failed with
'gix fetch error: Failed to consume the pack sent by the remote'
This only affects large repositories. Small ones finish inside the
window. Neither `limits.fetchPackReceive` nor `limits.fetchTimeout`
bounds it, and it reproduces against every peer, since any peer sending a
big pack goes gossip-quiet for the duration.
We now treat arriving pack bytes as liveness, symmetrically with the
`FrameData::Gossip` arm immediately above, which already calls
`received_message`.
Note that a remote that spends more than STALE_CONNECTION_TIMEOUT
server-side (e.g. enumerating or compressing a large pack before sending
its first byte), is still reaped, and `limits.fetchTimeout` does not help.
This is not a change in behaviour. Gossip silence would have reaped it anyway.
diff --git a/crates/radicle-node/src/wire.rs b/crates/radicle-node/src/wire.rs
index 9497bb1487..454929ca72 100644
--- a/crates/radicle-node/src/wire.rs
+++ b/crates/radicle-node/src/wire.rs
@@ -784,6 +784,9 @@ where
data: FrameData::Git(data),
..
})) => {
+ // Counts as liveness: this peer is sending something
+ self.service.received_bytes(*nid);
+
if let Some(s) = streams.get_mut(&stream) {
metrics.received_git_bytes += data.len();
// Send via channel to the worker thread
diff --git a/crates/radicle-protocol/src/service.rs b/crates/radicle-protocol/src/service.rs
index be4954b5e4..e874039601 100644
--- a/crates/radicle-protocol/src/service.rs
+++ b/crates/radicle-protocol/src/service.rs
@@ -1415,6 +1415,13 @@ where
self.dequeue_fetches();
}
+ /// Record that raw data was received from a peer on a worker stream.
+ pub fn received_bytes(&mut self, remote: NodeId) {
+ if let Some(session) = self.sessions.get_mut(&remote) {
+ session.last_active = self.clock;
+ }
+ }
+
pub fn received_message(&mut self, remote: NodeId, message: Message) {
if let Err(err) = self.handle_message(&remote, message) {
// If there's an error, stop processing messages from this peer.
commit 19d0b69a58867c9a80d20ef371e49843d9200285
Author: Yorgos Saslis <yorgos.work@proton.me>
Date: Thu Sep 17 17:13:22 2026 +0300
node/routing: Enforce routingMaxSize
`limits.routingMaxSize` is documented as "Number of routing table entries
before we start pruning", but the implementation diverges from this.
DELETE FROM routing WHERE node <> ?1 AND rowid IN
(SELECT rowid FROM routing WHERE timestamp < ?2 ORDER BY timestamp LIMIT ?3)
`?2` is `now - routingMaxAge`. So if nothing is older than routingMaxAge,
nothing is deleted, however far over routingMaxSize the table has grown. On a
seed following a busy network, entries are refreshed by gossip faster than
they age out, and the cap is never reachable.
For example, on a permissive node with routingMaxSize=1000, routingMaxAge=7d,
I observed:
routing rows 94,254
older than routingMaxAge, ie. prunable 16
Dropping routingMaxAge to 1 day cleared the backlog the 7-day setting had
accumulated -- from 94,254 down to 82,400 rows -- but then stopped, with
8 rows prunable and the table still ... 82x over the cap. The `routing` table
size is therefore decided by arrival rate; the configured max size has no
effect on it.
Split the two evictions so each does what its name says:
- size eviction removes the oldest entries down to `routingMaxSize`,
regardless of age;
- age eviction then removes anything still older than
`routingMaxAge`.
Size runs first: it takes the oldest rows, so the age pass only fires when
the table was near its limit.
diff --git a/crates/radicle/src/node/routing.rs b/crates/radicle/src/node/routing.rs
index f39db28c3f..c2c5bf0f81 100644
--- a/crates/radicle/src/node/routing.rs
+++ b/crates/radicle/src/node/routing.rs
@@ -221,20 +221,33 @@ impl Store for Database {
limit: Option<usize>,
ignore: &NodeId,
) -> Result<usize, Error> {
- let limit: i64 = limit
- .and_then(|limit| i64::try_from(limit).ok())
- .unwrap_or(i64::MAX);
- let mut stmt = self.db.prepare(
- "DELETE FROM routing
- WHERE node <> ?1 AND rowid IN
- (SELECT rowid FROM routing WHERE timestamp < ?2 ORDER BY timestamp LIMIT ?3)",
- )?;
+ let mut pruned = 0;
+
+ if let Some(limit) = limit {
+ let limit = i64::try_from(limit).unwrap_or(i64::MAX);
+ let mut stmt = self.db.prepare(
+ "DELETE FROM routing
+ WHERE node <> ?1 AND rowid IN
+ (SELECT rowid FROM routing
+ WHERE node <> ?1
+ ORDER BY timestamp ASC
+ LIMIT ?2)",
+ )?;
+ stmt.bind((1, ignore))?;
+ stmt.bind((2, limit))?;
+ stmt.next()?;
+ pruned += self.db.change_count();
+ }
+
+ let mut stmt = self
+ .db
+ .prepare("DELETE FROM routing WHERE node <> ?1 AND timestamp < ?2")?;
stmt.bind((1, ignore))?;
stmt.bind((2, &oldest))?;
- stmt.bind((3, limit))?;
stmt.next()?;
+ pruned += self.db.change_count();
- Ok(self.db.change_count())
+ Ok(pruned)
}
fn count(&self, id: &RepoId) -> Result<usize, Error> {
commit c7c1229630c4377fd69c448b877261c783c5efeb
Author: David Sommerseth <dazo@eurephia.org>
Date: Tue Oct 6 18:03:04 2026 +0200
cli: Reduce error to warning when displaying fetch results
That a fetch operation is not able to reach all nodes isn't
necessarily an error, but more an important warning. Since
the fetch may succeed in the background later on, it just
failed to reach all the nodes during the current operation.
An error is more easily understood that it failed completely,
but that's not necessarily the case here. So a warning
seems more reasonable at this phase, as it's something to
beware of but it might be fine.
Issue: 92ecb7691
Issue: https://radicle.network/nodes/index.radicle.garden/rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5/issues/92ecb7691f7b7c4d0e6fef8fe8e2f203cc806bf1
diff --git a/crates/radicle-cli/src/commands/sync.rs b/crates/radicle-cli/src/commands/sync.rs
index 9767298195..c071e40148 100644
--- a/crates/radicle-cli/src/commands/sync.rs
+++ b/crates/radicle-cli/src/commands/sync.rs
@@ -549,12 +549,12 @@ fn display_fetch_result(result: &sync::FetcherResult, verbose: bool) {
let target = failed.target();
let succeeded = progress.succeeded();
let missed = failed.missed_nodes();
- term::error(format!(
+ term::warning(format!(
"Fetched from {} preferred seed(s), could not reach {} seed(s)",
succeeded,
target.replicas().lower_bound(),
));
- term::error(format!(
+ term::warning(format!(
"Could not replicate from {} preferred seed(s)",
missed.len()
));
Exit code: 0
shell: 'export RUSTDOCFLAGS=''-D warnings'' cargo --version rustc --version cargo fmt --check cargo clippy --all-targets --workspace -- --deny warnings cargo build --all-targets --workspace cargo doc --workspace --no-deps --all-features cargo test --workspace --no-fail-fast '
Commands:
$ podman run --name bed9d408-fa6b-4018-b3fa-ee56c19690dc -v /opt/radcis/ci.rad.levitte.org/cci/state/bed9d408-fa6b-4018-b3fa-ee56c19690dc/s:/bed9d408-fa6b-4018-b3fa-ee56c19690dc/s:ro -v /opt/radcis/ci.rad.levitte.org/cci/state/bed9d408-fa6b-4018-b3fa-ee56c19690dc/w:/bed9d408-fa6b-4018-b3fa-ee56c19690dc/w -w /bed9d408-fa6b-4018-b3fa-ee56c19690dc/w -v /opt/radcis/ci.rad.levitte.org/.radicle:/${id}/.radicle:ro -e RAD_HOME=/${id}/.radicle rust:trixie bash /bed9d408-fa6b-4018-b3fa-ee56c19690dc/s/script.sh
+ export 'RUSTDOCFLAGS=-D warnings'
+ RUSTDOCFLAGS='-D warnings'
+ cargo --version
info: syncing channel updates for '1.98.0-x86_64-unknown-linux-gnu'
info: latest update on 2026-08-20, rust version 1.98.0 (88d9e12ae 2026-08-18)
info: downloading component 'cargo'
info: downloading component 'clippy'
info: downloading component 'rust-docs'
info: downloading component 'rust-src'
info: downloading component 'rust-std'
info: downloading component 'rustc'
info: downloading component 'rustfmt'
info: installing component 'cargo'
info: installing component 'clippy'
info: installing component 'rust-docs'
info: installing component 'rust-src'
info: installing component 'rust-std'
info: installing component 'rustc'
info: installing component 'rustfmt'
cargo 1.98.0 (797e8a9bc 2026-08-05)
+ rustc --version
rustc 1.98.0 (88d9e12ae 2026-08-18)
+ cargo fmt --check
Diff in /bed9d408-fa6b-4018-b3fa-ee56c19690dc/w/crates/radicle-cli/src/commands/sync.rs:569:
term::info!("Successfully fetched from the following seeds:");
display_success(results.success(), verbose)
} else {
- term::info!("If nodes are consistently failing, please review configured preferred seed nodes.");
+ term::info!(
+ "If nodes are consistently failing, please review configured preferred seed nodes."
+ );
}
}
}
Exit code: 1
{
"response": "finished",
"result": "failure"
}