Pilot findings: 5 real-world fixes from 2026-07-12 deploy #21
@@ -365,7 +365,9 @@ async fn cmd_status(args: PeerArgs) -> Result<()> {
|
||||
|
||||
async fn cmd_prefetch(args: PrefetchArgs) -> Result<()> {
|
||||
let (workspace, resolved, fp) = setup_peer(&args.peer)?;
|
||||
let target_dir = workspace.join("target").join(&resolved.profile);
|
||||
let target_dir = workspace
|
||||
.join("target")
|
||||
.join(target_subdir_for(&resolved.profile));
|
||||
|
||||
let (client, conn) = connect_peer(&resolved).await?;
|
||||
|
||||
@@ -485,7 +487,9 @@ async fn resolve_pin(
|
||||
|
||||
async fn cmd_build(args: BuildArgs) -> Result<()> {
|
||||
let (workspace, resolved, fp) = setup_peer(&args.peer)?;
|
||||
let target_dir = workspace.join("target").join(&resolved.profile);
|
||||
let target_dir = workspace
|
||||
.join("target")
|
||||
.join(target_subdir_for(&resolved.profile));
|
||||
tracing::info!("fingerprint {}", fp);
|
||||
|
||||
let (client, conn) = connect_peer(&resolved).await?;
|
||||
@@ -618,15 +622,31 @@ async fn cmd_pin(args: PinArgs) -> Result<()> {
|
||||
// 2. Publish the tag → BlobId mapping.
|
||||
call_put_tag(&conn, &args.name, blob_id.as_bytes()).await?;
|
||||
|
||||
// Field finding 2026-07-12: also stash the fingerprint under a
|
||||
// companion tag `<name>.fingerprint`. Downstream nodes fed by
|
||||
// prewarm need to know the fingerprint to publish their own
|
||||
// ref (`PutRef(fingerprint → blob)`) — otherwise a `build` on
|
||||
// the same source misses even though the blob is present.
|
||||
let companion = fingerprint_companion_tag(&args.name);
|
||||
call_put_tag(&conn, &companion, fp.as_bytes()).await?;
|
||||
|
||||
conn.close(quinn::VarInt::from_u32(0), b"done");
|
||||
client.shutdown().await;
|
||||
|
||||
println!("pinned: {}", args.name);
|
||||
println!("companion: {}", companion);
|
||||
println!("fingerprint: {}", fp);
|
||||
println!("blob: {}", blob_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Field finding 2026-07-12: companion tag suffix used by `pin` to
|
||||
/// stash the fingerprint alongside the blob-id mapping. Prewarm reads
|
||||
/// it to know what ref to publish downstream.
|
||||
fn fingerprint_companion_tag(name: &str) -> String {
|
||||
format!("{name}.fingerprint")
|
||||
}
|
||||
|
||||
async fn cmd_unpin(args: UnpinArgs) -> Result<()> {
|
||||
let workspace = args
|
||||
.peer
|
||||
@@ -880,6 +900,23 @@ async fn cmd_prewarm(args: PrewarmArgs) -> Result<()> {
|
||||
// there would still miss.
|
||||
call_put_tag(&down_conn, &args.pin, blob_id.as_bytes()).await?;
|
||||
|
||||
// Field finding 2026-07-12: also publish the fingerprint → blob
|
||||
// ref downstream when we can find the companion tag written by
|
||||
// `pin`. Without this, a `build` on downstream MISSES on the
|
||||
// matching fingerprint even though prewarm just put the blob
|
||||
// there — the runner would rebuild from scratch and re-upload.
|
||||
// Best-effort: absence of the companion tag (older pin) just
|
||||
// means we skip this step.
|
||||
let companion = fingerprint_companion_tag(&args.pin);
|
||||
let fingerprint_published = match call_get_tag(&up_conn, &companion).await? {
|
||||
Some(fp_bytes) => {
|
||||
call_put_tag(&down_conn, &companion, &fp_bytes).await?;
|
||||
call_put_ref(&down_conn, &fp_bytes, blob_id.as_bytes()).await?;
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
};
|
||||
|
||||
up_conn.close(quinn::VarInt::from_u32(0), b"done");
|
||||
down_conn.close(quinn::VarInt::from_u32(0), b"done");
|
||||
up_client.shutdown().await;
|
||||
@@ -906,6 +943,14 @@ async fn cmd_prewarm(args: PrewarmArgs) -> Result<()> {
|
||||
"dedup save: {} chunks",
|
||||
(stat.chunk_count as usize).saturating_sub(uploaded_chunks)
|
||||
);
|
||||
println!(
|
||||
"ref published: {}",
|
||||
if fingerprint_published {
|
||||
"yes (fingerprint→blob mapped downstream — build will HIT)"
|
||||
} else {
|
||||
"no (companion tag absent — build will still MISS on downstream)"
|
||||
}
|
||||
);
|
||||
println!("elapsed: {:?}", transfer_elapsed);
|
||||
println!("────────────────────────────────────────────────────");
|
||||
Ok(())
|
||||
@@ -928,3 +973,42 @@ fn run_cargo(
|
||||
let status = cmd.status().context("spawning cargo build")?;
|
||||
Ok(status)
|
||||
}
|
||||
|
||||
/// Map a cargo profile name to the directory under `target/` cargo
|
||||
/// actually writes to. Cargo aliases `dev`/`test` → `debug/` and
|
||||
/// `release`/`bench` → `release/`; custom profiles get a dir of their
|
||||
/// own name. First discovered in the field 2026-07-12 — silent upload
|
||||
/// skip when the pilot ran with the default `profile = "dev"`.
|
||||
fn target_subdir_for(profile: &str) -> &str {
|
||||
match profile {
|
||||
"dev" | "test" => "debug",
|
||||
"release" | "bench" => "release",
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn target_subdir_matches_cargo_layout() {
|
||||
assert_eq!(target_subdir_for("dev"), "debug");
|
||||
assert_eq!(target_subdir_for("test"), "debug");
|
||||
assert_eq!(target_subdir_for("release"), "release");
|
||||
assert_eq!(target_subdir_for("bench"), "release");
|
||||
assert_eq!(target_subdir_for("prod"), "prod");
|
||||
assert_eq!(target_subdir_for("hot-loop"), "hot-loop");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fingerprint_companion_tag_uses_dotted_suffix() {
|
||||
// Kept as a plain function so the shape is easy for a
|
||||
// downstream consumer to reproduce without linking. Any change
|
||||
// here must be coordinated with `cmd_prewarm`.
|
||||
assert_eq!(
|
||||
fingerprint_companion_tag("clawverse:main:latest"),
|
||||
"clawverse:main:latest.fingerprint"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,6 +232,77 @@ impl std::fmt::Display for Fingerprint {
|
||||
/// `target_dir` should be `<workspace>/target/<profile>` — the
|
||||
/// caller resolves the profile so this function doesn't have to
|
||||
/// know about cargo's directory layout beyond "the deps live here".
|
||||
/// Field finding 2026-07-12: walk `src` recursively in sorted order
|
||||
/// and append every regular file + directory to `tar` under
|
||||
/// `<archive_prefix>/<rel>`. Two calls on byte-identical trees produce
|
||||
/// byte-identical tar output (given `HeaderMode::Deterministic`), even
|
||||
/// across nodes whose `read_dir` returns entries in different orders.
|
||||
///
|
||||
/// Symlinks are appended as symlinks (the tar crate handles the header
|
||||
/// bookkeeping); anything else — sockets, fifos — is skipped.
|
||||
fn append_dir_sorted<W: std::io::Write>(
|
||||
tar: &mut tar::Builder<W>,
|
||||
archive_prefix: &str,
|
||||
src: &Path,
|
||||
) -> Result<()> {
|
||||
let mut stack: Vec<(PathBuf, String)> =
|
||||
vec![(src.to_path_buf(), archive_prefix.to_string())];
|
||||
while let Some((dir, archive_dir)) = stack.pop() {
|
||||
let mut entries: Vec<_> = std::fs::read_dir(&dir)
|
||||
.with_context(|| format!("reading {}", dir.display()))?
|
||||
.filter_map(|e| e.ok())
|
||||
.collect();
|
||||
// Sort by filename bytes — stable across filesystems.
|
||||
entries.sort_by(|a, b| a.file_name().cmp(&b.file_name()));
|
||||
for entry in entries {
|
||||
let ft = entry.file_type()?;
|
||||
let name = entry.file_name();
|
||||
let name_str = match name.to_str() {
|
||||
Some(s) => s,
|
||||
None => continue,
|
||||
};
|
||||
let archive_path = format!("{}/{}", archive_dir, name_str);
|
||||
let full = entry.path();
|
||||
if ft.is_dir() {
|
||||
// Push for later processing; also emit the directory
|
||||
// header so an empty dir survives the roundtrip.
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_entry_type(tar::EntryType::Directory);
|
||||
header.set_size(0);
|
||||
header.set_mode(0o755);
|
||||
header.set_mtime(0);
|
||||
header.set_cksum();
|
||||
tar.append_data(
|
||||
&mut header,
|
||||
format!("{}/", archive_path),
|
||||
std::io::empty(),
|
||||
)?;
|
||||
stack.push((full, archive_path));
|
||||
} else if ft.is_file() {
|
||||
let mut f = std::fs::File::open(&full)
|
||||
.with_context(|| format!("opening {}", full.display()))?;
|
||||
tar.append_file(&archive_path, &mut f)
|
||||
.with_context(|| format!("appending {}", full.display()))?;
|
||||
} else if ft.is_symlink() {
|
||||
let link_target = std::fs::read_link(&full)
|
||||
.with_context(|| format!("reading symlink {}", full.display()))?;
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_entry_type(tar::EntryType::Symlink);
|
||||
header.set_size(0);
|
||||
header.set_mode(0o777);
|
||||
header.set_mtime(0);
|
||||
header
|
||||
.set_link_name(&link_target)
|
||||
.context("setting symlink header link_name")?;
|
||||
header.set_cksum();
|
||||
tar.append_data(&mut header, &archive_path, std::io::empty())?;
|
||||
}
|
||||
// Other types (sockets, fifos) are skipped.
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn capture_target(target_dir: &Path) -> Result<Vec<u8>> {
|
||||
if !target_dir.is_dir() {
|
||||
bail!(
|
||||
@@ -247,10 +318,21 @@ pub fn capture_target(target_dir: &Path) -> Result<Vec<u8>> {
|
||||
tar.mode(tar::HeaderMode::Deterministic);
|
||||
tar.follow_symlinks(false);
|
||||
|
||||
// Field finding 2026-07-12: `tar::Builder::append_dir_all` walks
|
||||
// via `std::fs::read_dir`, which returns entries in
|
||||
// filesystem-native order — non-deterministic across nodes even
|
||||
// when contents are byte-identical. That drives blob-id drift
|
||||
// between peers building the same source with the same rustc,
|
||||
// which in turn caps cross-node dedup savings.
|
||||
//
|
||||
// Walk each subdir ourselves + sort by relative path before
|
||||
// appending so two nodes building the same tree emit
|
||||
// byte-identical tars (up to non-deterministic file contents
|
||||
// like rustc debug-info paths, which live elsewhere).
|
||||
for sub in CAPTURED_SUBDIRS {
|
||||
let path = target_dir.join(sub);
|
||||
if path.is_dir() {
|
||||
tar.append_dir_all(sub, &path)
|
||||
append_dir_sorted(&mut tar, sub, &path)
|
||||
.with_context(|| format!("archiving {}", path.display()))?;
|
||||
}
|
||||
}
|
||||
@@ -531,6 +613,40 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capture_is_order_independent_of_filesystem_readdir() {
|
||||
// Field finding 2026-07-12: `append_dir_all` used
|
||||
// `read_dir`'s native order, which differs across filesystems.
|
||||
// Two byte-identical trees produced different tars whose only
|
||||
// difference was entry order. Guard: create two trees whose
|
||||
// files are the same but written in DIFFERENT orders (which
|
||||
// biases readdir on many FS layouts), and require the
|
||||
// captures to match. `append_dir_sorted` — the new walker —
|
||||
// sorts by filename so the order at capture time is fixed.
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let a = tmp.path().join("A");
|
||||
let b = tmp.path().join("B");
|
||||
// Tree A: write a, b, c
|
||||
write_file(&a, "deps/aaa.rlib", b"aaa content");
|
||||
write_file(&a, "deps/bbb.rlib", b"bbb content");
|
||||
write_file(&a, "deps/ccc.rlib", b"ccc content");
|
||||
write_file(&a, ".fingerprint/aaa/xxx", b"fp-aaa");
|
||||
write_file(&a, ".fingerprint/bbb/xxx", b"fp-bbb");
|
||||
// Tree B: same files, reverse creation order
|
||||
write_file(&b, "deps/ccc.rlib", b"ccc content");
|
||||
write_file(&b, "deps/bbb.rlib", b"bbb content");
|
||||
write_file(&b, "deps/aaa.rlib", b"aaa content");
|
||||
write_file(&b, ".fingerprint/bbb/xxx", b"fp-bbb");
|
||||
write_file(&b, ".fingerprint/aaa/xxx", b"fp-aaa");
|
||||
|
||||
let cap_a = capture_target(&a).unwrap();
|
||||
let cap_b = capture_target(&b).unwrap();
|
||||
assert_eq!(
|
||||
cap_a, cap_b,
|
||||
"captures must be byte-identical after sorted walk"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capture_yields_identical_bytes_for_identical_input() {
|
||||
// With HeaderMode::Deterministic on the tar builder, two
|
||||
|
||||
@@ -61,6 +61,12 @@ pub mod keys {
|
||||
/// Phase 5i: cumulative bytes ingested into this node's blob store
|
||||
/// via `BlobPut`.
|
||||
pub const CACHE_BLOB_PUT_BYTES: &str = "clawstor.cache.blob_put.bytes";
|
||||
/// Field finding 2026-07-12: `rustc --version --verbose` short form
|
||||
/// — the "release" line only, e.g. `1.97.0`. Fingerprints depend on
|
||||
/// the full verbose output, so a mismatch here is a strong hint
|
||||
/// that two nodes will silo their caches. Peer-visible via
|
||||
/// `PeerView.rustc_release` and `cluster-peer-status`.
|
||||
pub const RUSTC_RELEASE: &str = "clawstor.rustc.release";
|
||||
}
|
||||
|
||||
/// Cluster identifier — every node in the same fleet must agree on this
|
||||
@@ -110,6 +116,11 @@ pub struct PeerView {
|
||||
pub cache_blob_get_bytes: Option<u64>,
|
||||
/// Phase 5i: cumulative bytes ingested into this node's blob store.
|
||||
pub cache_blob_put_bytes: Option<u64>,
|
||||
/// Field finding 2026-07-12: peer's `rustc --version` release
|
||||
/// string. `None` while the peer boots or when it can't invoke
|
||||
/// rustc. Used to surface toolchain drift that would otherwise
|
||||
/// silently silo caches.
|
||||
pub rustc_release: Option<String>,
|
||||
}
|
||||
|
||||
impl PeerView {
|
||||
@@ -277,6 +288,13 @@ impl ClusterGossip {
|
||||
state.set(keys::CACHE_BLOB_PUT_BYTES, snap.blob_put_bytes.to_string());
|
||||
}
|
||||
|
||||
/// Field finding 2026-07-12: publish the local rustc release
|
||||
/// string. Called at daemon startup so peers can flag mismatches
|
||||
/// before wasting a build on a cache that will silo.
|
||||
pub async fn set_rustc_release(&self, release: impl Into<String>) {
|
||||
self.set(keys::RUSTC_RELEASE, release).await;
|
||||
}
|
||||
|
||||
/// Publish the list of warm-tier `org/repo` projects this node serves.
|
||||
/// Later phases use this to bias runner scheduling.
|
||||
pub async fn set_warm_projects<S: AsRef<str>>(&self, projects: &[S]) {
|
||||
@@ -384,6 +402,7 @@ fn peer_view_from_state(id: &ChitchatId, state: &chitchat::NodeState, alive: boo
|
||||
cache_get_ref_misses: get_u64(state, keys::CACHE_GET_REF_MISSES),
|
||||
cache_blob_get_bytes: get_u64(state, keys::CACHE_BLOB_GET_BYTES),
|
||||
cache_blob_put_bytes: get_u64(state, keys::CACHE_BLOB_PUT_BYTES),
|
||||
rustc_release: get_str(state, keys::RUSTC_RELEASE),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -699,6 +718,7 @@ mod tests {
|
||||
cache_get_ref_misses: None,
|
||||
cache_blob_get_bytes: None,
|
||||
cache_blob_put_bytes: None,
|
||||
rustc_release: None,
|
||||
};
|
||||
assert_eq!(base.cache_get_ref_hit_rate(), None, "no counters → None");
|
||||
|
||||
@@ -744,6 +764,7 @@ mod tests {
|
||||
cache_get_ref_misses: None,
|
||||
cache_blob_get_bytes: None,
|
||||
cache_blob_put_bytes: None,
|
||||
rustc_release: None,
|
||||
};
|
||||
assert_eq!(base.hot_fill_ratio(), None, "no used → None");
|
||||
|
||||
|
||||
@@ -729,6 +729,14 @@ async fn handle_blob_put_stream(
|
||||
};
|
||||
match store.put_stream(recv).await {
|
||||
Ok(id) => {
|
||||
// Field finding 2026-07-12: the streaming variants had never
|
||||
// been counted, so `clawstor_cache_blob_put_bytes_total`
|
||||
// stayed at 0 even after multi-MB uploads. Look up the size
|
||||
// via `stat` — cheap (single manifest read) and gives the
|
||||
// authoritative post-store byte count.
|
||||
if let Ok(Some(stat)) = store.stat(&id).await {
|
||||
router.metrics.record_blob_put_bytes(stat.total_size);
|
||||
}
|
||||
let mut reply = Vec::with_capacity(33);
|
||||
reply.push(STREAM_STATUS_OK);
|
||||
reply.extend_from_slice(id.as_bytes());
|
||||
@@ -772,9 +780,14 @@ async fn handle_blob_get_stream(
|
||||
None => {
|
||||
send.write_all(&[ErrorCode::NotFound.as_byte()]).await?;
|
||||
}
|
||||
Some(_) => {
|
||||
Some(manifest) => {
|
||||
send.write_all(&[STREAM_STATUS_OK]).await?;
|
||||
store.stream_to(&id, &mut send).await?;
|
||||
// Field finding 2026-07-12: streaming GETs weren't counted,
|
||||
// leaving `blob_get_bytes_total` at 0. Record the manifest's
|
||||
// authoritative total_size — we've committed to serving the
|
||||
// whole thing by this point.
|
||||
router.metrics.record_blob_get_bytes(manifest.total_size);
|
||||
}
|
||||
}
|
||||
send.finish()?;
|
||||
|
||||
@@ -116,6 +116,17 @@ impl ClusterServices {
|
||||
// Publish the static config value once. Used-bytes updates every tick.
|
||||
gossip.set_hot_max(hot_max_bytes).await;
|
||||
|
||||
// Field finding 2026-07-12: publish `rustc --version` so peers
|
||||
// can flag toolchain drift before wasting a build on a cache
|
||||
// that will silo. Best-effort — a node with no rustc on PATH
|
||||
// simply doesn't advertise; peer-metrics prints "unknown".
|
||||
if let Some(release) = detect_rustc_release() {
|
||||
tracing::info!(rustc = %release, "publishing rustc release into gossip");
|
||||
gossip.set_rustc_release(release).await;
|
||||
} else {
|
||||
tracing::info!("rustc not detected on PATH; skipping rustc.release gossip key");
|
||||
}
|
||||
|
||||
// Open the local blob store if a root path was supplied. Kept
|
||||
// outside the TLS branch: a node can serve blobs to callers
|
||||
// without RPC (via in-process API) or over RPC (once TLS is
|
||||
@@ -380,6 +391,27 @@ fn dir_bytes_sync(root: &Path) -> u64 {
|
||||
total
|
||||
}
|
||||
|
||||
/// Field finding 2026-07-12: probe `rustc --version` at startup so the
|
||||
/// daemon can advertise its toolchain over gossip. Best-effort; if
|
||||
/// rustc isn't on PATH we return `None` and skip the publish.
|
||||
///
|
||||
/// Returns the "release" component only — for `rustc 1.97.0 (...)`
|
||||
/// that's `1.97.0`. Matches what fingerprints care about most: a bump
|
||||
/// in the major/minor version guarantees a different fingerprint.
|
||||
fn detect_rustc_release() -> Option<String> {
|
||||
let output = std::process::Command::new("rustc")
|
||||
.arg("--version")
|
||||
.output()
|
||||
.ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
let line = std::str::from_utf8(&output.stdout).ok()?.trim();
|
||||
// Format: `rustc 1.97.0 (2d8144b78 2026-07-07)` — second whitespace
|
||||
// token is the release.
|
||||
line.split_whitespace().nth(1).map(|s| s.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
+37
-4
@@ -252,10 +252,19 @@ async fn cmd_cluster_peer_status(
|
||||
println!(" (no peers known)");
|
||||
} else {
|
||||
println!(
|
||||
" {:<20} {:<14} {:<8} {:<22} {:<20}",
|
||||
"NAME", "ZONE", "STATE", "RPC LAN", "HOT USED / MAX"
|
||||
" {:<20} {:<14} {:<8} {:<22} {:<10} {:<20}",
|
||||
"NAME", "ZONE", "STATE", "RPC LAN", "RUSTC", "HOT USED / MAX"
|
||||
);
|
||||
println!(" {}", "-".repeat(90));
|
||||
println!(" {}", "-".repeat(100));
|
||||
// Field finding 2026-07-12: also render each peer's rustc
|
||||
// release. Toolchain drift silently silos caches; showing it
|
||||
// here means one glance surfaces the problem.
|
||||
let local_rustc = status
|
||||
.peers
|
||||
.iter()
|
||||
.filter_map(|p| p.rustc_release.clone())
|
||||
.next()
|
||||
.unwrap_or_default();
|
||||
for p in &status.peers {
|
||||
let state = if p.alive { "alive" } else { "dead" };
|
||||
let hot = match (p.hot_used_bytes, p.hot_max_bytes) {
|
||||
@@ -263,17 +272,41 @@ async fn cmd_cluster_peer_status(
|
||||
(Some(u), None) => format!("{u} / -"),
|
||||
_ => "-".into(),
|
||||
};
|
||||
let rustc = p.rustc_release.as_deref().unwrap_or("-");
|
||||
let mismatch = !local_rustc.is_empty()
|
||||
&& !rustc.is_empty()
|
||||
&& rustc != local_rustc
|
||||
&& local_rustc != "-";
|
||||
let rustc_col = if mismatch {
|
||||
format!("{rustc}!")
|
||||
} else {
|
||||
rustc.to_string()
|
||||
};
|
||||
println!(
|
||||
" {:<20} {:<14} {:<8} {:<22} {:<20}",
|
||||
" {:<20} {:<14} {:<8} {:<22} {:<10} {:<20}",
|
||||
p.name,
|
||||
p.zone,
|
||||
state,
|
||||
p.rpc_lan
|
||||
.map(|a| a.to_string())
|
||||
.unwrap_or_else(|| "-".into()),
|
||||
rustc_col,
|
||||
hot,
|
||||
);
|
||||
}
|
||||
if status
|
||||
.peers
|
||||
.iter()
|
||||
.filter_map(|p| p.rustc_release.as_deref())
|
||||
.collect::<std::collections::HashSet<_>>()
|
||||
.len()
|
||||
> 1
|
||||
{
|
||||
println!();
|
||||
println!(
|
||||
" ⚠ rustc release mismatch across peers → fingerprints will silo caches"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
conn.close(quinn::VarInt::from_u32(0), b"done");
|
||||
|
||||
Reference in New Issue
Block a user