letify 2.10.0__tar.gz → 2.10.2__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 (213) hide show
  1. {letify-2.10.0 → letify-2.10.2}/PKG-INFO +1 -1
  2. {letify-2.10.0 → letify-2.10.2}/docs/SPEC.md +7 -2
  3. {letify-2.10.0 → letify-2.10.2}/letify/__init__.py +1 -1
  4. {letify-2.10.0 → letify-2.10.2}/letify/protocol/worker.py +12 -9
  5. {letify-2.10.0 → letify-2.10.2}/letify/remoting/device/client.py +29 -4
  6. {letify-2.10.0 → letify-2.10.2}/letify/remoting/device/executor.py +31 -11
  7. {letify-2.10.0 → letify-2.10.2}/letify/store/pathdata.py +15 -2
  8. {letify-2.10.0 → letify-2.10.2}/letify/transport/link.py +9 -0
  9. {letify-2.10.0 → letify-2.10.2}/letify-core/Cargo.lock +2 -2
  10. {letify-2.10.0 → letify-2.10.2}/letify-core/Cargo.toml +1 -1
  11. {letify-2.10.0 → letify-2.10.2}/letify-ext/package-lock.json +2 -2
  12. {letify-2.10.0 → letify-2.10.2}/letify-ext/package.json +1 -1
  13. {letify-2.10.0 → letify-2.10.2}/pyproject.toml +1 -1
  14. {letify-2.10.0 → letify-2.10.2}/tests/test_device.py +111 -0
  15. {letify-2.10.0 → letify-2.10.2}/tests/test_parallel_placement.py +79 -0
  16. {letify-2.10.0 → letify-2.10.2}/tests/test_run_takeover.py +110 -0
  17. {letify-2.10.0 → letify-2.10.2}/.github/workflows/ci.yml +0 -0
  18. {letify-2.10.0 → letify-2.10.2}/.github/workflows/docs.yml +0 -0
  19. {letify-2.10.0 → letify-2.10.2}/.github/workflows/publish-crates.yml +0 -0
  20. {letify-2.10.0 → letify-2.10.2}/.github/workflows/publish.yml +0 -0
  21. {letify-2.10.0 → letify-2.10.2}/.github/workflows/reserve-crates.yml +0 -0
  22. {letify-2.10.0 → letify-2.10.2}/.gitignore +0 -0
  23. {letify-2.10.0 → letify-2.10.2}/.researchtree +0 -0
  24. {letify-2.10.0 → letify-2.10.2}/CLAUDE.md +0 -0
  25. {letify-2.10.0 → letify-2.10.2}/LICENSE +0 -0
  26. {letify-2.10.0 → letify-2.10.2}/PROJECT.md +0 -0
  27. {letify-2.10.0 → letify-2.10.2}/README.md +0 -0
  28. {letify-2.10.0 → letify-2.10.2}/docs/COMPONENT.md +0 -0
  29. {letify-2.10.0 → letify-2.10.2}/docs/INTENT.md +0 -0
  30. {letify-2.10.0 → letify-2.10.2}/docs/NETWORK.md +0 -0
  31. {letify-2.10.0 → letify-2.10.2}/docs/guide/01-getting-started.md +0 -0
  32. {letify-2.10.0 → letify-2.10.2}/docs/guide/02-providers.md +0 -0
  33. {letify-2.10.0 → letify-2.10.2}/docs/guide/03-execution-modes.md +0 -0
  34. {letify-2.10.0 → letify-2.10.2}/docs/guide/04-environments-and-data.md +0 -0
  35. {letify-2.10.0 → letify-2.10.2}/docs/guide/05-concurrency.md +0 -0
  36. {letify-2.10.0 → letify-2.10.2}/docs/guide/06-cost.md +0 -0
  37. {letify-2.10.0 → letify-2.10.2}/docs/guide/07-troubleshooting.md +0 -0
  38. {letify-2.10.0 → letify-2.10.2}/docs/guide/08-releasing.md +0 -0
  39. {letify-2.10.0 → letify-2.10.2}/docs/guide/README.md +0 -0
  40. {letify-2.10.0 → letify-2.10.2}/docs/guide/ko/01-/354/213/234/354/236/221/355/225/230/352/270/260.md" +0 -0
  41. {letify-2.10.0 → letify-2.10.2}/docs/guide/ko/02-/355/224/204/353/241/234/353/260/224/354/235/264/353/215/224.md" +0 -0
  42. {letify-2.10.0 → letify-2.10.2}/docs/guide/ko/03-/354/213/244/355/226/211-/353/260/251/354/213/235.md" +0 -0
  43. {letify-2.10.0 → letify-2.10.2}/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
  44. {letify-2.10.0 → letify-2.10.2}/docs/guide/ko/05-/353/217/231/354/213/234-/354/213/244/355/226/211.md" +0 -0
  45. {letify-2.10.0 → letify-2.10.2}/docs/guide/ko/06-/353/271/204/354/232/251.md" +0 -0
  46. {letify-2.10.0 → letify-2.10.2}/docs/guide/ko/07-/353/254/270/354/240/234-/355/225/264/352/262/260.md" +0 -0
  47. {letify-2.10.0 → letify-2.10.2}/docs/guide/ko/08-/353/246/264/353/246/254/354/212/244.md" +0 -0
  48. {letify-2.10.0 → letify-2.10.2}/docs/guide/ko/README.md +0 -0
  49. {letify-2.10.0 → letify-2.10.2}/docs/locales/README_ko.md +0 -0
  50. {letify-2.10.0 → letify-2.10.2}/docs/mkdocs.yml +0 -0
  51. {letify-2.10.0 → letify-2.10.2}/examples/README.md +0 -0
  52. {letify-2.10.0 → letify-2.10.2}/examples/nvfp4-lora/.letify.example +0 -0
  53. {letify-2.10.0 → letify-2.10.2}/examples/nvfp4-lora/00_smoke.py +0 -0
  54. {letify-2.10.0 → letify-2.10.2}/examples/nvfp4-lora/01_sweep.py +0 -0
  55. {letify-2.10.0 → letify-2.10.2}/examples/nvfp4-lora/02_resume.py +0 -0
  56. {letify-2.10.0 → letify-2.10.2}/examples/nvfp4-lora/03_watch.py +0 -0
  57. {letify-2.10.0 → letify-2.10.2}/examples/nvfp4-lora/README.md +0 -0
  58. {letify-2.10.0 → letify-2.10.2}/examples/nvfp4-lora/README_ko.md +0 -0
  59. {letify-2.10.0 → letify-2.10.2}/examples/nvfp4-lora/common.py +0 -0
  60. {letify-2.10.0 → letify-2.10.2}/examples/nvfp4-lora/recipe.py +0 -0
  61. {letify-2.10.0 → letify-2.10.2}/letify/cli.py +0 -0
  62. {letify-2.10.0 → letify-2.10.2}/letify/config/__init__.py +0 -0
  63. {letify-2.10.0 → letify-2.10.2}/letify/config/inventory.py +0 -0
  64. {letify-2.10.0 → letify-2.10.2}/letify/config/login.py +0 -0
  65. {letify-2.10.0 → letify-2.10.2}/letify/config/schema.py +0 -0
  66. {letify-2.10.0 → letify-2.10.2}/letify/config/secrets.py +0 -0
  67. {letify-2.10.0 → letify-2.10.2}/letify/config/writer.py +0 -0
  68. {letify-2.10.0 → letify-2.10.2}/letify/declare/__init__.py +0 -0
  69. {letify-2.10.0 → letify-2.10.2}/letify/declare/cache.py +0 -0
  70. {letify-2.10.0 → letify-2.10.2}/letify/declare/env.py +0 -0
  71. {letify-2.10.0 → letify-2.10.2}/letify/declare/exit.py +0 -0
  72. {letify-2.10.0 → letify-2.10.2}/letify/declare/function.py +0 -0
  73. {letify-2.10.0 → letify-2.10.2}/letify/declare/instance.py +0 -0
  74. {letify-2.10.0 → letify-2.10.2}/letify/declare/reduce.py +0 -0
  75. {letify-2.10.0 → letify-2.10.2}/letify/declare/replicas.py +0 -0
  76. {letify-2.10.0 → letify-2.10.2}/letify/errors.py +0 -0
  77. {letify-2.10.0 → letify-2.10.2}/letify/install.py +0 -0
  78. {letify-2.10.0 → letify-2.10.2}/letify/launcher.py +0 -0
  79. {letify-2.10.0 → letify-2.10.2}/letify/paths.py +0 -0
  80. {letify-2.10.0 → letify-2.10.2}/letify/protocol/__init__.py +0 -0
  81. {letify-2.10.0 → letify-2.10.2}/letify/protocol/codec.py +0 -0
  82. {letify-2.10.0 → letify-2.10.2}/letify/protocol/driver.py +0 -0
  83. {letify-2.10.0 → letify-2.10.2}/letify/protocol/handle.py +0 -0
  84. {letify-2.10.0 → letify-2.10.2}/letify/protocol/wire.py +0 -0
  85. {letify-2.10.0 → letify-2.10.2}/letify/providers/__init__.py +0 -0
  86. {letify-2.10.0 → letify-2.10.2}/letify/providers/base.py +0 -0
  87. {letify-2.10.0 → letify-2.10.2}/letify/providers/colab.py +0 -0
  88. {letify-2.10.0 → letify-2.10.2}/letify/providers/colab_files.py +0 -0
  89. {letify-2.10.0 → letify-2.10.2}/letify/providers/colab_keepalive.py +0 -0
  90. {letify-2.10.0 → letify-2.10.2}/letify/providers/elice.py +0 -0
  91. {letify-2.10.0 → letify-2.10.2}/letify/providers/kaggle.py +0 -0
  92. {letify-2.10.0 → letify-2.10.2}/letify/providers/kaggle_adapter.py +0 -0
  93. {letify-2.10.0 → letify-2.10.2}/letify/providers/local.py +0 -0
  94. {letify-2.10.0 → letify-2.10.2}/letify/providers/modal.py +0 -0
  95. {letify-2.10.0 → letify-2.10.2}/letify/providers/modal_adapter.py +0 -0
  96. {letify-2.10.0 → letify-2.10.2}/letify/providers/naming.py +0 -0
  97. {letify-2.10.0 → letify-2.10.2}/letify/providers/shell.py +0 -0
  98. {letify-2.10.0 → letify-2.10.2}/letify/providers/tunnel.py +0 -0
  99. {letify-2.10.0 → letify-2.10.2}/letify/providers/usage.py +0 -0
  100. {letify-2.10.0 → letify-2.10.2}/letify/remoting/__init__.py +0 -0
  101. {letify-2.10.0 → letify-2.10.2}/letify/remoting/device/__init__.py +0 -0
  102. {letify-2.10.0 → letify-2.10.2}/letify/remoting/device/cuda.py +0 -0
  103. {letify-2.10.0 → letify-2.10.2}/letify/remoting/device/frames.py +0 -0
  104. {letify-2.10.0 → letify-2.10.2}/letify/remoting/device/guard.py +0 -0
  105. {letify-2.10.0 → letify-2.10.2}/letify/remoting/device/tensor.py +0 -0
  106. {letify-2.10.0 → letify-2.10.2}/letify/remoting/device/trace.py +0 -0
  107. {letify-2.10.0 → letify-2.10.2}/letify/remoting/device/value.py +0 -0
  108. {letify-2.10.0 → letify-2.10.2}/letify/render.py +0 -0
  109. {letify-2.10.0 → letify-2.10.2}/letify/runtime/__init__.py +0 -0
  110. {letify-2.10.0 → letify-2.10.2}/letify/runtime/bootstrap.py +0 -0
  111. {letify-2.10.0 → letify-2.10.2}/letify/runtime/broker.py +0 -0
  112. {letify-2.10.0 → letify-2.10.2}/letify/runtime/channel.py +0 -0
  113. {letify-2.10.0 → letify-2.10.2}/letify/runtime/control.py +0 -0
  114. {letify-2.10.0 → letify-2.10.2}/letify/runtime/lease.py +0 -0
  115. {letify-2.10.0 → letify-2.10.2}/letify/runtime/pool.py +0 -0
  116. {letify-2.10.0 → letify-2.10.2}/letify/runtime/session.py +0 -0
  117. {letify-2.10.0 → letify-2.10.2}/letify/runtime/takeover.py +0 -0
  118. {letify-2.10.0 → letify-2.10.2}/letify/runtime/telemetry.py +0 -0
  119. {letify-2.10.0 → letify-2.10.2}/letify/runtime/workers.py +0 -0
  120. {letify-2.10.0 → letify-2.10.2}/letify/store/__init__.py +0 -0
  121. {letify-2.10.0 → letify-2.10.2}/letify/store/backends/__init__.py +0 -0
  122. {letify-2.10.0 → letify-2.10.2}/letify/store/backends/drive.py +0 -0
  123. {letify-2.10.0 → letify-2.10.2}/letify/store/backends/filesystem.py +0 -0
  124. {letify-2.10.0 → letify-2.10.2}/letify/store/backends/google_auth.py +0 -0
  125. {letify-2.10.0 → letify-2.10.2}/letify/store/backends/layout.py +0 -0
  126. {letify-2.10.0 → letify-2.10.2}/letify/store/backends/objects.py +0 -0
  127. {letify-2.10.0 → letify-2.10.2}/letify/store/cas.py +0 -0
  128. {letify-2.10.0 → letify-2.10.2}/letify/store/handoff.py +0 -0
  129. {letify-2.10.0 → letify-2.10.2}/letify/store/sendorder.py +0 -0
  130. {letify-2.10.0 → letify-2.10.2}/letify/store/volume.py +0 -0
  131. {letify-2.10.0 → letify-2.10.2}/letify/stubs.py +0 -0
  132. {letify-2.10.0 → letify-2.10.2}/letify/tools.py +0 -0
  133. {letify-2.10.0 → letify-2.10.2}/letify/transport/__init__.py +0 -0
  134. {letify-2.10.0 → letify-2.10.2}/letify/transport/agent.py +0 -0
  135. {letify-2.10.0 → letify-2.10.2}/letify/transport/announce.py +0 -0
  136. {letify-2.10.0 → letify-2.10.2}/letify/transport/nat.py +0 -0
  137. {letify-2.10.0 → letify-2.10.2}/letify/transport/pipeline.py +0 -0
  138. {letify-2.10.0 → letify-2.10.2}/letify/transport/portmap.py +0 -0
  139. {letify-2.10.0 → letify-2.10.2}/letify/transport/probe.py +0 -0
  140. {letify-2.10.0 → letify-2.10.2}/letify/transport/quic.py +0 -0
  141. {letify-2.10.0 → letify-2.10.2}/letify/transport/rendezvous.py +0 -0
  142. {letify-2.10.0 → letify-2.10.2}/letify/transport/setup.py +0 -0
  143. {letify-2.10.0 → letify-2.10.2}/letify/transport/sshopts.py +0 -0
  144. {letify-2.10.0 → letify-2.10.2}/letify/transport/strategies.py +0 -0
  145. {letify-2.10.0 → letify-2.10.2}/letify-core/.gitignore +0 -0
  146. {letify-2.10.0 → letify-2.10.2}/letify-core/build.py +0 -0
  147. {letify-2.10.0 → letify-2.10.2}/letify-core/letify/Cargo.toml +0 -0
  148. {letify-2.10.0 → letify-2.10.2}/letify-core/letify/LICENSE +0 -0
  149. {letify-2.10.0 → letify-2.10.2}/letify-core/letify/README.md +0 -0
  150. {letify-2.10.0 → letify-2.10.2}/letify-core/letify/src/lib.rs +0 -0
  151. {letify-2.10.0 → letify-2.10.2}/letify-core/letify-quic/Cargo.toml +0 -0
  152. {letify-2.10.0 → letify-2.10.2}/letify-core/letify-quic/src/main.rs +0 -0
  153. {letify-2.10.0 → letify-2.10.2}/letify-core/rust-toolchain.toml +0 -0
  154. {letify-2.10.0 → letify-2.10.2}/letify-ext/.vscodeignore +0 -0
  155. {letify-2.10.0 → letify-2.10.2}/letify-ext/LICENSE +0 -0
  156. {letify-2.10.0 → letify-2.10.2}/letify-ext/README.md +0 -0
  157. {letify-2.10.0 → letify-2.10.2}/letify-ext/media/icon.svg +0 -0
  158. {letify-2.10.0 → letify-2.10.2}/letify-ext/src/cli.ts +0 -0
  159. {letify-2.10.0 → letify-2.10.2}/letify-ext/src/extension.ts +0 -0
  160. {letify-2.10.0 → letify-2.10.2}/letify-ext/src/history.ts +0 -0
  161. {letify-2.10.0 → letify-2.10.2}/letify-ext/src/model.ts +0 -0
  162. {letify-2.10.0 → letify-2.10.2}/letify-ext/src/poll.ts +0 -0
  163. {letify-2.10.0 → letify-2.10.2}/letify-ext/src/render.ts +0 -0
  164. {letify-2.10.0 → letify-2.10.2}/letify-ext/test/cli.test.ts +0 -0
  165. {letify-2.10.0 → letify-2.10.2}/letify-ext/test/manifest.test.ts +0 -0
  166. {letify-2.10.0 → letify-2.10.2}/letify-ext/test/model.test.ts +0 -0
  167. {letify-2.10.0 → letify-2.10.2}/letify-ext/test/regressions.test.ts +0 -0
  168. {letify-2.10.0 → letify-2.10.2}/letify-ext/tsconfig.json +0 -0
  169. {letify-2.10.0 → letify-2.10.2}/letify_providers.py +0 -0
  170. {letify-2.10.0 → letify-2.10.2}/scripts/sync-main.sh +0 -0
  171. {letify-2.10.0 → letify-2.10.2}/scripts/version.py +0 -0
  172. {letify-2.10.0 → letify-2.10.2}/tests/conftest.py +0 -0
  173. {letify-2.10.0 → letify-2.10.2}/tests/fake_kaggle_adapter.py +0 -0
  174. {letify-2.10.0 → letify-2.10.2}/tests/fake_modal_adapter.py +0 -0
  175. {letify-2.10.0 → letify-2.10.2}/tests/test_cli.py +0 -0
  176. {letify-2.10.0 → letify-2.10.2}/tests/test_cli_output.py +0 -0
  177. {letify-2.10.0 → letify-2.10.2}/tests/test_colab_keepalive.py +0 -0
  178. {letify-2.10.0 → letify-2.10.2}/tests/test_config.py +0 -0
  179. {letify-2.10.0 → letify-2.10.2}/tests/test_core.py +0 -0
  180. {letify-2.10.0 → letify-2.10.2}/tests/test_data.py +0 -0
  181. {letify-2.10.0 → letify-2.10.2}/tests/test_data_streaming.py +0 -0
  182. {letify-2.10.0 → letify-2.10.2}/tests/test_declare.py +0 -0
  183. {letify-2.10.0 → letify-2.10.2}/tests/test_device_compile.py +0 -0
  184. {letify-2.10.0 → letify-2.10.2}/tests/test_distributed.py +0 -0
  185. {letify-2.10.0 → letify-2.10.2}/tests/test_drive_handoff.py +0 -0
  186. {letify-2.10.0 → letify-2.10.2}/tests/test_drive_store.py +0 -0
  187. {letify-2.10.0 → letify-2.10.2}/tests/test_editor_sessions.py +0 -0
  188. {letify-2.10.0 → letify-2.10.2}/tests/test_elice.py +0 -0
  189. {letify-2.10.0 → letify-2.10.2}/tests/test_frames.py +0 -0
  190. {letify-2.10.0 → letify-2.10.2}/tests/test_install.py +0 -0
  191. {letify-2.10.0 → letify-2.10.2}/tests/test_kaggle.py +0 -0
  192. {letify-2.10.0 → letify-2.10.2}/tests/test_kaggle_cookie.py +0 -0
  193. {letify-2.10.0 → letify-2.10.2}/tests/test_kaggle_link.py +0 -0
  194. {letify-2.10.0 → letify-2.10.2}/tests/test_link_log.py +0 -0
  195. {letify-2.10.0 → letify-2.10.2}/tests/test_notebook.py +0 -0
  196. {letify-2.10.0 → letify-2.10.2}/tests/test_paths.py +0 -0
  197. {letify-2.10.0 → letify-2.10.2}/tests/test_portmap.py +0 -0
  198. {letify-2.10.0 → letify-2.10.2}/tests/test_protocol.py +0 -0
  199. {letify-2.10.0 → letify-2.10.2}/tests/test_providers.py +0 -0
  200. {letify-2.10.0 → letify-2.10.2}/tests/test_punch_sockets.py +0 -0
  201. {letify-2.10.0 → letify-2.10.2}/tests/test_quic.py +0 -0
  202. {letify-2.10.0 → letify-2.10.2}/tests/test_remoting.py +0 -0
  203. {letify-2.10.0 → letify-2.10.2}/tests/test_render.py +0 -0
  204. {letify-2.10.0 → letify-2.10.2}/tests/test_run_control.py +0 -0
  205. {letify-2.10.0 → letify-2.10.2}/tests/test_runtime.py +0 -0
  206. {letify-2.10.0 → letify-2.10.2}/tests/test_shell_workers.py +0 -0
  207. {letify-2.10.0 → letify-2.10.2}/tests/test_ship.py +0 -0
  208. {letify-2.10.0 → letify-2.10.2}/tests/test_store.py +0 -0
  209. {letify-2.10.0 → letify-2.10.2}/tests/test_strategies.py +0 -0
  210. {letify-2.10.0 → letify-2.10.2}/tests/test_stubs.py +0 -0
  211. {letify-2.10.0 → letify-2.10.2}/tests/test_transport.py +0 -0
  212. {letify-2.10.0 → letify-2.10.2}/tests/test_tunnel_setup.py +0 -0
  213. {letify-2.10.0 → letify-2.10.2}/tests/test_version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: letify
