tensorfs 0.3.63__tar.gz → 0.3.64__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. {tensorfs-0.3.63 → tensorfs-0.3.64}/Cargo.lock +2 -2
  2. {tensorfs-0.3.63 → tensorfs-0.3.64}/Cargo.toml +1 -1
  3. {tensorfs-0.3.63 → tensorfs-0.3.64}/PKG-INFO +1 -1
  4. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/bin/tfs/fetch.rs +3 -1
  5. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/catalog.rs +10 -1
  6. tensorfs-0.3.64/crates/tensorfs-core/src/descriptors.rs +112 -0
  7. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/lib.rs +1 -0
  8. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/providers.rs +1 -0
  9. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/store.rs +41 -3
  10. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/transport/http.rs +27 -9
  11. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/transport/http_reuse_tests.rs +9 -5
  12. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/transport/ledger.rs +15 -1
  13. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/transport/pace.rs +54 -8
  14. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/transport/policy.rs +45 -6
  15. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/transport/pull.rs +121 -49
  16. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/transport/tests.rs +5 -0
  17. tensorfs-0.3.64/crates/tensorfs-core/tests/fd_budget.rs +256 -0
  18. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/tests/source_artifacts.rs +1 -0
  19. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/tests/source_download.rs +1 -0
  20. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/tests/source_prefixes.rs +1 -0
  21. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/tests/store_boundaries.rs +32 -0
  22. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/tests/transfer_memory.rs +1 -0
  23. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-py/src/store.rs +4 -0
  24. {tensorfs-0.3.63 → tensorfs-0.3.64}/pyproject.toml +1 -1
  25. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/Cargo.toml +0 -0
  26. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/build.rs +0 -0
  27. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/examples/h3-swiglu-layout.md +0 -0
  28. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/examples/optional-cache-backfill.md +0 -0
  29. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/examples/resident-conversion.md +0 -0
  30. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/examples/resident_conversion_profile.rs +0 -0
  31. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/asset_update.rs +0 -0
  32. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/b64.rs +0 -0
  33. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/bin/tfs/ckpt.rs +0 -0
  34. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/bin/tfs/conform.rs +0 -0
  35. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/bin/tfs/ingest.rs +0 -0
  36. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/bin/tfs/main.rs +0 -0
  37. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/bin/tfs/rd.rs +0 -0
  38. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/bin/tfs/repo.rs +0 -0
  39. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/bin/tfs/snap.rs +0 -0
  40. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/bin/tfs/watch.rs +0 -0
  41. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/cache_roots.rs +0 -0
  42. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/canon.rs +0 -0
  43. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/capability.rs +0 -0
  44. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/cbor.rs +0 -0
  45. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/checkpoint.rs +0 -0
  46. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/checkpoint_root.rs +0 -0
  47. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/corpus.rs +0 -0
  48. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/derived/adoption.rs +0 -0
  49. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/derived/companion_tests.rs +0 -0
  50. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/derived/estimate.rs +0 -0
  51. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/derived/graft_tests.rs +0 -0
  52. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/derived/part_rows_tests.rs +0 -0
  53. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/derived/progress.rs +0 -0
  54. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/derived/resume_tests.rs +0 -0
  55. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/derived/retention.rs +0 -0
  56. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/derived/roots.rs +0 -0
  57. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/derived.rs +0 -0
  58. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/dtype.rs +0 -0
  59. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/durability.rs +0 -0
  60. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/err.rs +0 -0
  61. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/fetch.rs +0 -0
  62. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/fill.rs +0 -0
  63. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/fit.rs +0 -0
  64. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/gc.rs +0 -0
  65. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/header.rs +0 -0
  66. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/home.rs +0 -0
  67. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/ids.rs +0 -0
  68. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/ingest/carrier.rs +0 -0
  69. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/ingest/convert.rs +0 -0
  70. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/ingest/custody.rs +0 -0
  71. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/ingest/fingerprint.rs +0 -0
  72. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/ingest/gguf.rs +0 -0
  73. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/ingest/h3_lora.rs +0 -0
  74. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/ingest/journal.rs +0 -0
  75. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/ingest/mod.rs +0 -0
  76. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/ingest/preflight.rs +0 -0
  77. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/ingest/source.rs +0 -0
  78. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/ingest/stamp.rs +0 -0
  79. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/ingest/transaction.rs +0 -0
  80. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/jcs.rs +0 -0
  81. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/limits.rs +0 -0
  82. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/machine.rs +0 -0
  83. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/manifest.rs +0 -0
  84. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/meta.rs +0 -0
  85. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/project.rs +0 -0
  86. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/read.rs +0 -0
  87. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/receipt.rs +0 -0
  88. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/reclaim.rs +0 -0
  89. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/registry.rs +0 -0
  90. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/relation.rs +0 -0
  91. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/repo_cache/identity.rs +0 -0
  92. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/repo_cache.rs +0 -0
  93. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/repository.rs +0 -0
  94. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/sha256.rs +0 -0
  95. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/source_artifact.rs +0 -0
  96. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/spec.rs +0 -0
  97. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/staging.rs +0 -0
  98. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/stats.rs +0 -0
  99. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/storage.rs +0 -0
  100. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/transport/api.rs +0 -0
  101. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/transport/hub.rs +0 -0
  102. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/transport/memory.rs +0 -0
  103. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/transport/mod.rs +0 -0
  104. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/transport/prefix.rs +0 -0
  105. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/transport/push.rs +0 -0
  106. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/transport/ranged.rs +0 -0
  107. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/transport/speculate.rs +0 -0
  108. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/src/vectors.rs +0 -0
  109. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/tests/additive_fields.rs +0 -0
  110. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/tests/anima_hf_profile.rs +0 -0
  111. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/tests/checkpoint_roots.rs +0 -0
  112. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/tests/fd_headroom.rs +0 -0
  113. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/tests/fetch_url_progress.rs +0 -0
  114. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/tests/fit.rs +0 -0
  115. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/tests/fixtures/repo_apply/blobs.jsonl +0 -0
  116. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/tests/fixtures/repo_apply/expected_list.jsonl +0 -0
  117. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/tests/fixtures/repo_apply/expected_repository.json +0 -0
  118. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/tests/fixtures/repo_apply/expected_rows.jsonl +0 -0
  119. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/tests/fixtures/repo_apply/header.cbor +0 -0
  120. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/tests/fixtures/repo_apply/manifest.json +0 -0
  121. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/tests/fixtures/repo_apply/mutation.json +0 -0
  122. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/tests/foreign_json.rs +0 -0
  123. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/tests/h3_lora.rs +0 -0
  124. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/tests/h3_swiglu.rs +0 -0
  125. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/tests/home.rs +0 -0
  126. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/tests/import_tree.rs +0 -0
  127. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/tests/instrument_profiles.rs +0 -0
  128. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/tests/liveness.rs +0 -0
  129. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/tests/qwen_image21_profile.rs +0 -0
  130. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/tests/read_plan.rs +0 -0
  131. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/tests/reclaim.rs +0 -0
  132. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/tests/reclaim_run.rs +0 -0
  133. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/tests/repo_cache.rs +0 -0
  134. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/tests/repo_store.rs +0 -0
  135. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/tests/resident_admission.rs +0 -0
  136. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/tests/sha256_backends.rs +0 -0
  137. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/tests/sharded_cas.rs +0 -0
  138. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/tests/source_plan.rs +0 -0
  139. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/tests/source_preflight.rs +0 -0
  140. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/tests/staging.rs +0 -0
  141. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/tests/tensor_schema.rs +0 -0
  142. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/tests/trust_index.rs +0 -0
  143. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/tests/version.rs +0 -0
  144. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-core/tests/write_through.rs +0 -0
  145. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-py/Cargo.toml +0 -0
  146. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-py/src/docs.rs +0 -0
  147. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-py/src/errors.rs +0 -0
  148. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-py/src/ingest.rs +0 -0
  149. {tensorfs-0.3.63 → tensorfs-0.3.64}/crates/tensorfs-py/src/lib.rs +0 -0
  150. {tensorfs-0.3.63 → tensorfs-0.3.64}/python/tensorfs/__init__.py +0 -0
  151. {tensorfs-0.3.63 → tensorfs-0.3.64}/python/tensorfs/_derived_channel.py +0 -0
  152. {tensorfs-0.3.63 → tensorfs-0.3.64}/python/tensorfs/_ext.pyi +0 -0
  153. {tensorfs-0.3.63 → tensorfs-0.3.64}/python/tensorfs/_source_channel.py +0 -0
  154. {tensorfs-0.3.63 → tensorfs-0.3.64}/python/tensorfs/_tensors.py +0 -0
  155. {tensorfs-0.3.63 → tensorfs-0.3.64}/python/tensorfs/derived.py +0 -0
  156. {tensorfs-0.3.63 → tensorfs-0.3.64}/python/tensorfs/errors.py +0 -0
  157. {tensorfs-0.3.63 → tensorfs-0.3.64}/python/tensorfs/py.typed +0 -0
