letify 1.2.0__tar.gz → 1.2.1__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 (191) hide show
  1. {letify-1.2.0 → letify-1.2.1}/PKG-INFO +4 -1
  2. {letify-1.2.0 → letify-1.2.1}/README.md +3 -0
  3. {letify-1.2.0 → letify-1.2.1}/docs/guide/07-troubleshooting.md +1 -1
  4. {letify-1.2.0 → letify-1.2.1}/docs/guide/ko/07-/353/254/270/354/240/234-/355/225/264/352/262/260.md +1 -1
  5. {letify-1.2.0 → letify-1.2.1}/docs/locales/README_ko.md +3 -0
  6. {letify-1.2.0 → letify-1.2.1}/letify/__init__.py +1 -1
  7. {letify-1.2.0 → letify-1.2.1}/letify/providers/colab.py +21 -3
  8. {letify-1.2.0 → letify-1.2.1}/letify/providers/kaggle.py +18 -7
  9. {letify-1.2.0 → letify-1.2.1}/letify/tools.py +2 -2
  10. {letify-1.2.0 → letify-1.2.1}/letify/transport/pipeline.py +18 -11
  11. {letify-1.2.0 → letify-1.2.1}/letify-core/Cargo.lock +3 -3
  12. {letify-1.2.0 → letify-1.2.1}/letify-core/Cargo.toml +1 -1
  13. {letify-1.2.0 → letify-1.2.1}/letify-ext/package-lock.json +2 -2
  14. {letify-1.2.0 → letify-1.2.1}/letify-ext/package.json +1 -1
  15. {letify-1.2.0 → letify-1.2.1}/pyproject.toml +1 -1
  16. {letify-1.2.0 → letify-1.2.1}/tests/test_kaggle.py +82 -0
  17. {letify-1.2.0 → letify-1.2.1}/tests/test_providers.py +52 -0
  18. {letify-1.2.0 → letify-1.2.1}/tests/test_transport.py +69 -2
  19. {letify-1.2.0 → letify-1.2.1}/.github/workflows/ci.yml +0 -0
  20. {letify-1.2.0 → letify-1.2.1}/.github/workflows/docs.yml +0 -0
  21. {letify-1.2.0 → letify-1.2.1}/.github/workflows/publish.yml +0 -0
  22. {letify-1.2.0 → letify-1.2.1}/.gitignore +0 -0
  23. {letify-1.2.0 → letify-1.2.1}/.researchtree +0 -0
  24. {letify-1.2.0 → letify-1.2.1}/LICENSE +0 -0
  25. {letify-1.2.0 → letify-1.2.1}/docs/guide/01-getting-started.md +0 -0
  26. {letify-1.2.0 → letify-1.2.1}/docs/guide/02-providers.md +0 -0
  27. {letify-1.2.0 → letify-1.2.1}/docs/guide/03-execution-modes.md +0 -0
  28. {letify-1.2.0 → letify-1.2.1}/docs/guide/04-environments-and-data.md +0 -0
  29. {letify-1.2.0 → letify-1.2.1}/docs/guide/05-concurrency.md +0 -0
  30. {letify-1.2.0 → letify-1.2.1}/docs/guide/06-cost.md +0 -0
  31. {letify-1.2.0 → letify-1.2.1}/docs/guide/08-releasing.md +0 -0
  32. {letify-1.2.0 → letify-1.2.1}/docs/guide/README.md +0 -0
  33. {letify-1.2.0 → letify-1.2.1}/docs/guide/ko/01-/354/213/234/354/236/221/355/225/230/352/270/260.md" +0 -0
  34. {letify-1.2.0 → letify-1.2.1}/docs/guide/ko/02-/355/224/204/353/241/234/353/260/224/354/235/264/353/215/224.md" +0 -0
  35. {letify-1.2.0 → letify-1.2.1}/docs/guide/ko/03-/354/213/244/355/226/211-/353/260/251/354/213/235.md" +0 -0
  36. {letify-1.2.0 → letify-1.2.1}/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
  37. {letify-1.2.0 → letify-1.2.1}/docs/guide/ko/05-/353/217/231/354/213/234-/354/213/244/355/226/211.md" +0 -0
  38. {letify-1.2.0 → letify-1.2.1}/docs/guide/ko/06-/353/271/204/354/232/251.md" +0 -0
  39. {letify-1.2.0 → letify-1.2.1}/docs/guide/ko/08-/353/246/264/353/246/254/354/212/244.md" +0 -0
  40. {letify-1.2.0 → letify-1.2.1}/docs/guide/ko/README.md +0 -0
  41. {letify-1.2.0 → letify-1.2.1}/docs/mkdocs.yml +0 -0
  42. {letify-1.2.0 → letify-1.2.1}/examples/README.md +0 -0
  43. {letify-1.2.0 → letify-1.2.1}/examples/nvfp4-lora/.letify.example +0 -0
  44. {letify-1.2.0 → letify-1.2.1}/examples/nvfp4-lora/00_smoke.py +0 -0
  45. {letify-1.2.0 → letify-1.2.1}/examples/nvfp4-lora/01_sweep.py +0 -0
  46. {letify-1.2.0 → letify-1.2.1}/examples/nvfp4-lora/02_resume.py +0 -0
  47. {letify-1.2.0 → letify-1.2.1}/examples/nvfp4-lora/03_watch.py +0 -0
  48. {letify-1.2.0 → letify-1.2.1}/examples/nvfp4-lora/README.md +0 -0
  49. {letify-1.2.0 → letify-1.2.1}/examples/nvfp4-lora/README_ko.md +0 -0
  50. {letify-1.2.0 → letify-1.2.1}/examples/nvfp4-lora/common.py +0 -0
  51. {letify-1.2.0 → letify-1.2.1}/examples/nvfp4-lora/recipe.py +0 -0
  52. {letify-1.2.0 → letify-1.2.1}/letify/cli.py +0 -0
  53. {letify-1.2.0 → letify-1.2.1}/letify/config/__init__.py +0 -0
  54. {letify-1.2.0 → letify-1.2.1}/letify/config/inventory.py +0 -0
  55. {letify-1.2.0 → letify-1.2.1}/letify/config/login.py +0 -0
  56. {letify-1.2.0 → letify-1.2.1}/letify/config/schema.py +0 -0
  57. {letify-1.2.0 → letify-1.2.1}/letify/config/secrets.py +0 -0
  58. {letify-1.2.0 → letify-1.2.1}/letify/config/writer.py +0 -0
  59. {letify-1.2.0 → letify-1.2.1}/letify/declare/__init__.py +0 -0
  60. {letify-1.2.0 → letify-1.2.1}/letify/declare/cache.py +0 -0
  61. {letify-1.2.0 → letify-1.2.1}/letify/declare/env.py +0 -0
  62. {letify-1.2.0 → letify-1.2.1}/letify/declare/function.py +0 -0
  63. {letify-1.2.0 → letify-1.2.1}/letify/declare/instance.py +0 -0
  64. {letify-1.2.0 → letify-1.2.1}/letify/errors.py +0 -0
  65. {letify-1.2.0 → letify-1.2.1}/letify/install.py +0 -0
  66. {letify-1.2.0 → letify-1.2.1}/letify/launcher.py +0 -0
  67. {letify-1.2.0 → letify-1.2.1}/letify/paths.py +0 -0
  68. {letify-1.2.0 → letify-1.2.1}/letify/protocol/__init__.py +0 -0
  69. {letify-1.2.0 → letify-1.2.1}/letify/protocol/codec.py +0 -0
  70. {letify-1.2.0 → letify-1.2.1}/letify/protocol/driver.py +0 -0
  71. {letify-1.2.0 → letify-1.2.1}/letify/protocol/handle.py +0 -0
  72. {letify-1.2.0 → letify-1.2.1}/letify/protocol/wire.py +0 -0
  73. {letify-1.2.0 → letify-1.2.1}/letify/protocol/worker.py +0 -0
  74. {letify-1.2.0 → letify-1.2.1}/letify/providers/__init__.py +0 -0
  75. {letify-1.2.0 → letify-1.2.1}/letify/providers/base.py +0 -0
  76. {letify-1.2.0 → letify-1.2.1}/letify/providers/colab_files.py +0 -0
  77. {letify-1.2.0 → letify-1.2.1}/letify/providers/colab_keepalive.py +0 -0
  78. {letify-1.2.0 → letify-1.2.1}/letify/providers/elice.py +0 -0
  79. {letify-1.2.0 → letify-1.2.1}/letify/providers/kaggle_adapter.py +0 -0
  80. {letify-1.2.0 → letify-1.2.1}/letify/providers/local.py +0 -0
  81. {letify-1.2.0 → letify-1.2.1}/letify/providers/modal.py +0 -0
  82. {letify-1.2.0 → letify-1.2.1}/letify/providers/modal_adapter.py +0 -0
  83. {letify-1.2.0 → letify-1.2.1}/letify/providers/naming.py +0 -0
  84. {letify-1.2.0 → letify-1.2.1}/letify/providers/shell.py +0 -0
  85. {letify-1.2.0 → letify-1.2.1}/letify/providers/tunnel.py +0 -0
  86. {letify-1.2.0 → letify-1.2.1}/letify/providers/usage.py +0 -0
  87. {letify-1.2.0 → letify-1.2.1}/letify/remoting/__init__.py +0 -0
  88. {letify-1.2.0 → letify-1.2.1}/letify/remoting/capability.py +0 -0
  89. {letify-1.2.0 → letify-1.2.1}/letify/remoting/device/__init__.py +0 -0
  90. {letify-1.2.0 → letify-1.2.1}/letify/remoting/device/client.py +0 -0
  91. {letify-1.2.0 → letify-1.2.1}/letify/remoting/device/cuda.py +0 -0
  92. {letify-1.2.0 → letify-1.2.1}/letify/remoting/device/executor.py +0 -0
  93. {letify-1.2.0 → letify-1.2.1}/letify/remoting/device/frames.py +0 -0
  94. {letify-1.2.0 → letify-1.2.1}/letify/remoting/device/guard.py +0 -0
  95. {letify-1.2.0 → letify-1.2.1}/letify/remoting/device/tensor.py +0 -0
  96. {letify-1.2.0 → letify-1.2.1}/letify/remoting/device/trace.py +0 -0
  97. {letify-1.2.0 → letify-1.2.1}/letify/remoting/device/value.py +0 -0
  98. {letify-1.2.0 → letify-1.2.1}/letify/remoting/loader.py +0 -0
  99. {letify-1.2.0 → letify-1.2.1}/letify/remoting/probe.py +0 -0
  100. {letify-1.2.0 → letify-1.2.1}/letify/render.py +0 -0
  101. {letify-1.2.0 → letify-1.2.1}/letify/runtime/__init__.py +0 -0
  102. {letify-1.2.0 → letify-1.2.1}/letify/runtime/bootstrap.py +0 -0
  103. {letify-1.2.0 → letify-1.2.1}/letify/runtime/channel.py +0 -0
  104. {letify-1.2.0 → letify-1.2.1}/letify/runtime/lease.py +0 -0
  105. {letify-1.2.0 → letify-1.2.1}/letify/runtime/pool.py +0 -0
  106. {letify-1.2.0 → letify-1.2.1}/letify/runtime/session.py +0 -0
  107. {letify-1.2.0 → letify-1.2.1}/letify/runtime/telemetry.py +0 -0
  108. {letify-1.2.0 → letify-1.2.1}/letify/store/__init__.py +0 -0
  109. {letify-1.2.0 → letify-1.2.1}/letify/store/backends/__init__.py +0 -0
  110. {letify-1.2.0 → letify-1.2.1}/letify/store/backends/filesystem.py +0 -0
  111. {letify-1.2.0 → letify-1.2.1}/letify/store/backends/google_auth.py +0 -0
  112. {letify-1.2.0 → letify-1.2.1}/letify/store/backends/layout.py +0 -0
  113. {letify-1.2.0 → letify-1.2.1}/letify/store/backends/objects.py +0 -0
  114. {letify-1.2.0 → letify-1.2.1}/letify/store/cas.py +0 -0
  115. {letify-1.2.0 → letify-1.2.1}/letify/store/pathdata.py +0 -0
  116. {letify-1.2.0 → letify-1.2.1}/letify/store/sendorder.py +0 -0
  117. {letify-1.2.0 → letify-1.2.1}/letify/store/volume.py +0 -0
  118. {letify-1.2.0 → letify-1.2.1}/letify/stubs.py +0 -0
  119. {letify-1.2.0 → letify-1.2.1}/letify/transport/__init__.py +0 -0
  120. {letify-1.2.0 → letify-1.2.1}/letify/transport/agent.py +0 -0
  121. {letify-1.2.0 → letify-1.2.1}/letify/transport/announce.py +0 -0
  122. {letify-1.2.0 → letify-1.2.1}/letify/transport/link.py +0 -0
  123. {letify-1.2.0 → letify-1.2.1}/letify/transport/nat.py +0 -0
  124. {letify-1.2.0 → letify-1.2.1}/letify/transport/probe.py +0 -0
  125. {letify-1.2.0 → letify-1.2.1}/letify/transport/rendezvous.py +0 -0
  126. {letify-1.2.0 → letify-1.2.1}/letify/transport/setup.py +0 -0
  127. {letify-1.2.0 → letify-1.2.1}/letify/transport/sshopts.py +0 -0
  128. {letify-1.2.0 → letify-1.2.1}/letify/transport/strategies.py +0 -0
  129. {letify-1.2.0 → letify-1.2.1}/letify-core/.gitignore +0 -0
  130. {letify-1.2.0 → letify-1.2.1}/letify-core/build.py +0 -0
  131. {letify-1.2.0 → letify-1.2.1}/letify-core/letify-agent/Cargo.toml +0 -0
  132. {letify-1.2.0 → letify-1.2.1}/letify-core/letify-agent/src/driver.rs +0 -0
  133. {letify-1.2.0 → letify-1.2.1}/letify-core/letify-agent/src/main.rs +0 -0
  134. {letify-1.2.0 → letify-1.2.1}/letify-core/letify-driver/Cargo.toml +0 -0
  135. {letify-1.2.0 → letify-1.2.1}/letify-core/letify-driver/src/client.rs +0 -0
  136. {letify-1.2.0 → letify-1.2.1}/letify-core/letify-driver/src/exports.rs +0 -0
  137. {letify-1.2.0 → letify-1.2.1}/letify-core/letify-driver/src/image.rs +0 -0
  138. {letify-1.2.0 → letify-1.2.1}/letify-core/letify-driver/src/lib.rs +0 -0
  139. {letify-1.2.0 → letify-1.2.1}/letify-core/letify-driver/src/status.rs +0 -0
  140. {letify-1.2.0 → letify-1.2.1}/letify-core/letify-driver/src/table.rs +0 -0
  141. {letify-1.2.0 → letify-1.2.1}/letify-core/letify-wire/Cargo.toml +0 -0
  142. {letify-1.2.0 → letify-1.2.1}/letify-core/letify-wire/src/lib.rs +0 -0
  143. {letify-1.2.0 → letify-1.2.1}/letify-core/letify-wire/tests/throughput.rs +0 -0
  144. {letify-1.2.0 → letify-1.2.1}/letify-core/rust-toolchain.toml +0 -0
  145. {letify-1.2.0 → letify-1.2.1}/letify-ext/.vscodeignore +0 -0
  146. {letify-1.2.0 → letify-1.2.1}/letify-ext/LICENSE +0 -0
  147. {letify-1.2.0 → letify-1.2.1}/letify-ext/README.md +0 -0
  148. {letify-1.2.0 → letify-1.2.1}/letify-ext/media/icon.svg +0 -0
  149. {letify-1.2.0 → letify-1.2.1}/letify-ext/src/cli.ts +0 -0
  150. {letify-1.2.0 → letify-1.2.1}/letify-ext/src/extension.ts +0 -0
  151. {letify-1.2.0 → letify-1.2.1}/letify-ext/src/history.ts +0 -0
  152. {letify-1.2.0 → letify-1.2.1}/letify-ext/src/model.ts +0 -0
  153. {letify-1.2.0 → letify-1.2.1}/letify-ext/src/poll.ts +0 -0
  154. {letify-1.2.0 → letify-1.2.1}/letify-ext/src/render.ts +0 -0
  155. {letify-1.2.0 → letify-1.2.1}/letify-ext/test/cli.test.ts +0 -0
  156. {letify-1.2.0 → letify-1.2.1}/letify-ext/test/manifest.test.ts +0 -0
  157. {letify-1.2.0 → letify-1.2.1}/letify-ext/test/model.test.ts +0 -0
  158. {letify-1.2.0 → letify-1.2.1}/letify-ext/test/regressions.test.ts +0 -0
  159. {letify-1.2.0 → letify-1.2.1}/letify-ext/tsconfig.json +0 -0
  160. {letify-1.2.0 → letify-1.2.1}/letify_providers.py +0 -0
  161. {letify-1.2.0 → letify-1.2.1}/scripts/sync-main.sh +0 -0
  162. {letify-1.2.0 → letify-1.2.1}/scripts/version.py +0 -0
  163. {letify-1.2.0 → letify-1.2.1}/tests/conftest.py +0 -0
  164. {letify-1.2.0 → letify-1.2.1}/tests/fake_kaggle_adapter.py +0 -0
  165. {letify-1.2.0 → letify-1.2.1}/tests/fake_modal_adapter.py +0 -0
  166. {letify-1.2.0 → letify-1.2.1}/tests/test_cli.py +0 -0
  167. {letify-1.2.0 → letify-1.2.1}/tests/test_cli_output.py +0 -0
  168. {letify-1.2.0 → letify-1.2.1}/tests/test_colab_keepalive.py +0 -0
  169. {letify-1.2.0 → letify-1.2.1}/tests/test_config.py +0 -0
  170. {letify-1.2.0 → letify-1.2.1}/tests/test_core.py +0 -0
  171. {letify-1.2.0 → letify-1.2.1}/tests/test_data.py +0 -0
  172. {letify-1.2.0 → letify-1.2.1}/tests/test_data_streaming.py +0 -0
  173. {letify-1.2.0 → letify-1.2.1}/tests/test_declare.py +0 -0
  174. {letify-1.2.0 → letify-1.2.1}/tests/test_device.py +0 -0
  175. {letify-1.2.0 → letify-1.2.1}/tests/test_editor_sessions.py +0 -0
  176. {letify-1.2.0 → letify-1.2.1}/tests/test_elice.py +0 -0
  177. {letify-1.2.0 → letify-1.2.1}/tests/test_frames.py +0 -0
  178. {letify-1.2.0 → letify-1.2.1}/tests/test_install.py +0 -0
  179. {letify-1.2.0 → letify-1.2.1}/tests/test_kaggle_cookie.py +0 -0
  180. {letify-1.2.0 → letify-1.2.1}/tests/test_link_log.py +0 -0
  181. {letify-1.2.0 → letify-1.2.1}/tests/test_notebook.py +0 -0
  182. {letify-1.2.0 → letify-1.2.1}/tests/test_paths.py +0 -0
  183. {letify-1.2.0 → letify-1.2.1}/tests/test_protocol.py +0 -0
  184. {letify-1.2.0 → letify-1.2.1}/tests/test_remoting.py +0 -0
  185. {letify-1.2.0 → letify-1.2.1}/tests/test_render.py +0 -0
  186. {letify-1.2.0 → letify-1.2.1}/tests/test_runtime.py +0 -0
  187. {letify-1.2.0 → letify-1.2.1}/tests/test_store.py +0 -0
  188. {letify-1.2.0 → letify-1.2.1}/tests/test_strategies.py +0 -0
  189. {letify-1.2.0 → letify-1.2.1}/tests/test_stubs.py +0 -0
  190. {letify-1.2.0 → letify-1.2.1}/tests/test_tunnel_setup.py +0 -0
  191. {letify-1.2.0 → letify-1.2.1}/tests/test_version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: letify