3
- Version: 2.10.0
3
+ Version: 2.10.2
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
@@ -1089,7 +1089,7 @@ A call has exactly one owner at a time, and the session still ends with the call
1089
1089
 
1090
1090
  1. **The registry keeps an orphan.** The record also holds `project`, the project root the call was sent from, and `lease_deadline`, the Unix time at which the runtime's lease ends unless renewed, rewritten at each renewal. It also holds what a second process needs to reach the call and finish it: `door`, the path of the worker's socket below; `config`, the project `config.toml` that names the account; and `call_dir`, `blobs` and `outputs`, the runtime paths and the output locations to write back, each location stored relative to `project` when it lies inside it. No secret is among them. A record whose owner pid is gone is no longer removed when read while its `lease_deadline` is in the future: `letify runs` lists it with `owner` `gone` and the seconds left, and it can be taken over. After the deadline it is removed as stale.
1091
1091
  2. **The worker keeps a second door.** While a call runs, the worker listens on a Unix socket on the runtime, `<workspace root>/.letify/run-<run id>.sock`, mode 0600. When that path is longer than 100 bytes, the limit of a Unix socket address, the worker and the relay reach the socket through `/proc/self/fd/<n>`, a descriptor of its directory, so it stays at that path; where `/proc` is missing the worker reports that the run has no second door. A channel that closes while a call runs, with no shutdown frame before it, does not end the worker: the body keeps running until the lease deadline passes. A shutdown frame is different: it is the owner ending the run on purpose, as [A deliberate shutdown](#run-shutdown) describes. Output the body makes while no channel is open is kept, the last 256 KiB per stream, and is sent to the next owner first. A body that returns while no channel is open keeps its result for a new owner until the lease deadline; on a provider with no lease the worker ends then, as before.
1092
- 3. **The new owner connects.** `letify takeover <run>` reads the record, opens a channel to the same runtime through the record's provider and runtime name, and starts a relay to that socket instead of a new worker. It then sends `adopt` with the run id. The worker answers once with the call's state, which is `running` or `finished`, and from then on sends the call's output and its reply on the new channel, the reply as the last piece of the same answer. The old channel, if still open, is told `moved` on the call's stream and answers every later request on it with `moved`. A door takes one connection at a time and no request but `adopt` before it, and a second owner can adopt after the first one's connection has closed.
1092
+ 3. **The new owner connects.** `letify takeover <run>` reads the record, opens a channel to the same runtime through the record's provider and runtime name, and starts a relay to that socket instead of a new worker. It then sends `adopt` with the run id. The worker answers once with the call's state, which is `running` or `finished`, and from then on sends the call's output and its reply on the new channel, the reply as the last piece of the same answer. The old channel, if still open, is told `moved` on the call's stream, after the worker has switched the call's output to the new channel, so everything the body prints once the old owner knows of the move reaches the new owner. It answers every later request on it with `moved`. A door takes one connection at a time and no request but `adopt` before it, and a second owner can adopt after the first one's connection has closed. The adoption is atomic with respect to the old channel: if the old channel closes while the adoption is under way, the worker decides what that close means only after the adoption has finished, sees that the call has moved, and does nothing. The new owner's channel is then never marked as having lost its input, and no exit hook runs for the close.
1093
1093
  4. **The old owner lets go.** When the old owner is alive, takeover first asks it through its control socket to release the run. Its waiting call raises `OwnershipMoved` naming the run and the new owner's pid, it stops renewing that runtime's lease, and it drops the runtime from its pool without stopping it. A `keep_alive` block in the old owner no longer holds that session. An old owner that does not answer within 5 s is treated as gone.
1094
1094
  5. **The call ends at the new owner.** `letify takeover` prints the body's output as it arrives. When the call returns it prints the `repr` of the value, or with `--result PATH` writes the value with pickle to `PATH`, and exits 0. When the body raises it prints the traceback and exits 1. With `--json` the last line is one object with `run`, `ok`, `value_repr` or `error`, and `result_path`. The call's outputs are written back under the record's `project`, not the current directory. The session is then ended.
1095
1095
  6. **The same from Python.** `letify.takeover(run)` does the same in the calling process and returns the value or raises the body's exception, as a call does. It holds no handle: it returns once the call has ended and the session is gone. A darkpyonix kernel uses this to adopt a run started elsewhere.
@@ -1333,7 +1333,7 @@ connection, but a call's second large blob is split whenever splitting is faster
1333
1333
 
1334
1334
  A connection that costs something to open has to pay for itself, so a provider reports `transfer_connection_cost_s`, the seconds one more connection takes. It is 0 for a provider that dials an address, and about 10 for one reached by a hole punch, because that is what a punch measures. The streams are opened only when the transfer is expected to finish sooner with them: the estimate uses the probe's upload rate, assumes the streams scale and that their connections are opened at the same time, and keeps one stream when `size / (rate x streams) + cost` is not less than `size / rate`. A measured case: a Kaggle runtime at a 44.7 ms round trip carried 128 MiB at 21.0 MiB/s on one stream and 9.5 MiB/s on four, 2.2 times worse, because three more punches cost more than the transfer saved. The same account at a 193 ms round trip carried 1.57 MiB/s on one raw SSH connection and 6.10 MiB/s on four, so the gain is real where the link is slow enough and the blob large enough for it to be worth 10 s.
1335
1335
 
1336
- The connections have to be real ones. Every SSH command letify builds carries `ControlMaster=auto`, which multiplexes further sessions onto the first connection, so opening more channels that way would add streams inside one TCP connection and gain nothing. A transfer channel therefore sets `ControlPath=none` and `ControlMaster=no`, and a punched link gives each its own socket, so each transfer channel is its own kernel connection. A punched link holds the spare sockets of its punch, `PUNCH_SOCKETS` of them in all, [Rendezvous](#rendezvous), and hands one to each SSH command before it redials. The worker channel and the first transfer channels therefore cost no rendezvous, and `transfer_connection_cost_s` applies only to the streams beyond the spares.
1336
+ The connections have to be real ones. Every SSH command letify builds carries `ControlMaster=auto`, which multiplexes further sessions onto the first connection, so opening more channels that way would add streams inside one TCP connection and gain nothing. A transfer channel therefore sets `ControlPath=none` and `ControlMaster=no`, and a punched link gives each its own socket, so each transfer channel is its own kernel connection. A punched link holds the spare sockets of its punch, `PUNCH_SOCKETS` of them in all, [Rendezvous](#rendezvous), and hands one to each SSH command before it redials. The worker channel and the first transfer channels therefore cost no rendezvous, and `transfer_connection_cost_s` applies only to the streams beyond the spares: with `k` spares still unused and `s` streams wanted, `s - 1 - k` punches are needed, and when that is above 0 the estimate adds the cost once (the punches run at the same time), otherwise it adds 0. The link reports `k` as `spare_connections()`, which is 0 for every link but a punched one.
1337
1337
 
1338
1338
  The worker's `data_put` appends to one handle per digest and hashes as it goes, which assumes the pieces arrive in order on one stream. Parallel senders cannot use it, so they use two other requests:
1339
1339
 
@@ -2683,6 +2683,11 @@ The worker records the first failure and skips the operators after it until a sy
2683
2683
 
2684
2684
  A lost worker process or link raises `RuntimeLost`, and the session is discarded.
2685
2685
 
2686
+ An executor that ends because of an exception sends one last message, a failure naming the exception, before it stops, so the client stops waiting at once instead of waiting for a reply that cannot come. This holds at startup and in the middle of a session, on the call worker's channel and as a process of its own. The client discards the executor and raises:
2687
+
2688
+ - `ConfigError` when the executor could not start because a module is missing from the runtime's environment, such as `torch`. The message names the exception, for example `ModuleNotFoundError: No module named 'torch'`, and for `torch` says to add it to the project's dependencies. It is not retried, because a fresh runtime builds the same environment from the same lock.
2689
+ - `RuntimeLost`, carrying the executor's exception and traceback, for any other exception.
2690
+
2686
2691
  ### Version guards <!-- id: forwarding-versions -->
2687
2692
 
2688
2693
  > PyTorch 2.1 or newer is required locally and on the runtime.
@@ -70,7 +70,7 @@ distributed: Final = Host.distributed
70
70
  # The enum class stays importable from letify.declare.instance for letify's own use.
71
71
  del Host
72
72
 
73
- __version__ = "2.10.0"
73
+ __version__ = "2.10.2"
74
74
 
75
75
 
76
76
  def fetch(tensor): # type: ignore[no-untyped-def]
@@ -3549,11 +3549,12 @@ def _reader_ended(door, shutdown=False):
3549
3549
  Spec "A deliberate shutdown": a shutdown frame on the channel that owns a running call
3550
3550
  ends the body and the worker. End of input alone leaves the body for a takeover.
3551
3551
  """
3552
- if door is None and _TAKE["moved"]:
3553
- # The call was adopted away; this channel closing is the old owner leaving.
3554
- return
3555
3552
  during = False
3556
3553
  with _TAKE_COND:
3554
+ if door is None and _TAKE["moved"]:
3555
+ # The call was adopted away; this channel closing is the old owner leaving. Read
3556
+ # under the lock, because an adoption in progress holds it until it has moved.
3557
+ return
3557
3558
  if door is None or (_TAKE["owner"] is door and _TAKE["live"]):
3558
3559
  during = _TAKE["run"] is not None
3559
3560
  if door is not None or during:
@@ -3702,12 +3703,6 @@ def _door_adopt(door, stream, request):
3702
3703
  moved = _moved_outcome()
3703
3704
  if _TAKE["owner"] is not None:
3704
3705
  moved["last"] = True
3705
- # Told on a thread, because a former owner that has stopped reading can block the write.
3706
- told = threading.Thread(
3707
- target=_answer, args=(former, former_stream, moved), daemon=True
3708
- )
3709
- told.start()
3710
- told.join(2.0)
3711
3706
  state = {
3712
3707
  "run": run,
3713
3708
  "state": "finished" if _TAKE["finished"] else "running",
@@ -3728,6 +3723,14 @@ def _door_adopt(door, stream, request):
3728
3723
  )
3729
3724
  _SENDER = door.sender
3730
3725
  _TAKE_COND.notify_all()
3726
+ # Told last, after the output has been switched, so that nothing the body prints from
3727
+ # the moment the former owner learns of the move is sent to a channel that has stopped
3728
+ # reading. A thread, because a former owner that has stopped reading can block the write.
3729
+ told = threading.Thread(
3730
+ target=_answer, args=(former, former_stream, moved), daemon=True
3731
+ )
3732
+ told.start()
3733
+ told.join(2.0)
3731
3734
 
3732
3735
 
3733
3736
  def _deliver_call(stream, outcome):
@@ -29,7 +29,7 @@ from typing import Any
29
29
  import torch
30
30
  from blake3 import blake3
31
31
 
32
- from ...errors import RemoteError, RuntimeLost
32
+ from ...errors import ConfigError, RemoteError, RuntimeLost
33
33
  from .executor import E_DEFINE, E_OP, E_REQUEST, E_STEP, E_STEP_DEFINE, UPLOAD_CACHE_BYTES
34
34
  from .frames import ChannelTransport, StreamTransport, Transport, TransportClosed
35
35
  from .guard import check_torch_version, check_worker_version
@@ -279,6 +279,7 @@ class Client:
279
279
  """Read the executor's first message, check its PyTorch, and start the threads."""
280
280
  head, _buffers = self._recv()
281
281
  self.hello = pickle.loads(head)
282
+ self._raise_fatal(self.hello)
282
283
  check_worker_version(local=torch.__version__, remote=str(self.hello["torch"]))
283
284
  self._sender = threading.Thread(
284
285
  target=self._send_loop, name=f"{self.name}-send", daemon=True
@@ -811,6 +812,7 @@ class Client:
811
812
  head, buffers = self._recv()
812
813
  consumers = self._replies.popleft()
813
814
  reply = pickle.loads(head)
815
+ self._raise_fatal(reply)
814
816
  results = reply["results"]
815
817
  failure = reply.get("failure")
816
818
  for note in reply.get("notes", ()):
@@ -1016,6 +1018,25 @@ class Client:
1016
1018
  detail = f"\n--- device worker stderr ---\n{tail}" if tail else ""
1017
1019
  return RuntimeLost(f"{self.name}: the device worker was lost: {reason}{detail}")
1018
1020
 
1021
+ def _raise_fatal(self, message: dict) -> None:
1022
+ """Raise the failure an executor sent as its last message, discarding the executor."""
1023
+ fatal = message.get("fatal")
1024
+ if fatal is None:
1025
+ return
1026
+ error = fatal["error"]
1027
+ if fatal["started"] or fatal["missing"] is None:
1028
+ where = "the device executor stopped" if fatal["started"] else "could not start"
1029
+ lost = self._lose(f"{where}: {error}\n{fatal['traceback']}")
1030
+ raise lost
1031
+ self._lose(error)
1032
+ hint = (
1033
+ "; add torch to the project's dependencies (uv add torch) so the runtime's "
1034
+ "environment has it"
1035
+ if fatal["missing"] == "torch"
1036
+ else "; add it to the project's dependencies"
1037
+ )
1038
+ raise ConfigError(f"{self.name}: the device executor could not start: {error}{hint}")
1039
+
1019
1040
  def _check_open(self) -> None:
1020
1041
  if os.getpid() != self._pid:
1021
1042
  raise RuntimeLost(
@@ -1113,10 +1134,10 @@ def connect(
1113
1134
 
1114
1135
  transport.raw(count_line(source) + source)
1115
1136
  client.start()
1116
- except (TransportClosed, RuntimeLost) as exc:
1137
+ except (TransportClosed, RuntimeLost, ConfigError) as exc:
1117
1138
  process.kill()
1118
1139
  process.wait()
1119
- if isinstance(exc, RuntimeLost):
1140
+ if isinstance(exc, (RuntimeLost, ConfigError)):
1120
1141
  raise
1121
1142
  raise client._lose(str(exc)) from exc
1122
1143
  return client
@@ -1132,7 +1153,11 @@ def attach(
1132
1153
  channel.request(
1133
1154
  {"op": "device", "device": device, "source": worker_source(None).decode()}, timeout=600
1134
1155
  )
1135
- client.start()
1156
+ try:
1157
+ client.start()
1158
+ except (RuntimeLost, ConfigError):
1159
+ transport.close()
1160
+ raise
1136
1161
  return client
1137
1162
 
1138
1163
 
@@ -946,14 +946,37 @@ class Executor:
946
946
 
947
947
 
948
948
  def serve_transport(device: str, transport) -> None:
949
- """Announce the executor on ``transport`` and answer its messages until it closes."""
950
- executor = Executor(device)
951
- transport.send(pickle.dumps(executor.hello(), protocol=5), [])
952
- while True:
949
+ """Announce the executor on ``transport`` and answer its messages until it closes.
950
+
951
+ An exception that ends the executor is sent as a last message, so the client stops
952
+ waiting for a reply that cannot come, as spec "Failure semantics" describes.
953
+ """
954
+ started = False
955
+ try:
956
+ executor = Executor(device)
957
+ transport.send(pickle.dumps(executor.hello(), protocol=5), [])
958
+ started = True
959
+ _answer(executor, transport)
960
+ except TransportClosed:
961
+ return
962
+ except BaseException as exc:
963
+ detail = traceback.format_exc()
964
+ print(detail, file=sys.stderr, flush=True)
965
+ fatal = {
966
+ "error": f"{type(exc).__name__}: {exc}",
967
+ "traceback": detail,
968
+ "started": started,
969
+ "missing": exc.name if isinstance(exc, ImportError) else None,
970
+ }
953
971
  try:
954
- head, buffers = transport.recv()
955
- except TransportClosed:
956
- return
972
+ transport.send(pickle.dumps({"fatal": fatal}, protocol=5), [])
973
+ except Exception: # pragma: no cover - the link is gone as well
974
+ pass
975
+
976
+
977
+ def _answer(executor: Executor, transport) -> None:
978
+ while True:
979
+ head, buffers = transport.recv()
957
980
  message = pickle.loads(head)
958
981
  head = None
959
982
  if message.get("close"):
@@ -962,10 +985,7 @@ def serve_transport(device: str, transport) -> None:
962
985
  buffers = None
963
986
  if reply is not None:
964
987
  reply_head, views, keep = reply
965
- try:
966
- transport.send(reply_head, views)
967
- except TransportClosed:
968
- return
988
+ transport.send(reply_head, views)
969
989
  del keep
970
990
 
971
991
 
@@ -1096,6 +1096,7 @@ def streams_for(
1096
1096
  size: int,
1097
1097
  rate_bps: float | None = None,
1098
1098
  cost_s: float = 0.0,
1099
+ spares: int = 0,
1099
1100
  ) -> int:
1100
1101
  """How many connections this blob is worth splitting across.
1101
1102
 
@@ -1103,14 +1104,16 @@ def streams_for(
1103
1104
  connections to send a few megabytes costs more than it saves; one on a short link,
1104
1105
  because there a single stream already carries everything the link will carry; never
1105
1106
  more streams than there are chunks to send; and, when a connection costs something to
1106
- open, only as many as finish the transfer sooner than one would.
1107
+ open, only as many as finish the transfer sooner than one would. The extra streams
1108
+ (all but one) that the link's ``spares`` cover cost nothing, so the cost applies only
1109
+ when ``count - 1`` exceeds them.
1107
1110
  """
1108
1111
  if streams <= 1 or size < threshold_mib * (1 << 20):
1109
1112
  return 1
1110
1113
  if rtt_ms is None or rtt_ms < PARALLEL_RTT_MS:
1111
1114
  return 1
1112
1115
  count = max(1, min(streams, (size + CHUNK - 1) // CHUNK))
1113
- if cost_s > 0 and count > 1:
1116
+ if cost_s > 0 and count - 1 > max(0, spares):
1114
1117
  if not rate_bps or rate_bps <= 0:
1115
1118
  # Nothing to weigh the cost against, and the cost is real, so do not pay it.
1116
1119
  return 1
@@ -1147,6 +1150,15 @@ def _measured_rtt(provider: Any, runtime: Any) -> float | None:
1147
1150
  return getattr(_measured_link(provider, runtime), "rtt_ms", None)
1148
1151
 
1149
1152
 
1153
+ def _spares_of(link: Any) -> int:
1154
+ """The spare connections ``link`` still holds, 0 where it has no such query."""
1155
+ query = getattr(link, "spare_connections", None)
1156
+ try:
1157
+ return max(0, int(query())) if callable(query) else 0
1158
+ except Exception:
1159
+ return 0
1160
+
1161
+
1150
1162
  def _streams_for(runtime: Any, size: int, *, warm: bool = False) -> int:
1151
1163
  """``streams_for`` with the account's settings and the link's measured round trip.
1152
1164
 
@@ -1167,6 +1179,7 @@ def _streams_for(runtime: Any, size: int, *, warm: bool = False) -> int:
1167
1179
  threshold_mib=int(getattr(provider, "transfer_parallel_mib", PARALLEL_MIB) or 0),
1168
1180
  rate_bps=rate,
1169
1181
  cost_s=cost_s,
1182
+ spares=_spares_of(link),
1170
1183
  rtt_ms=rtt,
1171
1184
  size=size,
1172
1185
  )
@@ -44,6 +44,10 @@ class Link:
44
44
  """A stream the probe can run over, or None when this link cannot carry it."""
45
45
  return None
46
46
 
47
+ def spare_connections(self) -> int:
48
+ """Connections handed out with no rendezvous. Spec "Several connections at once"."""
49
+ return 0
50
+
47
51
  def ssh_command(self, remote_command: str | None = None) -> list[str]:
48
52
  raise RuntimeFailure(f"the {self.strategy} link carries no SSH session")
49
53
 
@@ -150,6 +154,11 @@ class PunchedLink(Link):
150
154
  sock = self._spares.pop(0)
151
155
  return sock if sock is not None else self._redial()
152
156
 
157
+ def spare_connections(self) -> int:
158
+ """The spare sockets of the punch that no SSH command has taken yet."""
159
+ with self._lock:
160
+ return len(self._spares)
161
+
153
162
  def ssh_command(self, remote_command: str | None = None) -> list[str]:
154
163
  listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
155
164
  # Port 0: the operating system chooses, because Windows reserves ranges per machine.
@@ -138,11 +138,11 @@ dependencies = [
138
138
 
139
139
  [[package]]
140
140
  name = "letify"
141
- version = "2.10.0"
141
+ version = "2.10.2"
142
142
 
143
143
  [[package]]
144
144
  name = "letify-quic"
145
- version = "2.10.0"
145
+ version = "2.10.2"
146
146
  dependencies = [
147
147
  "quinn",
148
148
  "rcgen",
@@ -8,7 +8,7 @@ resolver = "3"
8
8
  members = ["letify", "letify-quic"]
9
9
 
10
10
  [workspace.package]
11
- version = "2.10.0"
11
+ version = "2.10.2"
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.10.0",
3
+ "version": "2.10.2",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "letify-status",
9
- "version": "2.10.0",
9
+ "version": "2.10.2",
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.10.0",
5
+ "version": "2.10.2",
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.10.0"
3
+ version = "2.10.2"
4
4
  description = "Declarations that become infrastructure."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -2027,3 +2027,114 @@ def test_the_environment_variable_turns_the_automatic_behaviour_off(monkeypatch)
2027
2027
  monkeypatch.delenv("LETIFY_AUTO_FETCH")
2028
2028
  assert auto_fetch_enabled(False) is False
2029
2029
  assert auto_fetch_enabled(None) is True
2030
+
2031
+
2032
+ # -- Spec: Failure semantics ---------------------------------------------------------
2033
+
2034
+
2035
+ class _Loopback:
2036
+ """The client's end of a pair of queues whose other end is ``serve_transport``."""
2037
+
2038
+ def __init__(self, incoming, outgoing):
2039
+ self.incoming = incoming
2040
+ self.outgoing = outgoing
2041
+
2042
+ def send(self, head, buffers) -> None:
2043
+ self.outgoing.put((bytes(head), [bytes(b) for b in buffers]))
2044
+
2045
+ def recv(self):
2046
+ message = self.incoming.get(timeout=20)
2047
+ if message is None:
2048
+ raise frames.TransportClosed("the loopback closed")
2049
+ return message
2050
+
2051
+ def close(self) -> None:
2052
+ self.outgoing.put(None)
2053
+
2054
+
2055
+ def _serve_in_thread(monkeypatch, make_executor):
2056
+ """Run the real ``serve_transport`` in a thread and return the client on its other end."""
2057
+ import queue
2058
+ import threading
2059
+
2060
+ from letify.remoting.device import executor as executor_module
2061
+
2062
+ requests, replies = queue.Queue(), queue.Queue()
2063
+ monkeypatch.setattr(executor_module, "Executor", make_executor)
2064
+ server = _Loopback(requests, replies)
2065
+ thread = threading.Thread(
2066
+ target=executor_module.serve_transport,
2067
+ args=("cpu", server),
2068
+ daemon=True,
2069
+ )
2070
+ thread.start()
2071
+ return forwarding.Client(_Loopback(replies, requests), name="loopback"), thread
2072
+
2073
+
2074
+ def test_a_device_executor_that_dies_mid_session_fails_the_waiting_read(monkeypatch) -> None:
2075
+ from letify.remoting.device import executor as executor_module
2076
+
2077
+ real = executor_module.Executor
2078
+
2079
+ class Dying(real):
2080
+ def run(self, message, buffers):
2081
+ raise ValueError("the executor broke")
2082
+
2083
+ client, thread = _serve_in_thread(monkeypatch, Dying)
2084
+ client.start()
2085
+ with client.activate(), pytest.raises(letify.RuntimeLost, match="the executor broke"):
2086
+ torch.ones(2, device="cuda").cpu()
2087
+ thread.join(5)
2088
+ assert not thread.is_alive()
2089
+
2090
+
2091
+ def test_a_device_executor_that_cannot_start_fails_the_hello_naming_the_cause(
2092
+ monkeypatch,
2093
+ ) -> None:
2094
+ class Broken:
2095
+ def __init__(self, device):
2096
+ raise RuntimeError("no device to start on")
2097
+
2098
+ client, _thread = _serve_in_thread(monkeypatch, Broken)
2099
+ with pytest.raises(letify.RuntimeLost, match="no device to start on"):
2100
+ client.start()
2101
+
2102
+
2103
+ _WITHOUT_TORCH = (
2104
+ "import sys\n"
2105
+ "class _NoTorch:\n"
2106
+ " def find_spec(self, name, path=None, target=None):\n"
2107
+ " if name == 'torch':\n"
2108
+ " raise ModuleNotFoundError(\"No module named 'torch'\", name='torch')\n"
2109
+ "sys.meta_path.insert(0, _NoTorch())\n"
2110
+ )
2111
+
2112
+
2113
+ def test_a_device_worker_that_cannot_import_torch_fails_the_call_naming_it(
2114
+ let, cpu, monkeypatch
2115
+ ) -> None:
2116
+ from letify.remoting.device import client as client_module
2117
+
2118
+ real = client_module.worker_source
2119
+ future_line = b"from __future__ import annotations\n"
2120
+
2121
+ def without_torch(device):
2122
+ return real(device).replace(future_line, future_line + _WITHOUT_TORCH.encode(), 1)
2123
+
2124
+ monkeypatch.setattr(client_module, "worker_source", without_torch)
2125
+
2126
+ @let.function(device=cpu, host="local")
2127
+ def step() -> float:
2128
+ return float(torch.ones(2, device="cuda").sum().item())
2129
+
2130
+ # The call must fail at once instead of waiting for a reply that never comes.
2131
+ import concurrent.futures
2132
+
2133
+ pool = concurrent.futures.ThreadPoolExecutor(1)
2134
+ try:
2135
+ future = pool.submit(step)
2136
+ with pytest.raises(letify.ConfigError, match="No module named 'torch'") as caught:
2137
+ future.result(timeout=60)
2138
+ finally:
2139
+ pool.shutdown(wait=False)
2140
+ assert "add torch" in str(caught.value).lower() or "dependencies" in str(caught.value)
@@ -548,3 +548,82 @@ class _Measured:
548
548
  def __init__(self, rtt_ms: float, upload_bps: float) -> None:
549
549
  self.rtt_ms = rtt_ms
550
550
  self.upload_bps = upload_bps
551
+
552
+
553
+ def test_spare_sockets_let_a_transfer_open_streams_without_paying_a_punch() -> None:
554
+ """Spec "Several connections at once": streams covered by spares cost no rendezvous.
555
+
556
+ 128 MiB at 56.8 MiB/s takes 2.3 s, so a 10 s punch keeps one stream. With three
557
+ spares the three extra streams of a four stream split need no punch and are kept.
558
+ """
559
+ from letify.store.pathdata import streams_for
560
+
561
+ MIB = 1 << 20
562
+ near = dict(streams=4, threshold_mib=64, rtt_ms=44.7, size=128 * MIB, rate_bps=56.8 * MIB)
563
+ assert streams_for(**near, cost_s=10.0, spares=0) == 1
564
+ assert streams_for(**near, cost_s=10.0, spares=3) == 4
565
+ assert streams_for(**near, cost_s=10.0, spares=4) == 4
566
+
567
+
568
+ def test_streams_beyond_the_spares_pay_the_connection_cost() -> None:
569
+ """Spec: with fewer spares than extra streams, one punch is paid, once, for all of them."""
570
+ from letify.store.pathdata import streams_for
571
+
572
+ MIB = 1 << 20
573
+ near = dict(streams=4, threshold_mib=64, rtt_ms=44.7, size=128 * MIB, rate_bps=56.8 * MIB)
574
+ assert streams_for(**near, cost_s=10.0, spares=2) == 1
575
+ far = dict(streams=4, threshold_mib=64, rtt_ms=193.0, size=1024 * MIB, rate_bps=12.8 * MIB)
576
+ assert streams_for(**far, cost_s=10.0, spares=2) == 4
577
+
578
+
579
+ def test_a_punched_link_reports_the_spare_sockets_it_still_holds() -> None:
580
+ """Spec: a punched link holds the spares of its punch; any other link holds none."""
581
+ import socket
582
+
583
+ from letify.transport.link import Link, PunchedLink
584
+
585
+ assert Link("x", 0).spare_connections() == 0
586
+ pairs = [socket.socketpair() for _ in range(4)]
587
+ socks = [a for a, _ in pairs]
588
+ link = PunchedLink("tcp_punch", 0, socks[0], lambda port, cmd: [], lambda: socks[0], socks[1:])
589
+ try:
590
+ assert link.spare_connections() == 3
591
+ link._take() # the punched connection itself
592
+ assert link.spare_connections() == 3
593
+ link._take() # first spare
594
+ assert link.spare_connections() == 2
595
+ finally:
596
+ link.close()
597
+ for _, b in pairs:
598
+ b.close()
599
+
600
+
601
+ def test_the_placement_reads_the_spares_from_the_link(monkeypatch) -> None:
602
+ """Spec: `_streams_for` passes the link's spare count, so a Kaggle shard splits for free."""
603
+ from conftest import provider_of
604
+
605
+ from letify.providers import Kaggle
606
+ from letify.store.pathdata import _streams_for
607
+
608
+ MIB = 1 << 20
609
+ provider = provider_of(Kaggle, "kaggle_a")
610
+
611
+ class Runtime:
612
+ name = "letify-t4-cd14ab"
613
+
614
+ def make(spares: int):
615
+ class Link:
616
+ strategy = "tcp_punch"
617
+ rtt_ms = 44.7
618
+ upload_bps = 56.8 * MIB
619
+
620
+ def spare_connections(self) -> int:
621
+ return spares
622
+
623
+ return Link()
624
+
625
+ Runtime.provider = provider
626
+ provider._links[Runtime.name] = make(0)
627
+ assert _streams_for(Runtime(), 128 * MIB) == 1
628
+ provider._links[Runtime.name] = make(3)
629
+ assert _streams_for(Runtime(), 128 * MIB) == 4