@@ -439,7 +439,7 @@ checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
439
439
 
440
440
  [[package]]
441
441
  name = "tensorfs-core"
442
- version = "0.3.63"
442
+ version = "0.3.64"
443
443
  dependencies = [
444
444
  "fs2",
445
445
  "libc",
@@ -454,7 +454,7 @@ dependencies = [
454
454
 
455
455
  [[package]]
456
456
  name = "tensorfs-py"
457
- version = "0.3.63"
457
+ version = "0.3.64"
458
458
  dependencies = [
459
459
  "pyo3",
460
460
  "tensorfs-core",
@@ -3,5 +3,5 @@ resolver = "2"
3
3
  members = ["crates/tensorfs-core", "crates/tensorfs-py"]
4
4
 
5
5
  [workspace.package]
6
- version = "0.3.63"
6
+ version = "0.3.64"
7
7
  edition = "2021"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tensorfs
3
- Version: 0.3.63
3
+ Version: 0.3.64
4
4
  Classifier: Programming Language :: Python :: 3
5
5
  Classifier: Programming Language :: Python :: Implementation :: CPython
6
6
  Classifier: Programming Language :: Rust
@@ -182,6 +182,7 @@ fn policy_of(flags: &Flags) -> SourcePolicy {
182
182
  .unwrap_or_default(),
183
183
  allow_local: crate::flag_on(flags, "allow-local"),
184
184
  max_redirects: crate::flag_num(flags, "max-redirects", 0u32),
185
+ ..Default::default()
185
186
  }
186
187
  }
187
188
 
@@ -334,12 +335,13 @@ fn pull_with_budget(
334
335
  // And what admitting them cost: a pull whose streams stop growing while its batches
335
336
  // take most of the wall time is held by the disk, not the link.
336
337
  eprintln!(
337
- "{{\"event\":\"pull.streams\",\"model\":{},\"seconds\":{:.3},\"bytes_moved\":{},\"streams\":{},\"levels\":[{}],\"admission\":{{\"objects\":{},\"batches\":{},\"seconds\":{:.3}}}}}",
338
+ "{{\"event\":\"pull.streams\",\"model\":{},\"seconds\":{:.3},\"bytes_moved\":{},\"streams\":{},\"levels\":[{}],\"backoffs\":{},\"admission\":{{\"objects\":{},\"batches\":{},\"seconds\":{:.3}}}}}",
338
339
  quoted(&report.model),
339
340
  seconds,
340
341
  report.bytes_moved,
341
342
  report.streams,
342
343
  levels.join(","),
344
+ report.backoffs,
343
345
  after.admitted_objects - before.admitted_objects,
344
346
  after.admission_batches - before.admission_batches,
345
347
  (after.admission_nanos - before.admission_nanos) as f64 / 1e9,
@@ -1473,7 +1473,16 @@ impl WriterGuard {
1473
1473
  .read(true)
1474
1474
  .write(true)
1475
1475
  .open(&path)
1476
- .map_err(|error| io("create writer marker", error))?;
1476
+ .map_err(|error| {
1477
+ if crate::descriptors::exhausted(&error) {
1478
+ Refusal {
1479
+ code: Code::FD_HEADROOM,
1480
+ detail: format!("create writer marker: {error}"),
1481
+ }
1482
+ } else {
1483
+ io("create writer marker", error)
1484
+ }
1485
+ })?;
1477
1486
  file.try_lock_exclusive()
1478
1487
  .map_err(|error| io("lock writer marker", error))?;
1479
1488
  Ok(Self {
@@ -0,0 +1,112 @@
1
+ //! File descriptors as a budget: raised to what the process may have, measured before a
2
+ //! transfer sizes itself, and an exhausted table reported as backpressure.
3
+ //!
4
+ //! Run 1297 (2026-09-27) grew a pull toward 128 streams under a 1,024 soft RLIMIT_NOFILE;
5
+ //! each object in flight holds a socket, a temp file, writer markers and a catalog
6
+ //! connection, and the table ran out mid-pull (`name resolution failed (Too many open
7
+ //! files)`). A Go supervisor raises its own soft limit but hands its children the original.
8
+ use std::sync::atomic::{AtomicU64, Ordering};
9
+ use std::sync::Once;
10
+
11
+ /// Descriptors one object stream may hold at once, and what it is spent on: the object
12
+ /// socket and one pooled idle socket (2); the temp file and the two writer-marker files of
13
+ /// its admission, nested once more by the catalog record (5); the catalog connection and its
14
+ /// journal (2); and the transient DNS socket or contender of a retry (3).
15
+ pub const PER_STREAM: u64 = 12;
16
+
17
+ /// Descriptors left for everything that is not a stream: stdio, the Store's own locks, the
18
+ /// hub calls, the progress channel and whatever the caller opens next.
19
+ pub const MARGIN: u64 = 64;
20
+
21
+ static RAISE: Once = Once::new();
22
+ static EXHAUSTED: AtomicU64 = AtomicU64::new(0);
23
+
24
+ /// Raise the soft RLIMIT_NOFILE to the hard limit, once per process. Idempotent; a refused
25
+ /// raise leaves the limit where it was and the budget below measures that.
26
+ pub fn raise() {
27
+ RAISE.call_once(|| {
28
+ use rustix::process::{getrlimit, setrlimit, Resource, Rlimit};
29
+ let limit = getrlimit(Resource::Nofile);
30
+ if let (Some(soft), Some(hard)) = (limit.current, limit.maximum) {
31
+ if soft < hard {
32
+ let _ = setrlimit(
33
+ Resource::Nofile,
34
+ Rlimit {
35
+ current: Some(hard),
36
+ maximum: Some(hard),
37
+ },
38
+ );
39
+ }
40
+ }
41
+ });
42
+ }
43
+
44
+ /// Descriptors this process can still open now: soft limit less those open and `MARGIN`.
45
+ /// `None` when the platform cannot say (no `/proc`, or no soft limit).
46
+ pub fn available() -> Option<u64> {
47
+ let soft = rustix::process::getrlimit(rustix::process::Resource::Nofile).current?;
48
+ let open = std::fs::read_dir("/proc/self/fd").ok()?.count() as u64;
49
+ Some(soft.saturating_sub(open).saturating_sub(MARGIN))
50
+ }
51
+
52
+ /// Object streams the descriptor table allows now: at most `wanted`, at least one.
53
+ pub fn streams_within(wanted: usize) -> usize {
54
+ streams_for(wanted, available())
55
+ }
56
+
57
+ /// `streams_within` against a stated number of free descriptors.
58
+ pub fn streams_for(wanted: usize, available: Option<u64>) -> usize {
59
+ let fits = available.map_or(usize::MAX, |free| {
60
+ usize::try_from(free / PER_STREAM).unwrap_or(usize::MAX)
61
+ });
62
+ wanted.min(fits).max(1)
63
+ }
64
+
65
+ /// Whether `error` is the descriptor table being full, counting it when it is. Call it
66
+ /// where an open, socket or resolution fails, before the error loses its errno.
67
+ pub fn exhausted(error: &std::io::Error) -> bool {
68
+ let full = matches!(
69
+ error.raw_os_error(),
70
+ Some(code) if code == rustix::io::Errno::MFILE.raw_os_error()
71
+ || code == rustix::io::Errno::NFILE.raw_os_error()
72
+ );
73
+ if full {
74
+ EXHAUSTED.fetch_add(1, Ordering::Relaxed);
75
+ }
76
+ full
77
+ }
78
+
79
+ /// How many times this process has found its descriptor table full. A caller diffs two
80
+ /// reads around its own work.
81
+ pub fn exhaustions() -> u64 {
82
+ EXHAUSTED.load(Ordering::Relaxed)
83
+ }
84
+
85
+ #[cfg(test)]
86
+ mod tests {
87
+ use super::*;
88
+
89
+ #[test]
90
+ fn the_budget_is_descriptors_over_per_stream_cost() {
91
+ assert_eq!(streams_for(128, Some(PER_STREAM * 10)), 10);
92
+ assert_eq!(streams_for(8, Some(PER_STREAM * 10)), 8);
93
+ assert_eq!(streams_for(128, Some(0)), 1, "one stream is always allowed");
94
+ assert_eq!(
95
+ streams_for(128, None),
96
+ 128,
97
+ "an unmeasurable table is not a shortage"
98
+ );
99
+ }
100
+
101
+ #[test]
102
+ fn only_a_full_table_counts_as_exhaustion() {
103
+ let before = exhaustions();
104
+ assert!(exhausted(&std::io::Error::from_raw_os_error(
105
+ rustix::io::Errno::MFILE.raw_os_error()
106
+ )));
107
+ assert!(!exhausted(&std::io::Error::from_raw_os_error(
108
+ rustix::io::Errno::CONNREFUSED.raw_os_error()
109
+ )));
110
+ assert!(exhaustions() > before);
111
+ }
112
+ }
@@ -19,6 +19,7 @@ pub mod checkpoint;
19
19
  pub mod checkpoint_root;
20
20
  pub mod corpus;
21
21
  pub mod derived;
22
+ pub mod descriptors;
22
23
  pub mod dtype;
23
24
  pub mod durability;
24
25
  pub mod err;
@@ -176,6 +176,7 @@ impl Endpoints {
176
176
  allowed_hosts,
177
177
  allow_local: self.allow_local,
178
178
  max_redirects: 5,
179
+ ..Default::default()
179
180
  }
180
181
  }
181
182
 
@@ -415,9 +415,14 @@ impl Store {
415
415
  ///
416
416
  /// A symlink, non-directory, partial Store, or directory containing foreign state refuses.
417
417
  /// There is no old-layout lookup or fallback root.
418
+ /// Open the Store at `root`, creating it when absent or empty. Concurrent calls on one
419
+ /// new root serialize on the root directory: exactly one creates it, the rest open it.
418
420
  pub fn ensure(root: &Path) -> Result<Store> {
419
421
  match fs::symlink_metadata(root) {
420
- Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Store::init(root),
422
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
423
+ fs::create_dir_all(root)
424
+ .map_err(|error| io(format!("create Store root {}", root.display()), error))?;
425
+ }
421
426
  Err(error) => return Err(io(format!("stat Store root {}", root.display()), error)),
422
427
  Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_dir() => {
423
428
  return refuse(
@@ -427,6 +432,7 @@ impl Store {
427
432
  }
428
433
  Ok(_) => {}
429
434
  }
435
+ let _creation = creation_lock(root)?;
430
436
  let empty = fs::read_dir(root)
431
437
  .map_err(|error| io(format!("read Store root {}", root.display()), error))?
432
438
  .next()
@@ -434,7 +440,7 @@ impl Store {
434
440
  .map_err(|error| io(format!("read Store root {}", root.display()), error))?
435
441
  .is_none();
436
442
  if empty {
437
- Store::init(root)
443
+ Store::init_locked(root)
438
444
  } else {
439
445
  validate_store_root(root)?;
440
446
  let catalog_path = root.join("tensorfs.sqlite");
@@ -456,6 +462,15 @@ impl Store {
456
462
  }
457
463
 
458
464
  pub fn init(root: &Path) -> Result<Store> {
465
+ if matches!(fs::symlink_metadata(root), Ok(metadata) if metadata.is_dir() && !metadata.file_type().is_symlink())
466
+ {
467
+ let _creation = creation_lock(root)?;
468
+ return Store::init_locked(root);
469
+ }
470
+ Store::init_locked(root)
471
+ }
472
+
473
+ fn init_locked(root: &Path) -> Result<Store> {
459
474
  match fs::symlink_metadata(root) {
460
475
  Ok(metadata) if metadata.is_dir() && !metadata.file_type().is_symlink() => {}
461
476
  Ok(_) => {
@@ -1347,7 +1362,16 @@ impl Store {
1347
1362
  .create_new(true)
1348
1363
  .mode(0o600)
1349
1364
  .open(&tmp)
1350
- .map_err(|e| io(format!("create temp {}", tmp.display()), e))?;
1365
+ .map_err(|e| {
1366
+ if crate::descriptors::exhausted(&e) {
1367
+ Refusal {
1368
+ code: Code::FD_HEADROOM,
1369
+ detail: format!("create temp {}: {e}", tmp.display()),
1370
+ }
1371
+ } else {
1372
+ io(format!("create temp {}", tmp.display()), e)
1373
+ }
1374
+ })?;
1351
1375
  match f.try_lock_exclusive() {
1352
1376
  Ok(()) => break (tmp, f),
1353
1377
  // Only a reaper can hold the lock on a name this process just minted: it
@@ -2103,6 +2127,20 @@ fn read_repo_cache_binding(root: &Path) -> Option<crate::repo_cache::RepoObjectC
2103
2127
  Some(crate::repo_cache::RepoObjectCache::new(cache))
2104
2128
  }
2105
2129
 
2130
+ /// Serializes Store creation on one root across threads and processes. A lock on the root
2131
+ /// directory itself leaves nothing behind in it.
2132
+ fn creation_lock(root: &Path) -> Result<File> {
2133
+ let directory = OpenOptions::new()
2134
+ .read(true)
2135
+ .custom_flags(O_NOFOLLOW)
2136
+ .open(root)
2137
+ .map_err(|error| io(format!("open Store root {}", root.display()), error))?;
2138
+ directory
2139
+ .lock_exclusive()
2140
+ .map_err(|error| io(format!("lock Store root {}", root.display()), error))?;
2141
+ Ok(directory)
2142
+ }
2143
+
2106
2144
  fn open_validated(root: &Path) -> Result<Store> {
2107
2145
  fs::create_dir_all(root.join("tmp")).map_err(|error| io("mkdir tmp", error))?;
2108
2146
  Ok(Store {
@@ -166,7 +166,8 @@ pub struct Client {
166
166
  type IdlePool = Arc<Mutex<Vec<Idle>>>;
167
167
 
168
168
  // This bounds retained descriptors, not in-flight transfers (the caller owns those).
169
- const MAX_IDLE: usize = 8;
169
+ // `descriptors::PER_STREAM` counts one idle socket per stream.
170
+ const MAX_IDLE: usize = 32;
170
171
 
171
172
  /// A connection belongs to its origin and the credentials sent on it, never to one
172
173
  /// request target: a signed CDN URL changes per resolve, and keying on it meant the
@@ -414,7 +415,15 @@ fn connect(
414
415
  let socket = match nonblocking_socket(family) {
415
416
  Ok(socket) => socket,
416
417
  Err(error) => {
417
- last = Some(error.into());
418
+ let error: std::io::Error = error.into();
419
+ if crate::descriptors::exhausted(&error) {
420
+ // Another address needs another descriptor it will not get either.
421
+ return refuse(
422
+ Code::FD_HEADROOM,
423
+ format!("{}: no descriptor for a socket ({error})", checked.host),
424
+ );
425
+ }
426
+ last = Some(error);
418
427
  continue;
419
428
  }
420
429
  };
@@ -653,6 +662,15 @@ pub struct BodyReader<'l> {
653
662
 
654
663
  impl Drop for BodyReader<'_> {
655
664
  fn drop(&mut self) {
665
+ self.recycle_if_consumed();
666
+ }
667
+ }
668
+
669
+ impl BodyReader<'_> {
670
+ /// Hand the connection back the moment its last body byte is read, not when the
671
+ /// reader is dropped: the consumer may still spend seconds hashing, fsyncing and
672
+ /// committing what arrived, and the next object's GET should find this socket idle.
673
+ fn recycle_if_consumed(&mut self) {
656
674
  // Only a fully consumed, unambiguous Content-Length response is reusable.
657
675
  // Dropped/failed bodies, chunked bodies and EOF-delimited replies close.
658
676
  if !matches!(self.framing, Framing::Length { remaining: 0 }) || self.raw_len() != 0 {
@@ -722,6 +740,7 @@ impl BodyReader<'_> {
722
740
  let silent = self.advanced.elapsed();
723
741
  if self.judge.silent_is_stall() && silent > self.ledger.patience() {
724
742
  self.stalled = true;
743
+ self.ledger.stalled_stream();
725
744
  let _ = self
726
745
  .wire
727
746
  .as_ref()
@@ -846,6 +865,7 @@ impl BodyReader<'_> {
846
865
  self.framing = Framing::Length {
847
866
  remaining: remaining - n as u64,
848
867
  };
868
+ self.recycle_if_consumed();
849
869
  Ok(n)
850
870
  }
851
871
  Framing::Chunked { in_chunk, done } => {
@@ -946,13 +966,11 @@ pub fn request<'l>(
946
966
  write_silence: Option<Duration>,
947
967
  fail: Code,
948
968
  ) -> Result<Response<'l>> {
949
- // Retained-source ranges already own their retry/resume budget. Other GETs and
950
- // metadata writes keep their one-connection behavior.
951
- let ranged = method == "GET"
969
+ // Every bodiless GET may reuse a connection to its origin: a pull's objects are many
970
+ // GETs to one host, and a fresh TCP+TLS connection per object spent a descriptor, a
971
+ // handshake and a slow start on each. Metadata writes keep one connection each.
972
+ let pooled = method == "GET"
952
973
  && body.is_none()
953
- && headers
954
- .iter()
955
- .any(|(name, _)| name.eq_ignore_ascii_case("range"))
956
974
  && !headers
957
975
  .iter()
958
976
  .any(|(name, _)| name.eq_ignore_ascii_case("connection"));
@@ -967,7 +985,7 @@ pub fn request<'l>(
967
985
  method,
968
986
  headers,
969
987
  body.map(Payload::Bytes),
970
- ranged.then_some(scoped.as_slice()),
988
+ pooled.then_some(scoped.as_slice()),
971
989
  deadline,
972
990
  ledger,
973
991
  judge,
@@ -197,6 +197,7 @@ impl Origin {
197
197
  allowed_hosts: vec!["localhost".into()],
198
198
  allow_local: true,
199
199
  max_redirects: 2,
200
+ ..Default::default()
200
201
  }
201
202
  }
202
203
  fn count(&self) -> usize {
@@ -364,20 +365,23 @@ fn only_complete_unambiguous_http11_length_bodies_enter_the_pool() {
364
365
  }
365
366
 
366
367
  #[test]
367
- fn idle_descriptor_retention_is_bounded_and_non_range_gets_do_not_pool() {
368
+ fn idle_descriptor_retention_is_bounded_and_plain_gets_pool_too() {
368
369
  let origin = Origin::new(false, Duration::ZERO, |_, _| Reply::body(b"bytes"));
369
370
  let client = origin.client();
370
- for index in 0..32 {
371
+ let scopes = MAX_IDLE + 8;
372
+ for index in 0..scopes {
371
373
  let mut headers = ranged();
372
374
  headers.push(("authorization".into(), format!("Bearer {index}")));
373
375
  ask(&client, &origin.checked("/object"), &headers).unwrap();
374
376
  }
375
377
  assert_eq!(client.idle.lock().unwrap().len(), MAX_IDLE);
376
- assert_eq!(origin.count(), 32);
378
+ assert_eq!(origin.count(), scopes);
379
+ // A whole-object GET is a pull's common case: the second rides the first's socket.
377
380
  for _ in 0..2 {
378
- ask(&client, &origin.checked("/metadata"), &[]).unwrap();
381
+ ask(&client, &origin.checked("/object"), &[]).unwrap();
379
382
  }
380
- assert_eq!(origin.count(), 34);
383
+ assert_eq!(origin.count(), scopes + 1);
384
+ assert_eq!(client.idle.lock().unwrap().len(), MAX_IDLE);
381
385
  }
382
386
 
383
387
  #[test]
@@ -31,7 +31,7 @@
31
31
  //! paid H100s sat at exactly zero for fifteen minutes with neither plane complaining.
32
32
 
33
33
  use crate::err::{refuse, Code, Result};
34
- use std::sync::atomic::{AtomicBool, Ordering};
34
+ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
35
35
  use std::sync::{Arc, Mutex};
36
36
  use std::time::{Duration, Instant};
37
37
 
@@ -151,6 +151,8 @@ impl PullCancellation {
151
151
  pub struct Ledger {
152
152
  caller_cancellation: Option<PullCancellation>,
153
153
  cancelled: Arc<AtomicBool>,
154
+ /// Streams this pull has judged stalled, a backpressure signal for its pace.
155
+ stalls: Arc<AtomicU64>,
154
156
  sample: Duration,
155
157
  worst_gap: Arc<Mutex<Duration>>,
156
158
  progress: Arc<Mutex<Progress>>,
@@ -170,6 +172,7 @@ impl Ledger {
170
172
  Ledger {
171
173
  caller_cancellation: None,
172
174
  cancelled: Arc::new(AtomicBool::new(false)),
175
+ stalls: Arc::new(AtomicU64::new(0)),
173
176
  sample: sample.max(Duration::from_millis(1)),
174
177
  worst_gap: Arc::new(Mutex::new(Duration::ZERO)),
175
178
  progress: Arc::new(Mutex::new(Progress {
@@ -243,6 +246,16 @@ impl Ledger {
243
246
  self.sample
244
247
  }
245
248
 
249
+ /// A stream of this pull was judged stalled.
250
+ pub(super) fn stalled_stream(&self) {
251
+ self.stalls.fetch_add(1, Ordering::Relaxed);
252
+ }
253
+
254
+ /// Streams this pull has judged stalled so far.
255
+ pub(super) fn stalls(&self) -> u64 {
256
+ self.stalls.load(Ordering::Relaxed)
257
+ }
258
+
246
259
  /// The smallest patience ever granted: `STILL_SAMPLES` intervals of the resolution.
247
260
  pub fn floor(&self) -> Duration {
248
261
  self.sample * STILL_SAMPLES
@@ -280,6 +293,7 @@ impl Ledger {
280
293
  Self {
281
294
  caller_cancellation: self.caller_cancellation.clone(),
282
295
  cancelled: Arc::clone(&self.cancelled),
296
+ stalls: Arc::clone(&self.stalls),
283
297
  sample: self.sample,
284
298
  worst_gap: Arc::clone(&self.worst_gap),
285
299
  progress: Arc::clone(&self.progress),
@@ -9,6 +9,10 @@
9
9
  //! admits while doing so bought at least `GAIN` more aggregate goodput. A doubling that
10
10
  //! bought less settles the walk; one that bought less than nothing returns to the level
11
11
  //! before it. Nothing is ended or failed here: this only admits workers to the next object.
12
+ //!
13
+ //! Two signals halve the level and stop its growth for the rest of the walk: a stream the
14
+ //! ledger judged stalled while the level stood, and a full descriptor table (`shrink`).
15
+ //! Both say the pull is asking more of the link or the process than either will give.
12
16
  use super::ledger::Ledger;
13
17
  use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
14
18
  use std::sync::{Condvar, Mutex};
@@ -32,6 +36,9 @@ struct State {
32
36
  limit: usize,
33
37
  active: usize,
34
38
  settled: bool,
39
+ /// Times the level was halved on backpressure.
40
+ backoffs: u32,
41
+ stalls_seen: u64,
35
42
  window_started: Instant,
36
43
  moved_at_window: u64,
37
44
  history: Vec<Level>,
@@ -67,6 +74,8 @@ impl Pace {
67
74
  limit,
68
75
  active: 0,
69
76
  settled: limit == ceiling,
77
+ backoffs: 0,
78
+ stalls_seen: ledger.stalls(),
70
79
  window_started: Instant::now(),
71
80
  moved_at_window: ledger.still().moved,
72
81
  history: Vec::new(),
@@ -82,10 +91,38 @@ impl Pace {
82
91
  self.state.lock().unwrap().limit
83
92
  }
84
93
 
85
- /// The level the walk ended at and every level it measured on the way.
86
- pub fn outcome(&self) -> (usize, Vec<Level>) {
94
+ /// The level the walk ended at, every level it measured on the way, and how many
95
+ /// times backpressure halved it.
96
+ pub fn outcome(&self) -> (usize, Vec<Level>, u32) {
87
97
  let state = self.state.lock().unwrap();
88
- (state.limit, state.history.clone())
98
+ (state.limit, state.history.clone(), state.backoffs)
99
+ }
100
+
101
+ /// Times backpressure has halved the level so far. A caller reads it before an attempt
102
+ /// and hands it to `back_off`, so one episode that fails many streams at once halves
103
+ /// the level once, not once per stream.
104
+ pub fn backoffs(&self) -> u32 {
105
+ self.state.lock().unwrap().backoffs
106
+ }
107
+
108
+ /// Halve the level unless it was already halved since `seen`.
109
+ pub fn back_off(&self, seen: u32) {
110
+ if self.state.lock().unwrap().backoffs == seen {
111
+ self.shrink();
112
+ }
113
+ }
114
+
115
+ /// Halve the level and stop growing it. `false` when it is already one stream: there is
116
+ /// nothing left to give back.
117
+ pub fn shrink(&self) -> bool {
118
+ let mut state = self.state.lock().unwrap();
119
+ state.settled = true;
120
+ if state.limit <= 1 {
121
+ return false;
122
+ }
123
+ state.limit /= 2;
124
+ state.backoffs += 1;
125
+ true
89
126
  }
90
127
 
91
128
  pub fn worker_finished(&self) {
@@ -126,10 +163,7 @@ impl Pace {
126
163
  loop {
127
164
  let mut state = self.state.lock().unwrap();
128
165
  loop {
129
- if state.settled
130
- || stop.load(Ordering::Relaxed)
131
- || self.live.load(Ordering::Acquire) == 0
132
- {
166
+ if stop.load(Ordering::Relaxed) || self.live.load(Ordering::Acquire) == 0 {
133
167
  return;
134
168
  }
135
169
  let waited = state.window_started.elapsed();
@@ -138,7 +172,19 @@ impl Pace {
138
172
  }
139
173
  state = self.wake.wait_timeout(state, tick - waited).unwrap().0;
140
174
  }
175
+ let stalls = ledger.stalls();
176
+ let stalled = stalls > state.stalls_seen;
177
+ state.stalls_seen = stalls;
178
+ if state.settled && !stalled {
179
+ state.window_started = Instant::now();
180
+ continue;
181
+ }
141
182
  drop(state);
183
+ if stalled {
184
+ self.shrink();
185
+ self.state.lock().unwrap().window_started = Instant::now();
186
+ continue;
187
+ }
142
188
  self.observe(ledger.still().moved, Instant::now(), unclaimed());
143
189
  }
144
190
  }
@@ -195,7 +241,7 @@ mod tests {
195
241
  pace.observe(530, t0 + second * 3, 1000);
196
242
  assert_eq!(pace.limit(), 64, "1.15x does not");
197
243
  pace.observe(10_000, t0 + second * 4, 1000);
198
- let (level, history) = pace.outcome();
244
+ let (level, history, _) = pace.outcome();
199
245
  assert_eq!(level, 64, "a settled walk stays settled");
200
246
  assert_eq!(history.len(), 3);
201
247
  }
@@ -26,7 +26,9 @@
26
26
  //! metadata read.
27
27
 
28
28
  use crate::err::{refuse, Code, Result};
29
+ use std::collections::HashMap;
29
30
  use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, ToSocketAddrs};
31
+ use std::sync::{Arc, Mutex};
30
32
 
31
33
  /// The address classes no downloader in this tree connects to — the port of
32
34
  /// `egress._CLASSES`, same names, same order. Returns why `address` is not fetchable, or
@@ -173,6 +175,36 @@ pub struct SourcePolicy {
173
175
  /// Redirect hop budget. 0 refuses every redirect — a presigned GET has no business
174
176
  /// redirecting; a provider handler (tfs-052) that needs hops declares them.
175
177
  pub max_redirects: u32,
178
+ /// Answers this policy has already resolved, shared by its clones. One pull asks the
179
+ /// resolver once per host instead of once per object; every answer is still checked.
180
+ pub resolved: Resolved,
181
+ }
182
+
183
+ /// Checked answers by host and port.
184
+ type Answers = HashMap<(String, u16), Vec<SocketAddr>>;
185
+
186
+ /// One resolution per host and port for as long as the policy that made it lives.
187
+ #[derive(Clone, Default)]
188
+ pub struct Resolved(Arc<Mutex<Answers>>);
189
+
190
+ impl std::fmt::Debug for Resolved {
191
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
192
+ f.write_str("Resolved")
193
+ }
194
+ }
195
+
196
+ impl Resolved {
197
+ fn lookup(&self, host: &str, port: u16) -> std::io::Result<Vec<SocketAddr>> {
198
+ let key = (host.to_string(), port);
199
+ if let Some(addrs) = self.0.lock().unwrap().get(&key) {
200
+ return Ok(addrs.clone());
201
+ }
202
+ let addrs = resolve(host, port)?;
203
+ if !addrs.is_empty() {
204
+ self.0.lock().unwrap().insert(key, addrs.clone());
205
+ }
206
+ Ok(addrs)
207
+ }
176
208
  }
177
209
 
178
210
  impl SourcePolicy {
@@ -219,13 +251,20 @@ impl SourcePolicy {
219
251
  );
220
252
  }
221
253
  let port = port.unwrap_or(if https { 443 } else { 80 });
222
- let addrs = resolve(&host, port).map_err(|error| crate::err::Refusal {
223
- code: Code::HUB_UNREACHABLE,
224
- detail: format!(
225
- "{host}: name resolution failed ({error}); an address this module cannot \
254
+ let addrs = self
255
+ .resolved
256
+ .lookup(&host, port)
257
+ .map_err(|error| crate::err::Refusal {
258
+ code: if crate::descriptors::exhausted(&error) {
259
+ Code::FD_HEADROOM
260
+ } else {
261
+ Code::HUB_UNREACHABLE
262
+ },
263
+ detail: format!(
264
+ "{host}: name resolution failed ({error}); an address this module cannot \
226
265
  check is not an address it will connect to"
227
- ),
228
- })?;
266
+ ),
267
+ })?;
229
268
  if addrs.is_empty() {
230
269
  return refuse(Code::HUB_UNREACHABLE, format!("{host} resolved to nothing"));
231
270
  }