3
- Version: 1.2.0
3
+ Version: 1.2.1
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
@@ -192,6 +192,7 @@ Each command writes two files. The account goes to `~/.letify/config.toml`, whic
192
192
  [colab_pro_plus]
193
193
  kind = "colab"
194
194
  account = "you@example.com"
195
+ min_mib_per_s = 0.05
195
196
 
196
197
  [lab_a100]
197
198
  kind = "shell"
@@ -201,6 +202,8 @@ key = "~/.ssh/id_ed25519"
201
202
  persistent = true
202
203
  ```
203
204
 
205
+ The connection pipeline defaults to `10 MiB/s` in each direction and `300 ms` RTT. Colab's measured sustained best is `8.8 MiB/s` up and `10.7 MiB/s` down, so it fails the default floor. The account example explicitly permits a slow link with `min_mib_per_s = 0.05`. Set `min_mib_per_s` and `max_rtt_ms` for your measurements. When all measured links fail the floor, the connection is refused even if an unprobed fallback connected.
206
+
204
207
  The project's `.letify/config.toml` gets only the alias, which is what makes the account usable in this project:
205
208
 
206
209
  ```toml
@@ -167,6 +167,7 @@ Each command writes two files. The account goes to `~/.letify/config.toml`, whic
167
167
  [colab_pro_plus]
168
168
  kind = "colab"
169
169
  account = "you@example.com"
170
+ min_mib_per_s = 0.05
170
171
 
171
172
  [lab_a100]
172
173
  kind = "shell"
@@ -176,6 +177,8 @@ key = "~/.ssh/id_ed25519"
176
177
  persistent = true
177
178
  ```
178
179
 
180
+ The connection pipeline defaults to `10 MiB/s` in each direction and `300 ms` RTT. Colab's measured sustained best is `8.8 MiB/s` up and `10.7 MiB/s` down, so it fails the default floor. The account example explicitly permits a slow link with `min_mib_per_s = 0.05`. Set `min_mib_per_s` and `max_rtt_ms` for your measurements. When all measured links fail the floor, the connection is refused even if an unprobed fallback connected.
181
+
179
182
  The project's `.letify/config.toml` gets only the alias, which is what makes the account usable in this project:
180
183
 
181
184
  ```toml
@@ -264,7 +264,7 @@ kind = "tunnel"
264
264
  mtu = 1280
265
265
  ```
266
266
 
267
- letify holds every connection strategy, Tailcat's hole punch included, to a floor: a probe below 5 MiB/s or above 300 ms round trip fails the connection instead of completing it, because a slow link is a failure, not a fallback. See [docs/SPEC.md, Link floor](../SPEC.md#link-floor). If the connection now fails with "below the floor", that is the diagnosis: the path is too slow to use, usually because Tailcat's own direct UDP punch did not succeed and it fell back to a relay. No relay path is offered as an alternative, so fix the underlying reachability instead: open the UDP port the punch needs, or set `address` to a direct SSH path if one exists on the same network. See [docs/NETWORK.md](../NETWORK.md).
267
+ letify holds every connection strategy, Tailcat's hole punch included, to a floor: a probe below 10 MiB/s or above 300 ms round trip fails the connection instead of completing it, because a slow link is a failure, not a fallback. See [docs/SPEC.md, Link floor](../SPEC.md#link-floor). If the connection now fails with "below the floor", that is the diagnosis: the path is too slow to use, usually because Tailcat's own direct UDP punch did not succeed and it fell back to a relay. No relay path is offered as an alternative, so fix the underlying reachability instead: open the UDP port the punch needs, or set `address` to a direct SSH path if one exists on the same network. See [docs/NETWORK.md](../NETWORK.md).
268
268
 
269
269
  ### Gathered calls are not running in parallel
270
270
 
@@ -264,7 +264,7 @@ kind = "tunnel"
264
264
  mtu = 1280
265
265
  ```
266
266
 
267
- letify는 Tailcat 홀펀칭을 포함한 모든 연결 전략을 바닥값에 묶어 둡니다: 측정값이 5 MiB/s 미만이거나 왕복 300 ms를 넘으면 연결을 완료시키는 대신 실패시킵니다. 느린 링크는 폴백이 아니라 실패이기 때문입니다. [docs/SPEC.md의 Link floor](../../SPEC.md#link-floor)를 보세요. 이제 "below the floor"로 연결이 실패한다면 그것이 진단입니다: 경로가 너무 느려 쓸 수 없고, 보통 Tailcat의 직접 UDP 홀펀칭이 성공하지 못해 중계로 떨어졌기 때문입니다. 대안으로 중계 경로를 제공하지 않으므로, 도달성 자체를 고치세요: 홀펀칭에 필요한 UDP 포트를 열거나, 같은 망에 직접 SSH 경로가 있다면 `address`를 설정하세요. [docs/NETWORK.md](../../NETWORK.md)를 보세요.
267
+ letify는 Tailcat 홀펀칭을 포함한 모든 연결 전략을 바닥값에 묶어 둡니다: 측정값이 10 MiB/s 미만이거나 왕복 300 ms를 넘으면 연결을 완료시키는 대신 실패시킵니다. 느린 링크는 폴백이 아니라 실패이기 때문입니다. [docs/SPEC.md의 Link floor](../../SPEC.md#link-floor)를 보세요. 이제 "below the floor"로 연결이 실패한다면 그것이 진단입니다: 경로가 너무 느려 쓸 수 없고, 보통 Tailcat의 직접 UDP 홀펀칭이 성공하지 못해 중계로 떨어졌기 때문입니다. 대안으로 중계 경로를 제공하지 않으므로, 도달성 자체를 고치세요: 홀펀칭에 필요한 UDP 포트를 열거나, 같은 망에 직접 SSH 경로가 있다면 `address`를 설정하세요. [docs/NETWORK.md](../../NETWORK.md)를 보세요.
268
268
 
269
269
  ### 모은 호출이 병렬로 돌지 않습니다
270
270
 
@@ -161,6 +161,7 @@ letify login shell lab_a100
161
161
  [colab_pro_plus]
162
162
  kind = "colab"
163
163
  account = "you@example.com"
164
+ min_mib_per_s = 0.05
164
165
 
165
166
  [lab_a100]
166
167
  kind = "shell"
@@ -170,6 +171,8 @@ key = "~/.ssh/id_ed25519"
170
171
  persistent = true
171
172
  ```
172
173
 
174
+ 연결 파이프라인의 기본 기준은 양방향 `10 MiB/s`, RTT `300 ms`입니다. Colab의 측정된 지속 전송 최고값은 올리기 `8.8 MiB/s`, 내리기 `10.7 MiB/s`라서 기본 기준을 통과하지 못합니다. 위 계정의 `min_mib_per_s = 0.05`는 느린 연결을 명시적으로 허용하는 예시입니다. 실제 측정에 맞춰 `min_mib_per_s`와 `max_rtt_ms`를 설정하세요. 측정된 링크가 모두 기준 미달이면 측정하지 않은 fallback으로 바꾸지 않고 연결을 거부합니다.
175
+
173
176
  프로젝트의 `.letify/config.toml`에는 alias만 들어갑니다. 이 줄이 있어야 그 계정을 이 프로젝트에서 쓸 수 있습니다.
174
177
 
175
178
  ```toml
@@ -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__ = "1.2.0"
65
+ __version__ = "1.2.1"
66
66
 
67
67
 
68
68
  def fetch(tensor): # type: ignore[no-untyped-def]
@@ -281,7 +281,27 @@ class Colab(Shell):
281
281
  from .colab_keepalive import account_lock
282
282
 
283
283
  with account_lock(account_directory(self.alias) / ".config" / "colab-cli"):
284
- return self._cli("exec", "-s", session, stdin=source, timeout=timeout)
284
+ try:
285
+ return self._cli("exec", "-s", session, stdin=source, timeout=timeout)
286
+ except subprocess.TimeoutExpired as exc:
287
+ stderr = exc.stderr or ""
288
+ if isinstance(stderr, bytes):
289
+ stderr = stderr.decode(errors="replace")
290
+ raise RuntimeFailure(
291
+ f"{session}: colab exec timed out after {timeout} s; "
292
+ "the notebook kernel may be busy or not answering",
293
+ command=" ".join([*self._colab(), "exec", "-s", session]),
294
+ stderr=stderr,
295
+ ) from exc
296
+ except RuntimeFailure as exc:
297
+ if "Timeout waiting for reply" not in exc.stderr:
298
+ raise
299
+ raise RuntimeFailure(
300
+ f"{session}: colab exec did not receive a reply; "
301
+ "the notebook kernel may be busy or not answering",
302
+ command=exc.command,
303
+ stderr=exc.stderr,
304
+ ) from exc
285
305
 
286
306
  def sessions(self) -> list[str]:
287
307
  """Names of the sessions this account currently holds."""
@@ -507,5 +527,3 @@ __all__ = [
507
527
  "Colab",
508
528
  "ColabSessionReclaimed",
509
529
  ]
510
-
511
-
@@ -520,9 +520,9 @@ def cancel_run(cookie: str, run_id: int) -> None:
520
520
 
521
521
 
522
522
  def read_api_token(alias: str) -> str | None:
523
- """Read the account's verbatim API token, or None when the file is unreadable or empty."""
523
+ """Read the account's API token with file whitespace stripped, or None if unavailable."""
524
524
  try:
525
- token = (account_directory(alias) / "access_token").read_text(encoding="utf-8")
525
+ token = (account_directory(alias) / "access_token").read_text(encoding="utf-8").strip()
526
526
  except (OSError, UnicodeError):
527
527
  return None
528
528
  return token or None
@@ -544,6 +544,15 @@ def require_api_token(alias: str) -> str:
544
544
  return token
545
545
 
546
546
 
547
+ def read_notebook_owner(alias: str) -> str | None:
548
+ """Read the public notebook owner saved by login, or None if unavailable."""
549
+ try:
550
+ owner = (account_directory(alias) / "username").read_text(encoding="utf-8").strip()
551
+ except (OSError, UnicodeError):
552
+ return None
553
+ return owner or None
554
+
555
+
547
556
  def delete_notebook_via_cli(alias: str, slug: str | None) -> bool:
548
557
  """Delete the ephemeral notebook through the official CLI, the only way it is deleted.
549
558
 
@@ -558,11 +567,8 @@ def delete_notebook_via_cli(alias: str, slug: str | None) -> bool:
558
567
  token = read_api_token(alias)
559
568
  if token is None:
560
569
  return False
561
- try:
562
- username = (account_directory(alias) / "username").read_text(encoding="utf-8").strip()
563
- except (OSError, UnicodeError):
564
- return False
565
- if not username:
570
+ username = read_notebook_owner(alias)
571
+ if username is None:
566
572
  return False
567
573
  uv = tools.find_uv()
568
574
  if uv is None:
@@ -1036,6 +1042,11 @@ class Kaggle(Provider):
1036
1042
  The notebook is created fresh for this runtime alone, never reused from an earlier
1037
1043
  one: spec "Kaggle session token chain".
1038
1044
  """
1045
+ if read_notebook_owner(self.alias) is None:
1046
+ raise ConfigError(
1047
+ f"{self.alias} has no Kaggle notebook owner. Log in again with: "
1048
+ f"letify login kaggle {self.alias} --username <owner>"
1049
+ )
1039
1050
  cookie = self._require_live_cookie()
1040
1051
  instance = getattr(runtime, "instance", None)
1041
1052
  gpu = getattr(instance, "gpu", None)
@@ -265,9 +265,9 @@ def kaggle_cli_command(uv: str) -> list[str]:
265
265
 
266
266
 
267
267
  def kaggle_cli_environment(alias: str) -> dict[str, str]:
268
- """Pass this account's verbatim token to the CLI through its child environment."""
268
+ """Pass this account's token without file whitespace through the child environment."""
269
269
  env = dict(os.environ)
270
- token = (account_directory(alias) / "access_token").read_text(encoding="utf-8")
270
+ token = (account_directory(alias) / "access_token").read_text(encoding="utf-8").strip()
271
271
  env["KAGGLE_API_TOKEN"] = token
272
272
  return env
273
273
 
@@ -32,12 +32,11 @@ CACHE_RATIO = 0.5
32
32
  CACHE_TTL_SECONDS = 3600.0
33
33
 
34
34
  MIB = 1024 * 1024
35
- #: The default floor: a link under 5 MiB/s in either direction, or over 300 ms round trip,
35
+ #: The default floor: a link under 10 MiB/s in either direction, or over 300 ms round trip,
36
36
  #: is a failure rather than a connection. See docs/INTENT.md, "A slow link is a failure,
37
- #: not a fallback", and docs/NETWORK.md, "Connection pipeline measurements", for the
38
- #: measured gap this is set against: a direct LAN or campus link runs 50 to 100 MiB/s at
39
- #: under 1 ms, a relayed Tailcat path has been measured at 1 to 2 MiB/s at 69 to 99 ms.
40
- DEFAULT_MIN_MIB_PER_S = 5.0
37
+ #: not a fallback". Accounts can override the shared policy; providers cannot choose
38
+ #: a lower default.
39
+ DEFAULT_MIN_MIB_PER_S = 10.0
41
40
  DEFAULT_MAX_RTT_MS = 300.0
42
41
 
43
42
 
@@ -66,6 +65,8 @@ class LinkFloor:
66
65
  found.append(
67
66
  f"round trip {result.rtt_ms:.1f} ms above the floor {self.max_rtt_ms:.0f} ms"
68
67
  )
68
+ if found:
69
+ found.append("account overrides: min_mib_per_s and max_rtt_ms")
69
70
  return found
70
71
 
71
72
 
@@ -270,7 +271,7 @@ class Pipeline:
270
271
 
271
272
  network = self.fingerprint() if self.cache is not None else None
272
273
  if self.cache is not None and network is not None:
273
- kept = self._from_cache(applicable, network)
274
+ kept = self._from_cache(applicable, network, reasons)
274
275
  if kept is not None:
275
276
  return kept
276
277
 
@@ -292,7 +293,7 @@ class Pipeline:
292
293
 
293
294
  # -- the cache -----------------------------------------------------------
294
295
 
295
- def _from_cache(self, applicable: list[Any], network: Fingerprint) -> Any:
296
+ def _from_cache(self, applicable: list[Any], network: Fingerprint, reasons: list[str]) -> Any:
296
297
  entry = self.cache.load() if self.cache else None
297
298
  if entry is None:
298
299
  return None
@@ -336,6 +337,7 @@ class Pipeline:
336
337
  floored = self.floor.violations(measured)
337
338
  if floored:
338
339
  self._say(f"cached {name} rejected: below the floor: {', '.join(floored)}")
340
+ reasons.append(f"{name}: below the floor: {', '.join(floored)}")
339
341
  _close(link)
340
342
  return None
341
343
  short = _below(
@@ -459,12 +461,16 @@ class Pipeline:
459
461
  self, connected: list[Any], reasons: list[str], held: set[str]
460
462
  ) -> tuple[Any, ProbeResult | None]:
461
463
  """Pick among connected links. ``held`` names the strategies that cannot be probed."""
464
+ floored_out = [reason for reason in reasons if ": below the floor:" in reason]
462
465
  if len(connected) == 1:
463
466
  link = connected[0]
464
467
  try:
465
468
  measured = self._measure(link)
466
469
  except Exception:
467
470
  measured = None
471
+ if measured is None and floored_out:
472
+ _close(link)
473
+ raise ProviderUnavailable("shell", self._explain(reasons))
468
474
  if link.strategy in held:
469
475
  self._fall_back(link)
470
476
  else:
@@ -491,7 +497,6 @@ class Pipeline:
491
497
  else:
492
498
  probed.append((link, measured))
493
499
 
494
- floored_out: list[str] = []
495
500
  above_floor: list[tuple[Any, ProbeResult]] = []
496
501
  for link, measured in probed:
497
502
  floored = self.floor.violations(measured)
@@ -525,14 +530,16 @@ class Pipeline:
525
530
  self._say(f"chose {chosen.strategy}: only one connected and probed")
526
531
  else:
527
532
  self._say(f"chose {chosen.strategy}: lowest rank within 25% of the fastest")
528
- elif unprobed:
529
- chosen, measured = unprobed[0], None
530
- self._fall_back(chosen)
531
533
  elif floored_out:
534
+ for link in unprobed:
535
+ _close(link)
532
536
  self._say("every probe was below the floor")
533
537
  raise ProviderUnavailable(
534
538
  "shell", self._explain([*reasons, "every probe was below the floor", *floored_out])
535
539
  )
540
+ elif unprobed:
541
+ chosen, measured = unprobed[0], None
542
+ self._fall_back(chosen)
536
543
  else:
537
544
  self._say("every probe failed")
538
545
  raise ProviderUnavailable("shell", self._explain([*reasons, "every probe failed"]))
@@ -10,7 +10,7 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
10
10
 
11
11
  [[package]]
12
12
  name = "letify-agent"
13
- version = "1.2.0"
13
+ version = "1.2.1"
14
14
  dependencies = [
15
15
  "letify-wire",
16
16
  "libloading",
@@ -18,14 +18,14 @@ dependencies = [
18
18
 
19
19
  [[package]]
20
20
  name = "letify-driver"
21
- version = "1.2.0"
21
+ version = "1.2.1"
22
22
  dependencies = [
23
23
  "letify-wire",
24
24
  ]
25
25
 
26
26
  [[package]]
27
27
  name = "letify-wire"
28
- version = "1.2.0"
28
+ version = "1.2.1"
29
29
 
30
30
  [[package]]
31
31
  name = "libloading"
@@ -8,7 +8,7 @@ resolver = "3"
8
8
  members = ["letify-wire", "letify-driver", "letify-agent"]
9
9
 
10
10
  [workspace.package]
11
- version = "1.2.0"
11
+ version = "1.2.1"
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": "1.2.0",
3
+ "version": "1.2.1",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "letify-status",
9
- "version": "1.2.0",
9
+ "version": "1.2.1",
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": "1.2.0",
5
+ "version": "1.2.1",
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 = "1.2.0"
3
+ version = "1.2.1"
4
4
  description = "Declarations that become infrastructure."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -127,6 +127,20 @@ def test_a_login_missing_only_the_key_is_refused(isolated_home, accept_cookie, c
127
127
  assert not (account("kaggle_a") / "cookie").exists()
128
128
 
129
129
 
130
+ @pytest.mark.parametrize("owner", [None, "", " "])
131
+ def test_login_refuses_a_missing_owner_before_declaring_the_account(
132
+ isolated_home, accept_cookie, capsys, owner
133
+ ) -> None:
134
+ """Spec "Kaggle": a declared account must carry its notebook deletion owner."""
135
+ owner_args = [] if owner is None else ["--username", owner]
136
+ assert main(["login", "kaggle", "kaggle_a", "--cookie", make_cookie(),
137
+ "--key", API_TOKEN, *owner_args, "--no-input"]) == 1
138
+ assert "notebook owner" in capsys.readouterr().err
139
+ assert not account("kaggle_a").exists()
140
+ assert not (Path.home() / ".letify" / "config.toml").exists()
141
+ assert accept_cookie == []
142
+
143
+
130
144
  def test_an_expired_cookie_is_refused_with_nothing_written(
131
145
  isolated_home, accept_cookie, capsys
132
146
  ) -> None:
@@ -268,6 +282,7 @@ def test_a_run_refuses_to_start_with_under_an_hour_left_on_the_cookie(isolated_h
268
282
  import letify
269
283
  from letify.config.secrets import write_secret
270
284
 
285
+ write_secret("kaggle_a", "username", "irack000")
271
286
  soon = datetime.now(UTC) + timedelta(minutes=30)
272
287
  write_secret("kaggle_a", "cookie", make_cookie(soon.strftime("%Y-%m-%dT%H:%M:%SZ")))
273
288
  runtime = type("R", (), {"name": "letify-t4-1"})()
@@ -786,6 +801,51 @@ def test_stopping_cancels_the_run_through_the_cookie_and_deletes_the_notebook_vi
786
801
  assert "kernels" in deletes[0]
787
802
 
788
803
 
804
+ def test_stop_deletes_the_notebook_for_an_account_declared_through_login(
805
+ fake_kaggle, fake_kaggle_cli, capsys
806
+ ) -> None:
807
+ """Spec "Kaggle session token chain": login supplies the owner used by stop."""
808
+ for path in account("kaggle_a").iterdir():
809
+ path.unlink()
810
+ account("kaggle_a").rmdir()
811
+ assert main(["login", "kaggle", "kaggle_a", "--cookie", make_cookie(),
812
+ "--username", "login_owner", "--key", API_TOKEN, "--no-input"]) == 0
813
+ assert home_config()["kaggle_a"] == {"kind": "kaggle"}
814
+ assert (account("kaggle_a") / "username").read_text() == "login_owner"
815
+ assert (account("kaggle_a") / "access_token").read_text() == API_TOKEN
816
+ assert (account("kaggle_a") / "cookie").read_text() == make_cookie()
817
+
818
+ provider, runtime, _channel = session_channel(fake_kaggle)
819
+ provider.stop(runtime)
820
+
821
+ assert fake_kaggle.kernels == set()
822
+ assert fake_kaggle.cancelled == [fake_kaggle.RUN_ID]
823
+ assert len(fake_kaggle_cli.calls) == 1
824
+ assert fake_kaggle_cli.calls[0][-4:] == [
825
+ "kernels", "delete", "-y", "login_owner/letify-runtime"
826
+ ]
827
+ assert fake_kaggle_cli.environments[0]["KAGGLE_API_TOKEN"] == API_TOKEN
828
+ assert "delete it by hand" not in capsys.readouterr().err
829
+
830
+
831
+ @pytest.mark.parametrize("contents", [None, b"", b" \n", b"\xff"])
832
+ def test_a_session_without_a_readable_owner_is_refused_before_creating_a_notebook(
833
+ fake_kaggle, contents
834
+ ) -> None:
835
+ """Spec "Kaggle": an incomplete account cannot create an undeletable notebook."""
836
+ from letify.errors import ConfigError
837
+
838
+ owner_path = account("kaggle_a") / "username"
839
+ if contents is None:
840
+ owner_path.unlink()
841
+ else:
842
+ owner_path.write_bytes(contents)
843
+ with pytest.raises(ConfigError, match=r"letify login kaggle kaggle_a.*--username"):
844
+ session_channel(fake_kaggle)
845
+ assert fake_kaggle.cloud_calls == []
846
+ assert fake_kaggle.kernels == set()
847
+
848
+
789
849
  def test_a_notebook_is_warned_about_by_name_when_the_cli_delete_fails(
790
850
  fake_kaggle, fake_kaggle_cli, capsys
791
851
  ) -> None:
@@ -949,3 +1009,25 @@ def test_a_september_access_token_file_authenticates_quota_without_login(
949
1009
  assert fake_kaggle_cli.environments[0]["KAGGLE_API_TOKEN"] == API_TOKEN
950
1010
  assert all(API_TOKEN not in argument and str(token_path) not in argument
951
1011
  for argument in fake_kaggle_cli.calls[0])
1012
+
1013
+
1014
+ @pytest.mark.parametrize("contents", [API_TOKEN + "\n", " " + API_TOKEN + "\r\n", " \n"])
1015
+ def test_reading_a_stored_api_token_strips_surrounding_whitespace(isolated_home, contents) -> None:
1016
+ """Spec "Kaggle": stored token files may have trailing newlines."""
1017
+ from letify.config.secrets import write_secret
1018
+ from letify.providers.kaggle import read_api_token
1019
+
1020
+ write_secret("kaggle_a", "access_token", contents)
1021
+ assert read_api_token("kaggle_a") == (contents.strip() or None)
1022
+
1023
+
1024
+ def test_a_stored_token_with_a_newline_authenticates_cli_deletion(
1025
+ fake_kaggle_cli, kaggle_api_token
1026
+ ) -> None:
1027
+ """Spec "Kaggle": the CLI receives the token without file whitespace."""
1028
+ from letify.providers.kaggle import delete_notebook_via_cli
1029
+
1030
+ kaggle_api_token.write_text(API_TOKEN + "\n", encoding="utf-8")
1031
+ assert delete_notebook_via_cli("kaggle_a", "notebook63516d2758") is True
1032
+ assert fake_kaggle_cli.calls[0][-1] == "irack000/notebook63516d2758"
1033
+ assert fake_kaggle_cli.environments[0]["KAGGLE_API_TOKEN"] == API_TOKEN
@@ -2640,6 +2640,7 @@ def test_colab_prepares_tailcat_before_the_connection_race(
2640
2640
  ) -> None:
2641
2641
  # Spec "Colab", Runtime tools: installation failure cannot silently lose the race.
2642
2642
  from letify import install
2643
+
2643
2644
  patch_which(tools_module, present=True)
2644
2645
  found = FakeCompleted(stdout='LETIFY-TAILCAT "/remote/tailcat"\n')
2645
2646
  recorder = patch_run(colab_module, result=found)
@@ -2739,3 +2740,54 @@ def test_colab_diagnose_leaves_other_failures_unchanged_when_session_remains(
2739
2740
  diagnosed = provider.diagnose(runtime, failure)
2740
2741
  assert diagnosed is failure
2741
2742
 
2743
+
2744
+ # Spec: Transport, Link floor and Colab.
2745
+ @pytest.mark.parametrize("provider_class", [Shell, Colab, Tunnel, Elice])
2746
+ def test_shell_accounts_share_the_floor_and_keep_account_overrides(provider_class):
2747
+ from letify.transport.pipeline import MIB
2748
+
2749
+ default = provider_of(provider_class, "floor_default").link_floor
2750
+ assert default.min_bps == 10 * MIB
2751
+ assert default.max_rtt_ms == 300
2752
+ custom = provider_of(provider_class, "floor_custom", min_mib_per_s=0.05, max_rtt_ms=500)
2753
+ assert custom.link_floor.min_bps == 0.05 * MIB
2754
+ assert custom.link_floor.max_rtt_ms == 500
2755
+
2756
+
2757
+ def test_colab_exec_reply_timeout_names_the_kernel_and_keeps_cli_evidence(
2758
+ isolated_home, patch_which, patch_run
2759
+ ):
2760
+ patch_which(tools_module, present=True)
2761
+ patch_run(
2762
+ colab_module,
2763
+ result=FakeCompleted(returncode=1, stderr="TimeoutError: Timeout waiting for reply"),
2764
+ )
2765
+ provider = provider_of(Colab, "colab_a", channel="exec")
2766
+ runtime = type("R", (), {"name": "letify-a100-330e87"})()
2767
+ channel = provider.open_channel(runtime)
2768
+ with pytest.raises(letify.RuntimeFailure) as caught:
2769
+ channel.eval("1", timeout=120)
2770
+ assert "letify-a100-330e87" in str(caught.value)
2771
+ assert "notebook kernel" in str(caught.value)
2772
+ assert "busy or not answering" in str(caught.value)
2773
+ assert "colab exec" in str(caught.value)
2774
+ assert caught.value.stderr == "TimeoutError: Timeout waiting for reply"
2775
+ assert "exec -s letify-a100-330e87" in caught.value.command
2776
+
2777
+
2778
+ def test_colab_exec_command_timeout_names_its_limit_and_kernel(
2779
+ isolated_home, patch_which, patch_run
2780
+ ):
2781
+ import subprocess
2782
+
2783
+ patch_which(tools_module, present=True)
2784
+ expired = subprocess.TimeoutExpired("colab exec", 120, stderr="kernel stalled")
2785
+ patch_run(colab_module, error=expired)
2786
+ provider = provider_of(Colab, "colab_a", channel="exec")
2787
+ runtime = type("R", (), {"name": "letify-a100-330e87"})()
2788
+ with pytest.raises(letify.RuntimeFailure) as caught:
2789
+ provider.open_channel(runtime).eval("1", timeout=120)
2790
+ assert "120 s" in str(caught.value)
2791
+ assert "notebook kernel" in str(caught.value)
2792
+ assert caught.value.stderr == "kernel stalled"
2793
+ assert caught.value.__cause__ is expired
@@ -520,9 +520,9 @@ def test_a_round_trip_above_the_floor_is_refused_even_with_fast_throughput(isola
520
520
  pipeline([broken, slow_round_trip], floor=floor).connect()
521
521
 
522
522
 
523
- def test_the_default_floor_is_five_mib_per_second_and_three_hundred_ms() -> None:
523
+ def test_the_default_floor_is_ten_mib_per_second_and_three_hundred_ms() -> None:
524
524
  floor = LinkFloor.default()
525
- assert floor.min_bps == 5.0 * MIB
525
+ assert floor.min_bps == 10.0 * MIB
526
526
  assert floor.max_rtt_ms == 300.0
527
527
 
528
528
 
@@ -589,3 +589,70 @@ def test_the_fingerprint_reads_the_public_address_from_stun(stun_server: StunSer
589
589
 
590
590
  def test_a_fingerprint_with_no_stun_answer_has_no_public_address() -> None:
591
591
  assert network_fingerprint(("127.0.0.1", 9), timeout=0.5).public_ip is None
592
+
593
+
594
+ # Spec: Transport, Link floor.
595
+ @pytest.mark.parametrize("up,down,rtt", [(2.6, 0.1, 177.4), (8.8, 10.7, 175.0)])
596
+ def test_floor_rejection_cannot_select_an_unprobed_fallback(isolated_home, up, down, rtt):
597
+ slow = FakeStrategy("tailcat", 3, result=result(up, down, rtt))
598
+ fallback = FakeStrategy("fallback", 4, probed=False)
599
+ with pytest.raises(letify.ProviderUnavailable) as caught:
600
+ pipeline([slow, fallback], floor=LinkFloor.default()).connect()
601
+ message = str(caught.value)
602
+ assert "tailcat: below the floor" in message
603
+ assert f"up {up:.1f} MiB/s" in message
604
+ assert "10.0 MiB/s" in message
605
+ assert "min_mib_per_s" in message
606
+ assert "max_rtt_ms" in message
607
+ assert slow.links[0].closed
608
+ assert fallback.links[0].closed
609
+
610
+
611
+ @pytest.mark.parametrize("race_fails", [False, True])
612
+ def test_a_cached_floor_rejection_cannot_escape_to_an_unprobed_fallback(
613
+ isolated_home, monkeypatch, race_fails
614
+ ):
615
+ cache = LinkCache("lab")
616
+ cache.save("tailcat", result(20, 20), Fingerprint("203.0.113.7", "eth0"))
617
+ slow = FakeStrategy("tailcat", 3, result=result(2.6, 0.1, 177.4))
618
+ fallback = FakeStrategy("fallback", 4, probed=False)
619
+ attempt = slow.attempt
620
+
621
+ def reconnect(target, cancel=None):
622
+ if race_fails and slow.attempts:
623
+ raise OSError("connection lost after cached probe")
624
+ return attempt(target, cancel=cancel)
625
+
626
+ monkeypatch.setattr(slow, "attempt", reconnect)
627
+ with pytest.raises(letify.ProviderUnavailable, match="tailcat: below the floor"):
628
+ pipeline([slow, fallback], cache=cache, floor=LinkFloor.default()).connect()
629
+ assert all(link.closed for link in slow.links + fallback.links)
630
+ assert cache.load().probe == result(20, 20)
631
+
632
+
633
+ def test_floor_rejections_name_both_account_overrides():
634
+ violations = LinkFloor.default().violations(result(2.6, 0.1, 500))
635
+ assert any("up 2.6 MiB/s" in text for text in violations)
636
+ assert any("down 0.1 MiB/s" in text for text in violations)
637
+ assert any("round trip 500.0 ms" in text for text in violations)
638
+ assert "min_mib_per_s" in ", ".join(violations)
639
+ assert "max_rtt_ms" in ", ".join(violations)
640
+
641
+
642
+ # Spec: Transport, Link floor.
643
+ def test_a_cached_floor_rejection_cannot_accept_a_lone_failed_probe(isolated_home, monkeypatch):
644
+ cache = LinkCache("lab")
645
+ cache.save("tailcat", result(20, 20), Fingerprint("203.0.113.7", "eth0"))
646
+ slow = FakeStrategy("tailcat", 3, result=result(2.6, 0.1, 177.4))
647
+ fallback = FakeStrategy("fallback", 4, probed=False, error="fallback unavailable")
648
+ attempt = slow.attempt
649
+
650
+ def reconnect(target, cancel=None):
651
+ if slow.attempts:
652
+ slow.probe_error = True
653
+ return attempt(target, cancel=cancel)
654
+
655
+ monkeypatch.setattr(slow, "attempt", reconnect)
656
+ with pytest.raises(letify.ProviderUnavailable, match="tailcat: below the floor"):
657
+ pipeline([slow, fallback], cache=cache, floor=LinkFloor.default()).connect()
658
+ assert all(link.closed for link in slow.links)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes