letify 2.0.0__tar.gz → 2.1.0__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 (200) hide show
  1. {letify-2.0.0 → letify-2.1.0}/PKG-INFO +1 -1
  2. {letify-2.0.0 → letify-2.1.0}/letify/__init__.py +1 -1
  3. {letify-2.0.0 → letify-2.1.0}/letify/protocol/worker.py +12 -4
  4. {letify-2.0.0 → letify-2.1.0}/letify/providers/kaggle.py +7 -4
  5. {letify-2.0.0 → letify-2.1.0}/letify/transport/probe.py +35 -9
  6. {letify-2.0.0 → letify-2.1.0}/letify-core/Cargo.lock +3 -3
  7. {letify-2.0.0 → letify-2.1.0}/letify-core/Cargo.toml +1 -1
  8. {letify-2.0.0 → letify-2.1.0}/letify-ext/package-lock.json +2 -2
  9. {letify-2.0.0 → letify-2.1.0}/letify-ext/package.json +1 -1
  10. {letify-2.0.0 → letify-2.1.0}/pyproject.toml +1 -1
  11. {letify-2.0.0 → letify-2.1.0}/tests/test_data_streaming.py +20 -1
  12. {letify-2.0.0 → letify-2.1.0}/tests/test_kaggle_link.py +14 -1
  13. {letify-2.0.0 → letify-2.1.0}/tests/test_transport.py +40 -0
  14. {letify-2.0.0 → letify-2.1.0}/.github/workflows/ci.yml +0 -0
  15. {letify-2.0.0 → letify-2.1.0}/.github/workflows/docs.yml +0 -0
  16. {letify-2.0.0 → letify-2.1.0}/.github/workflows/publish.yml +0 -0
  17. {letify-2.0.0 → letify-2.1.0}/.gitignore +0 -0
  18. {letify-2.0.0 → letify-2.1.0}/.researchtree +0 -0
  19. {letify-2.0.0 → letify-2.1.0}/LICENSE +0 -0
  20. {letify-2.0.0 → letify-2.1.0}/README.md +0 -0
  21. {letify-2.0.0 → letify-2.1.0}/docs/guide/01-getting-started.md +0 -0
  22. {letify-2.0.0 → letify-2.1.0}/docs/guide/02-providers.md +0 -0
  23. {letify-2.0.0 → letify-2.1.0}/docs/guide/03-execution-modes.md +0 -0
  24. {letify-2.0.0 → letify-2.1.0}/docs/guide/04-environments-and-data.md +0 -0
  25. {letify-2.0.0 → letify-2.1.0}/docs/guide/05-concurrency.md +0 -0
  26. {letify-2.0.0 → letify-2.1.0}/docs/guide/06-cost.md +0 -0
  27. {letify-2.0.0 → letify-2.1.0}/docs/guide/07-troubleshooting.md +0 -0
  28. {letify-2.0.0 → letify-2.1.0}/docs/guide/08-releasing.md +0 -0
  29. {letify-2.0.0 → letify-2.1.0}/docs/guide/README.md +0 -0
  30. {letify-2.0.0 → letify-2.1.0}/docs/guide/ko/01-/354/213/234/354/236/221/355/225/230/352/270/260.md" +0 -0
  31. {letify-2.0.0 → letify-2.1.0}/docs/guide/ko/02-/355/224/204/353/241/234/353/260/224/354/235/264/353/215/224.md" +0 -0
  32. {letify-2.0.0 → letify-2.1.0}/docs/guide/ko/03-/354/213/244/355/226/211-/353/260/251/354/213/235.md" +0 -0
  33. {letify-2.0.0 → letify-2.1.0}/docs/guide/ko/04-/355/231/230/352/262/275/352/263/274-/353/215/260/354/235/264/355/204/260.md" +0 -0
  34. {letify-2.0.0 → letify-2.1.0}/docs/guide/ko/05-/353/217/231/354/213/234-/354/213/244/355/226/211.md" +0 -0
  35. {letify-2.0.0 → letify-2.1.0}/docs/guide/ko/06-/353/271/204/354/232/251.md" +0 -0
  36. {letify-2.0.0 → letify-2.1.0}/docs/guide/ko/07-/353/254/270/354/240/234-/355/225/264/352/262/260.md" +0 -0
  37. {letify-2.0.0 → letify-2.1.0}/docs/guide/ko/08-/353/246/264/353/246/254/354/212/244.md" +0 -0
  38. {letify-2.0.0 → letify-2.1.0}/docs/guide/ko/README.md +0 -0
  39. {letify-2.0.0 → letify-2.1.0}/docs/locales/README_ko.md +0 -0
  40. {letify-2.0.0 → letify-2.1.0}/docs/mkdocs.yml +0 -0
  41. {letify-2.0.0 → letify-2.1.0}/examples/README.md +0 -0
  42. {letify-2.0.0 → letify-2.1.0}/examples/nvfp4-lora/.letify.example +0 -0
  43. {letify-2.0.0 → letify-2.1.0}/examples/nvfp4-lora/00_smoke.py +0 -0
  44. {letify-2.0.0 → letify-2.1.0}/examples/nvfp4-lora/01_sweep.py +0 -0
  45. {letify-2.0.0 → letify-2.1.0}/examples/nvfp4-lora/02_resume.py +0 -0
  46. {letify-2.0.0 → letify-2.1.0}/examples/nvfp4-lora/03_watch.py +0 -0
  47. {letify-2.0.0 → letify-2.1.0}/examples/nvfp4-lora/README.md +0 -0
  48. {letify-2.0.0 → letify-2.1.0}/examples/nvfp4-lora/README_ko.md +0 -0
  49. {letify-2.0.0 → letify-2.1.0}/examples/nvfp4-lora/common.py +0 -0
  50. {letify-2.0.0 → letify-2.1.0}/examples/nvfp4-lora/recipe.py +0 -0
  51. {letify-2.0.0 → letify-2.1.0}/letify/cli.py +0 -0
  52. {letify-2.0.0 → letify-2.1.0}/letify/config/__init__.py +0 -0
  53. {letify-2.0.0 → letify-2.1.0}/letify/config/inventory.py +0 -0
  54. {letify-2.0.0 → letify-2.1.0}/letify/config/login.py +0 -0
  55. {letify-2.0.0 → letify-2.1.0}/letify/config/schema.py +0 -0
  56. {letify-2.0.0 → letify-2.1.0}/letify/config/secrets.py +0 -0
  57. {letify-2.0.0 → letify-2.1.0}/letify/config/writer.py +0 -0
  58. {letify-2.0.0 → letify-2.1.0}/letify/declare/__init__.py +0 -0
  59. {letify-2.0.0 → letify-2.1.0}/letify/declare/cache.py +0 -0
  60. {letify-2.0.0 → letify-2.1.0}/letify/declare/env.py +0 -0
  61. {letify-2.0.0 → letify-2.1.0}/letify/declare/function.py +0 -0
  62. {letify-2.0.0 → letify-2.1.0}/letify/declare/instance.py +0 -0
  63. {letify-2.0.0 → letify-2.1.0}/letify/errors.py +0 -0
  64. {letify-2.0.0 → letify-2.1.0}/letify/install.py +0 -0
  65. {letify-2.0.0 → letify-2.1.0}/letify/launcher.py +0 -0
  66. {letify-2.0.0 → letify-2.1.0}/letify/paths.py +0 -0
  67. {letify-2.0.0 → letify-2.1.0}/letify/protocol/__init__.py +0 -0
  68. {letify-2.0.0 → letify-2.1.0}/letify/protocol/codec.py +0 -0
  69. {letify-2.0.0 → letify-2.1.0}/letify/protocol/driver.py +0 -0
  70. {letify-2.0.0 → letify-2.1.0}/letify/protocol/handle.py +0 -0
  71. {letify-2.0.0 → letify-2.1.0}/letify/protocol/wire.py +0 -0
  72. {letify-2.0.0 → letify-2.1.0}/letify/providers/__init__.py +0 -0
  73. {letify-2.0.0 → letify-2.1.0}/letify/providers/base.py +0 -0
  74. {letify-2.0.0 → letify-2.1.0}/letify/providers/colab.py +0 -0
  75. {letify-2.0.0 → letify-2.1.0}/letify/providers/colab_files.py +0 -0
  76. {letify-2.0.0 → letify-2.1.0}/letify/providers/colab_keepalive.py +0 -0
  77. {letify-2.0.0 → letify-2.1.0}/letify/providers/elice.py +0 -0
  78. {letify-2.0.0 → letify-2.1.0}/letify/providers/kaggle_adapter.py +0 -0
  79. {letify-2.0.0 → letify-2.1.0}/letify/providers/local.py +0 -0
  80. {letify-2.0.0 → letify-2.1.0}/letify/providers/modal.py +0 -0
  81. {letify-2.0.0 → letify-2.1.0}/letify/providers/modal_adapter.py +0 -0
  82. {letify-2.0.0 → letify-2.1.0}/letify/providers/naming.py +0 -0
  83. {letify-2.0.0 → letify-2.1.0}/letify/providers/shell.py +0 -0
  84. {letify-2.0.0 → letify-2.1.0}/letify/providers/tunnel.py +0 -0
  85. {letify-2.0.0 → letify-2.1.0}/letify/providers/usage.py +0 -0
  86. {letify-2.0.0 → letify-2.1.0}/letify/remoting/__init__.py +0 -0
  87. {letify-2.0.0 → letify-2.1.0}/letify/remoting/capability.py +0 -0
  88. {letify-2.0.0 → letify-2.1.0}/letify/remoting/device/__init__.py +0 -0
  89. {letify-2.0.0 → letify-2.1.0}/letify/remoting/device/client.py +0 -0
  90. {letify-2.0.0 → letify-2.1.0}/letify/remoting/device/cuda.py +0 -0
  91. {letify-2.0.0 → letify-2.1.0}/letify/remoting/device/executor.py +0 -0
  92. {letify-2.0.0 → letify-2.1.0}/letify/remoting/device/frames.py +0 -0
  93. {letify-2.0.0 → letify-2.1.0}/letify/remoting/device/guard.py +0 -0
  94. {letify-2.0.0 → letify-2.1.0}/letify/remoting/device/tensor.py +0 -0
  95. {letify-2.0.0 → letify-2.1.0}/letify/remoting/device/trace.py +0 -0
  96. {letify-2.0.0 → letify-2.1.0}/letify/remoting/device/value.py +0 -0
  97. {letify-2.0.0 → letify-2.1.0}/letify/remoting/loader.py +0 -0
  98. {letify-2.0.0 → letify-2.1.0}/letify/remoting/probe.py +0 -0
  99. {letify-2.0.0 → letify-2.1.0}/letify/render.py +0 -0
  100. {letify-2.0.0 → letify-2.1.0}/letify/runtime/__init__.py +0 -0
  101. {letify-2.0.0 → letify-2.1.0}/letify/runtime/bootstrap.py +0 -0
  102. {letify-2.0.0 → letify-2.1.0}/letify/runtime/channel.py +0 -0
  103. {letify-2.0.0 → letify-2.1.0}/letify/runtime/lease.py +0 -0
  104. {letify-2.0.0 → letify-2.1.0}/letify/runtime/pool.py +0 -0
  105. {letify-2.0.0 → letify-2.1.0}/letify/runtime/session.py +0 -0
  106. {letify-2.0.0 → letify-2.1.0}/letify/runtime/telemetry.py +0 -0
  107. {letify-2.0.0 → letify-2.1.0}/letify/store/__init__.py +0 -0
  108. {letify-2.0.0 → letify-2.1.0}/letify/store/backends/__init__.py +0 -0
  109. {letify-2.0.0 → letify-2.1.0}/letify/store/backends/filesystem.py +0 -0
  110. {letify-2.0.0 → letify-2.1.0}/letify/store/backends/google_auth.py +0 -0
  111. {letify-2.0.0 → letify-2.1.0}/letify/store/backends/layout.py +0 -0
  112. {letify-2.0.0 → letify-2.1.0}/letify/store/backends/objects.py +0 -0
  113. {letify-2.0.0 → letify-2.1.0}/letify/store/cas.py +0 -0
  114. {letify-2.0.0 → letify-2.1.0}/letify/store/pathdata.py +0 -0
  115. {letify-2.0.0 → letify-2.1.0}/letify/store/sendorder.py +0 -0
  116. {letify-2.0.0 → letify-2.1.0}/letify/store/volume.py +0 -0
  117. {letify-2.0.0 → letify-2.1.0}/letify/stubs.py +0 -0
  118. {letify-2.0.0 → letify-2.1.0}/letify/tools.py +0 -0
  119. {letify-2.0.0 → letify-2.1.0}/letify/transport/__init__.py +0 -0
  120. {letify-2.0.0 → letify-2.1.0}/letify/transport/agent.py +0 -0
  121. {letify-2.0.0 → letify-2.1.0}/letify/transport/announce.py +0 -0
  122. {letify-2.0.0 → letify-2.1.0}/letify/transport/link.py +0 -0
  123. {letify-2.0.0 → letify-2.1.0}/letify/transport/nat.py +0 -0
  124. {letify-2.0.0 → letify-2.1.0}/letify/transport/pipeline.py +0 -0
  125. {letify-2.0.0 → letify-2.1.0}/letify/transport/portmap.py +0 -0
  126. {letify-2.0.0 → letify-2.1.0}/letify/transport/quic.py +0 -0
  127. {letify-2.0.0 → letify-2.1.0}/letify/transport/rendezvous.py +0 -0
  128. {letify-2.0.0 → letify-2.1.0}/letify/transport/setup.py +0 -0
  129. {letify-2.0.0 → letify-2.1.0}/letify/transport/sshopts.py +0 -0
  130. {letify-2.0.0 → letify-2.1.0}/letify/transport/strategies.py +0 -0
  131. {letify-2.0.0 → letify-2.1.0}/letify-core/.gitignore +0 -0
  132. {letify-2.0.0 → letify-2.1.0}/letify-core/build.py +0 -0
  133. {letify-2.0.0 → letify-2.1.0}/letify-core/letify-agent/Cargo.toml +0 -0
  134. {letify-2.0.0 → letify-2.1.0}/letify-core/letify-agent/src/driver.rs +0 -0
  135. {letify-2.0.0 → letify-2.1.0}/letify-core/letify-agent/src/main.rs +0 -0
  136. {letify-2.0.0 → letify-2.1.0}/letify-core/letify-driver/Cargo.toml +0 -0
  137. {letify-2.0.0 → letify-2.1.0}/letify-core/letify-driver/src/client.rs +0 -0
  138. {letify-2.0.0 → letify-2.1.0}/letify-core/letify-driver/src/exports.rs +0 -0
  139. {letify-2.0.0 → letify-2.1.0}/letify-core/letify-driver/src/image.rs +0 -0
  140. {letify-2.0.0 → letify-2.1.0}/letify-core/letify-driver/src/lib.rs +0 -0
  141. {letify-2.0.0 → letify-2.1.0}/letify-core/letify-driver/src/status.rs +0 -0
  142. {letify-2.0.0 → letify-2.1.0}/letify-core/letify-driver/src/table.rs +0 -0
  143. {letify-2.0.0 → letify-2.1.0}/letify-core/letify-quic/Cargo.toml +0 -0
  144. {letify-2.0.0 → letify-2.1.0}/letify-core/letify-quic/src/main.rs +0 -0
  145. {letify-2.0.0 → letify-2.1.0}/letify-core/letify-wire/Cargo.toml +0 -0
  146. {letify-2.0.0 → letify-2.1.0}/letify-core/letify-wire/src/lib.rs +0 -0
  147. {letify-2.0.0 → letify-2.1.0}/letify-core/letify-wire/tests/throughput.rs +0 -0
  148. {letify-2.0.0 → letify-2.1.0}/letify-core/rust-toolchain.toml +0 -0
  149. {letify-2.0.0 → letify-2.1.0}/letify-ext/.vscodeignore +0 -0
  150. {letify-2.0.0 → letify-2.1.0}/letify-ext/LICENSE +0 -0
  151. {letify-2.0.0 → letify-2.1.0}/letify-ext/README.md +0 -0
  152. {letify-2.0.0 → letify-2.1.0}/letify-ext/media/icon.svg +0 -0
  153. {letify-2.0.0 → letify-2.1.0}/letify-ext/src/cli.ts +0 -0
  154. {letify-2.0.0 → letify-2.1.0}/letify-ext/src/extension.ts +0 -0
  155. {letify-2.0.0 → letify-2.1.0}/letify-ext/src/history.ts +0 -0
  156. {letify-2.0.0 → letify-2.1.0}/letify-ext/src/model.ts +0 -0
  157. {letify-2.0.0 → letify-2.1.0}/letify-ext/src/poll.ts +0 -0
  158. {letify-2.0.0 → letify-2.1.0}/letify-ext/src/render.ts +0 -0
  159. {letify-2.0.0 → letify-2.1.0}/letify-ext/test/cli.test.ts +0 -0
  160. {letify-2.0.0 → letify-2.1.0}/letify-ext/test/manifest.test.ts +0 -0
  161. {letify-2.0.0 → letify-2.1.0}/letify-ext/test/model.test.ts +0 -0
  162. {letify-2.0.0 → letify-2.1.0}/letify-ext/test/regressions.test.ts +0 -0
  163. {letify-2.0.0 → letify-2.1.0}/letify-ext/tsconfig.json +0 -0
  164. {letify-2.0.0 → letify-2.1.0}/letify_providers.py +0 -0
  165. {letify-2.0.0 → letify-2.1.0}/scripts/sync-main.sh +0 -0
  166. {letify-2.0.0 → letify-2.1.0}/scripts/version.py +0 -0
  167. {letify-2.0.0 → letify-2.1.0}/tests/conftest.py +0 -0
  168. {letify-2.0.0 → letify-2.1.0}/tests/fake_kaggle_adapter.py +0 -0
  169. {letify-2.0.0 → letify-2.1.0}/tests/fake_modal_adapter.py +0 -0
  170. {letify-2.0.0 → letify-2.1.0}/tests/test_cli.py +0 -0
  171. {letify-2.0.0 → letify-2.1.0}/tests/test_cli_output.py +0 -0
  172. {letify-2.0.0 → letify-2.1.0}/tests/test_colab_keepalive.py +0 -0
  173. {letify-2.0.0 → letify-2.1.0}/tests/test_config.py +0 -0
  174. {letify-2.0.0 → letify-2.1.0}/tests/test_core.py +0 -0
  175. {letify-2.0.0 → letify-2.1.0}/tests/test_data.py +0 -0
  176. {letify-2.0.0 → letify-2.1.0}/tests/test_declare.py +0 -0
  177. {letify-2.0.0 → letify-2.1.0}/tests/test_device.py +0 -0
  178. {letify-2.0.0 → letify-2.1.0}/tests/test_editor_sessions.py +0 -0
  179. {letify-2.0.0 → letify-2.1.0}/tests/test_elice.py +0 -0
  180. {letify-2.0.0 → letify-2.1.0}/tests/test_frames.py +0 -0
  181. {letify-2.0.0 → letify-2.1.0}/tests/test_install.py +0 -0
  182. {letify-2.0.0 → letify-2.1.0}/tests/test_kaggle.py +0 -0
  183. {letify-2.0.0 → letify-2.1.0}/tests/test_kaggle_cookie.py +0 -0
  184. {letify-2.0.0 → letify-2.1.0}/tests/test_link_log.py +0 -0
  185. {letify-2.0.0 → letify-2.1.0}/tests/test_notebook.py +0 -0
  186. {letify-2.0.0 → letify-2.1.0}/tests/test_parallel_placement.py +0 -0
  187. {letify-2.0.0 → letify-2.1.0}/tests/test_paths.py +0 -0
  188. {letify-2.0.0 → letify-2.1.0}/tests/test_portmap.py +0 -0
  189. {letify-2.0.0 → letify-2.1.0}/tests/test_protocol.py +0 -0
  190. {letify-2.0.0 → letify-2.1.0}/tests/test_providers.py +0 -0
  191. {letify-2.0.0 → letify-2.1.0}/tests/test_quic.py +0 -0
  192. {letify-2.0.0 → letify-2.1.0}/tests/test_remoting.py +0 -0
  193. {letify-2.0.0 → letify-2.1.0}/tests/test_render.py +0 -0
  194. {letify-2.0.0 → letify-2.1.0}/tests/test_runtime.py +0 -0
  195. {letify-2.0.0 → letify-2.1.0}/tests/test_ship.py +0 -0
  196. {letify-2.0.0 → letify-2.1.0}/tests/test_store.py +0 -0
  197. {letify-2.0.0 → letify-2.1.0}/tests/test_strategies.py +0 -0
  198. {letify-2.0.0 → letify-2.1.0}/tests/test_stubs.py +0 -0
  199. {letify-2.0.0 → letify-2.1.0}/tests/test_tunnel_setup.py +0 -0
  200. {letify-2.0.0 → letify-2.1.0}/tests/test_version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: letify
3
- Version: 2.0.0
3
+ Version: 2.1.0
4
4
  Summary: Declarations that become infrastructure.
5
5
  Project-URL: Homepage, https://github.com/DarkPyonix/letify
6
6
  Project-URL: Documentation, https://github.com/DarkPyonix/letify/tree/main/docs
@@ -62,7 +62,7 @@ remote: Final = Host.remote
62
62
  # The enum class stays importable from letify.declare.instance for letify's own use.
63
63
  del Host
64
64
 
65
- __version__ = "2.0.0"
65
+ __version__ = "2.1.0"
66
66
 
67
67
 
68
68
  def fetch(tensor): # type: ignore[no-untyped-def]
@@ -949,13 +949,21 @@ def _data_place_now(state, path, entry):
949
949
  except OSError:
950
950
  pass
951
951
  if not linked:
952
+ # Linked at a name of its own and renamed over the path, never unlinked first.
953
+ # Unlinking leaves the final name absent for as long as the link takes, and when
954
+ # the link then fails the file that was there is lost: a body that listed the
955
+ # directory is left with a name it cannot stat. Spec "What the body sees before a
956
+ # file arrives". _DATA_PARTIAL names are filtered from every patched listing.
957
+ staged = "%s%slink%d" % (path, _DATA_PARTIAL, os.getpid())
952
958
  try:
953
- if os.path.exists(path):
954
- os.remove(path)
955
- os.link(source, path)
959
+ os.link(source, staged)
960
+ os.replace(staged, path)
956
961
  linked = True
957
962
  except OSError:
958
- pass
963
+ try:
964
+ os.remove(staged)
965
+ except OSError:
966
+ pass
959
967
  if not linked:
960
968
  # Copied to a name of its own and renamed into place, so a reader never stats a
961
969
  # file that is half written. Spec "What the body sees before a file arrives".
@@ -1125,7 +1125,7 @@ class Kaggle(Provider):
1125
1125
  )
1126
1126
  return rendezvous
1127
1127
 
1128
- def target_over(self, channel: Any) -> Any:
1128
+ def target_over(self, channel: Any, *, name: str | None = None) -> Any:
1129
1129
  """What the strategies need, with no address because there is none to dial."""
1130
1130
  from ..transport import nat
1131
1131
  from ..transport.strategies import Target
@@ -1133,6 +1133,9 @@ class Kaggle(Provider):
1133
1133
  user = self.config.option("user")
1134
1134
  return Target(
1135
1135
  alias=self.alias,
1136
+ # Every run is a new machine whose ssh-keygen -A makes new host keys, so one
1137
+ # alias for the account would fail every session after the first.
1138
+ host_key_alias=f"letify-{self.alias}-{name}" if name else None,
1136
1139
  rendezvous=self.rendezvous_over(channel),
1137
1140
  remote_python=self.remote_python,
1138
1141
  stun=nat.DEFAULT_STUN,
@@ -1149,12 +1152,12 @@ class Kaggle(Provider):
1149
1152
 
1150
1153
  return [TCPPunch(), TailcatUDP(), QuicUDP(), ProviderFallback(rank=6)]
1151
1154
 
1152
- def link_over(self, channel: Any) -> Any:
1155
+ def link_over(self, channel: Any, *, name: str | None = None) -> Any:
1153
1156
  """Race the strategies over the bridge and return the link that wins."""
1154
1157
  from ..transport.announce import printer
1155
1158
  from ..transport.pipeline import LinkCache, Pipeline, network_fingerprint
1156
1159
 
1157
- target = self.target_over(channel)
1160
+ target = self.target_over(channel, name=name)
1158
1161
  return Pipeline(
1159
1162
  self.strategies(),
1160
1163
  target=target,
@@ -1195,7 +1198,7 @@ class Kaggle(Provider):
1195
1198
  from ..runtime.channel import PersistentChannel
1196
1199
 
1197
1200
  try:
1198
- link = self.link_over(bridge)
1201
+ link = self.link_over(bridge, name=name)
1199
1202
  except Exception:
1200
1203
  return bridge
1201
1204
  self._links[name] = link
@@ -1,8 +1,8 @@
1
1
  """Probe, a short measurement of a Link.
2
2
 
3
- Owns the client side of the probe: 30 round trips, then a timed transfer in each
4
- direction against the responder in ``nat.serve_probe``. It does not own what the numbers
5
- decide; the pipeline does.
3
+ Owns the client side of the probe: 30 round trips, then a discarded warm-up and a timed
4
+ transfer in each direction against the responder in ``nat.serve_probe``. It does not own
5
+ what the numbers decide; the pipeline does.
6
6
  """
7
7
 
8
8
  from __future__ import annotations
@@ -38,8 +38,19 @@ class ProbeResult:
38
38
  return cls(float(data["rtt_ms"]), float(data["upload_bps"]), float(data["download_bps"]))
39
39
 
40
40
 
41
+ #: Round trips of warm-up before the measured transfer, and the bounds on it in seconds.
42
+ WARMUP_ROUND_TRIPS = 8
43
+ WARMUP_MIN_S = 0.5
44
+ WARMUP_MAX_S = 2.0
45
+
46
+
41
47
  class Probe:
42
- """30 round trips, then ``seconds`` of transfer each way. Durations are injectable."""
48
+ """30 round trips, a discarded warm-up, then ``seconds`` of transfer each way.
49
+
50
+ The warm-up exists because TCP's ramp is fixed in round trips while the measurement
51
+ window is fixed in seconds, so without it a far link is charged for its own slow
52
+ start and reports the ramp average. Spec "Choosing a link". Durations are injectable.
53
+ """
43
54
 
44
55
  def __init__(
45
56
  self,
@@ -52,6 +63,11 @@ class Probe:
52
63
  self.seconds = seconds
53
64
  self.clock = clock
54
65
 
66
+ def warmup_for(self, rtt_ms: float) -> float:
67
+ """Seconds of transfer to discard before measuring, from the round trip."""
68
+ wanted = WARMUP_ROUND_TRIPS * rtt_ms / 1000.0
69
+ return min(WARMUP_MAX_S, max(WARMUP_MIN_S, wanted))
70
+
55
71
  def measure(self, stream: Stream) -> ProbeResult:
56
72
  rtts = []
57
73
  for index in range(self.round_trips):
@@ -61,26 +77,36 @@ class Probe:
61
77
  if nat.recv_exact(stream, 9) != b"P" + payload:
62
78
  raise OSError("the probe responder answered out of order")
63
79
  rtts.append((self.clock() - began) * 1000.0)
80
+ rtt_ms = statistics.median(rtts) if rtts else 0.0
81
+
82
+ # The warm-up runs on the same connection, so the window it opens carries over.
83
+ warmup = self.warmup_for(rtt_ms)
84
+ self._upload(stream, warmup)
85
+ upload = self._upload(stream, self.seconds)
86
+ self._download(stream, warmup)
87
+ download = self._download(stream, self.seconds)
88
+ return ProbeResult(rtt_ms, upload, download)
64
89
 
90
+ def _upload(self, stream: Stream, seconds: float) -> float:
65
91
  block = b"\0" * nat.CHUNK
66
92
  stream.sendall(b"U")
67
93
  began = self.clock()
68
- while self.clock() - began < self.seconds:
94
+ while self.clock() - began < seconds:
69
95
  stream.sendall(struct.pack("!I", len(block)) + block)
70
96
  stream.sendall(struct.pack("!I", 0))
71
97
  received = struct.unpack("!Q", nat.recv_exact(stream, 8))[0]
72
- upload = received / max(self.clock() - began, 1e-9)
98
+ return received / max(self.clock() - began, 1e-9)
73
99
 
100
+ def _download(self, stream: Stream, seconds: float) -> float:
74
101
  began = self.clock()
75
- stream.sendall(b"D" + struct.pack("!d", self.seconds))
102
+ stream.sendall(b"D" + struct.pack("!d", seconds))
76
103
  total = 0
77
104
  while True:
78
105
  size = struct.unpack("!I", nat.recv_exact(stream, 4))[0]
79
106
  if size == 0:
80
107
  break
81
108
  total += len(nat.recv_exact(stream, size))
82
- download = total / max(self.clock() - began, 1e-9)
83
- return ProbeResult(statistics.median(rtts) if rtts else 0.0, upload, download)
109
+ return total / max(self.clock() - began, 1e-9)
84
110
 
85
111
 
86
112
  __all__ = ["Probe", "ProbeResult", "Stream"]
@@ -138,7 +138,7 @@ dependencies = [
138
138
 
139
139
  [[package]]
140
140
  name = "letify-agent"
141
- version = "2.0.0"
141
+ version = "2.1.0"
142
142
  dependencies = [
143
143
  "letify-wire",
144
144
  "libloading",
@@ -146,7 +146,7 @@ dependencies = [
146
146
 
147
147
  [[package]]
148
148
  name = "letify-driver"
149
- version = "2.0.0"
149
+ version = "2.1.0"
150
150
  dependencies = [
151
151
  "letify-wire",
152
152
  ]
@@ -164,7 +164,7 @@ dependencies = [
164
164
 
165
165
  [[package]]
166
166
  name = "letify-wire"
167
- version = "2.0.0"
167
+ version = "2.1.0"
168
168
 
169
169
  [[package]]
170
170
  name = "libc"
@@ -8,7 +8,7 @@ resolver = "3"
8
8
  members = ["letify-wire", "letify-driver", "letify-agent", "letify-quic"]
9
9
 
10
10
  [workspace.package]
11
- version = "2.0.0"
11
+ version = "2.1.0"
12
12
  edition = "2024"
13
13
  license = "MIT"
14
14
  repository = "https://github.com/DarkPyonix/letify"
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "letify-status",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "letify-status",
9
- "version": "2.0.0",
9
+ "version": "2.1.0",
10
10
  "license": "SEE LICENSE IN LICENSE",
11
11
  "devDependencies": {
12
12
  "@types/node": "^18.19.0",
@@ -2,7 +2,7 @@
2
2
  "name": "letify-status",
3
3
  "displayName": "letify Status",
4
4
  "description": "letify quota and GPU activity in the status bar, read from the letify CLI.",
5
- "version": "2.0.0",
5
+ "version": "2.1.0",
6
6
  "publisher": "darkpyonix",
7
7
  "license": "SEE LICENSE IN LICENSE",
8
8
  "repository": {
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "letify"
3
- version = "2.0.0"
3
+ version = "2.1.0"
4
4
  description = "Declarations that become infrastructure."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -15,6 +15,7 @@ from __future__ import annotations
15
15
 
16
16
  import collections
17
17
  import re
18
+ import threading
18
19
  import time
19
20
  from pathlib import Path
20
21
 
@@ -752,10 +753,28 @@ def test_a_blob_that_never_arrives_fails_the_open(
752
753
 
753
754
 
754
755
  def test_a_call_that_returns_early_cancels_the_transfer(
755
- launcher_from, project, capsys
756
+ launcher_from, project, capsys, monkeypatch
756
757
  ) -> None:
758
+ # Spec "A call that finishes before the background transfer". Each piece sent after
759
+ # the call request waits 50 ms, so the cancellation is certain to arrive while the
760
+ # transfer is still running. Without that the test asserts a timing accident: on a
761
+ # local channel all 23 MiB can go out in 0.1 s, before the body has even returned,
762
+ # and nothing is left to cancel. It failed that way in 2 of 4 runs pinned to two
763
+ # cores, which is what CI runs on.
757
764
  let = streaming(launcher_from, data_first_wave_files=1)
758
765
  root = dataset(project, 24, size=1 << 20)
766
+ calling = threading.Event()
767
+ original_message = wire.Sender.message
768
+
769
+ def message(self_, kind, stream, obj):
770
+ if kind == wire.REQUEST and isinstance(obj, dict):
771
+ if obj.get("op") == "call":
772
+ calling.set()
773
+ elif obj.get("op") == "data_put" and calling.is_set():
774
+ time.sleep(0.05)
775
+ return original_message(self_, kind, stream, obj)
776
+
777
+ monkeypatch.setattr(wire.Sender, "message", message)
759
778
 
760
779
  @let.function(device=let.providers.lab.CPU, host=letify.remote)
761
780
  def peek(directory: Path) -> str:
@@ -131,7 +131,7 @@ def test_a_chosen_link_becomes_the_channel(monkeypatch) -> None:
131
131
  built.append(command)
132
132
  self.name = name
133
133
 
134
- monkeypatch.setattr(provider, "link_over", lambda channel: Link())
134
+ monkeypatch.setattr(provider, "link_over", lambda channel, name=None: Link())
135
135
  monkeypatch.setattr("letify.runtime.channel.PersistentChannel", Fake)
136
136
  channel = provider.channel_over(Channel(), name="k-1")
137
137
  assert isinstance(channel, Fake)
@@ -230,3 +230,16 @@ def test_the_kaggle_punch_target_logs_in_as_root_with_the_authorized_key(tmp_pat
230
230
  command = target.forwarded_ssh(40000, "python3 -c pass")
231
231
  assert "root@127.0.0.1" in command
232
232
  assert command[command.index("-i") + 1] == str(key)
233
+
234
+
235
+ def test_a_kaggle_runtime_pins_its_host_key_under_its_own_alias() -> None:
236
+ """Spec "Kaggle runtimes", An SSH link over the kernel: each run is a new machine.
237
+
238
+ One alias for the account fails every session after the first with REMOTE HOST
239
+ IDENTIFICATION HAS CHANGED, because ssh-keygen -A makes new host keys on each VM.
240
+ """
241
+ provider = kaggle()
242
+ target = provider.target_over(object(), name="letify-cpu-abc123")
243
+ assert target.host_key_alias == "letify-kaggle_a-letify-cpu-abc123"
244
+ command = target.forwarded_ssh(40000)
245
+ assert command[command.index("HostKeyAlias=letify-kaggle_a-letify-cpu-abc123")]
@@ -224,6 +224,46 @@ def test_the_probe_defaults_are_thirty_round_trips_and_two_seconds_each_way() ->
224
224
  assert (probe.round_trips, probe.seconds) == (30, 2.0)
225
225
 
226
226
 
227
+ def test_the_warm_up_is_eight_round_trips_bounded_to_half_a_second_and_two() -> None:
228
+ # Spec "Choosing a link": long enough for TCP to leave slow start, and no longer.
229
+ probe = Probe()
230
+ assert probe.warmup_for(0.0) == 0.5
231
+ assert probe.warmup_for(10.0) == 0.5
232
+ assert probe.warmup_for(190.0) == pytest.approx(1.52)
233
+ assert probe.warmup_for(1000.0) == 2.0
234
+
235
+
236
+ def test_the_probe_discards_a_warm_up_transfer_in_each_direction() -> None:
237
+ """Spec "Choosing a link": the measured transfer is not charged for the ramp.
238
+
239
+ The responder is asked to transfer four times, a warm-up and a measurement each way.
240
+ Only the measured ones decide the numbers, so a stream that is slow until it is warm
241
+ reports its warm rate.
242
+ """
243
+ user, remote = socket.socketpair()
244
+ threading.Thread(target=nat.serve_probe, args=(remote,), daemon=True).start()
245
+ probe = Probe(round_trips=2, seconds=0.02)
246
+ probe.warmup_for = lambda _rtt_ms: 0.02
247
+ seen: list[bytes] = []
248
+
249
+ class Watched:
250
+ """The stream, recording which transfer ops the probe asked for."""
251
+
252
+ def recv(self, size: int) -> bytes:
253
+ return user.recv(size)
254
+
255
+ def sendall(self, data: bytes) -> None:
256
+ if data[:1] in (b"U", b"D"):
257
+ seen.append(data[:1])
258
+ user.sendall(data)
259
+
260
+ measured = probe.measure(Watched())
261
+ user.close()
262
+ assert seen == [b"U", b"U", b"D", b"D"]
263
+ assert measured.upload_bps > 0
264
+ assert measured.download_bps > 0
265
+
266
+
227
267
  def test_a_responder_asked_to_bridge_stops_answering_the_probe() -> None:
228
268
  user, remote = socket.socketpair()
229
269
  user.sendall(b"B")
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes