letify 2.10.0__tar.gz → 2.10.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 (213) hide show
  1. {letify-2.10.0 → letify-2.10.1}/PKG-INFO +1 -1
  2. {letify-2.10.0 → letify-2.10.1}/docs/SPEC.md +6 -1
  3. {letify-2.10.0 → letify-2.10.1}/letify/__init__.py +1 -1
  4. {letify-2.10.0 → letify-2.10.1}/letify/remoting/device/client.py +29 -4
  5. {letify-2.10.0 → letify-2.10.1}/letify/remoting/device/executor.py +31 -11
  6. {letify-2.10.0 → letify-2.10.1}/letify/store/pathdata.py +15 -2
  7. {letify-2.10.0 → letify-2.10.1}/letify/transport/link.py +9 -0
  8. {letify-2.10.0 → letify-2.10.1}/letify-core/Cargo.lock +2 -2
  9. {letify-2.10.0 → letify-2.10.1}/letify-core/Cargo.toml +1 -1
  10. {letify-2.10.0 → letify-2.10.1}/letify-ext/package-lock.json +2 -2
  11. {letify-2.10.0 → letify-2.10.1}/letify-ext/package.json +1 -1
  12. {letify-2.10.0 → letify-2.10.1}/pyproject.toml +1 -1
  13. {letify-2.10.0 → letify-2.10.1}/tests/test_device.py +111 -0
  14. {letify-2.10.0 → letify-2.10.1}/tests/test_parallel_placement.py +79 -0
  15. {letify-2.10.0 → letify-2.10.1}/.github/workflows/ci.yml +0 -0
  16. {letify-2.10.0 → letify-2.10.1}/.github/workflows/docs.yml +0 -0
  17. {letify-2.10.0 → letify-2.10.1}/.github/workflows/publish-crates.yml +0 -0
  18. {letify-2.10.0 → letify-2.10.1}/.github/workflows/publish.yml +0 -0
  19. {letify-2.10.0 → letify-2.10.1}/.github/workflows/reserve-crates.yml +0 -0
  20. {letify-2.10.0 → letify-2.10.1}/.gitignore +0 -0
  21. {letify-2.10.0 → letify-2.10.1}/.researchtree +0 -0
  22. {letify-2.10.0 → letify-2.10.1}/CLAUDE.md +0 -0
  23. {letify-2.10.0 → letify-2.10.1}/LICENSE +0 -0
  24. {letify-2.10.0 → letify-2.10.1}/PROJECT.md +0 -0
  25. {letify-2.10.0 → letify-2.10.1}/README.md +0 -0
  26. {letify-2.10.0 → letify-2.10.1}/docs/COMPONENT.md +0 -0
  27. {letify-2.10.0 → letify-2.10.1}/docs/INTENT.md +0 -0
  28. {letify-2.10.0 → letify-2.10.1}/docs/NETWORK.md +0 -0
  29. {letify-2.10.0 → letify-2.10.1}/docs/guide/01-getting-started.md +0 -0
  30. {letify-2.10.0 → letify-2.10.1}/docs/guide/02-providers.md +0 -0
  31. {letify-2.10.0 → letify-2.10.1}/docs/guide/03-execution-modes.md +0 -0
  32. {letify-2.10.0 → letify-2.10.1}/docs/guide/04-environments-and-data.md +0 -0
  33. {letify-2.10.0 → letify-2.10.1}/docs/guide/05-concurrency.md +0 -0
  34. {letify-2.10.0 → letify-2.10.1}/docs/guide/06-cost.md +0 -0
  35. {letify-2.10.0 → letify-2.10.1}/docs/guide/07-troubleshooting.md +0 -0
  36. {letify-2.10.0 → letify-2.10.1}/docs/guide/08-releasing.md +0 -0
  37. {letify-2.10.0 → letify-2.10.1}/docs/guide/README.md +0 -0
  38. {letify-2.10.0 → letify-2.10.1}/docs/guide/ko/01-/354/213/234/354/236/221/355/225/230/352/270/260.md" +0 -0
  39. {letify-2.10.0 → letify-2.10.1}/docs/guide/ko/02-/355/224/204/353/241/234/353/260/224/354/235/264/353/215/224.md" +0 -0
  40. {letify-2.10.0 → letify-2.10.1}/docs/guide/ko/03-/354/213/244/355/226/211-/353/260/251/354/213/235.md" +0 -0
  41. {letify-2.10.0 → letify-2.10.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
  42. {letify-2.10.0 → letify-2.10.1}/docs/guide/ko/05-/353/217/231/354/213/234-/354/213/244/355/226/211.md" +0 -0
  43. {letify-2.10.0 → letify-2.10.1}/docs/guide/ko/06-/353/271/204/354/232/251.md" +0 -0
  44. {letify-2.10.0 → letify-2.10.1}/docs/guide/ko/07-/353/254/270/354/240/234-/355/225/264/352/262/260.md" +0 -0
  45. {letify-2.10.0 → letify-2.10.1}/docs/guide/ko/08-/353/246/264/353/246/254/354/212/244.md" +0 -0
  46. {letify-2.10.0 → letify-2.10.1}/docs/guide/ko/README.md +0 -0
  47. {letify-2.10.0 → letify-2.10.1}/docs/locales/README_ko.md +0 -0
  48. {letify-2.10.0 → letify-2.10.1}/docs/mkdocs.yml +0 -0
  49. {letify-2.10.0 → letify-2.10.1}/examples/README.md +0 -0
  50. {letify-2.10.0 → letify-2.10.1}/examples/nvfp4-lora/.letify.example +0 -0
  51. {letify-2.10.0 → letify-2.10.1}/examples/nvfp4-lora/00_smoke.py +0 -0
  52. {letify-2.10.0 → letify-2.10.1}/examples/nvfp4-lora/01_sweep.py +0 -0
  53. {letify-2.10.0 → letify-2.10.1}/examples/nvfp4-lora/02_resume.py +0 -0
  54. {letify-2.10.0 → letify-2.10.1}/examples/nvfp4-lora/03_watch.py +0 -0
  55. {letify-2.10.0 → letify-2.10.1}/examples/nvfp4-lora/README.md +0 -0
  56. {letify-2.10.0 → letify-2.10.1}/examples/nvfp4-lora/README_ko.md +0 -0
  57. {letify-2.10.0 → letify-2.10.1}/examples/nvfp4-lora/common.py +0 -0
  58. {letify-2.10.0 → letify-2.10.1}/examples/nvfp4-lora/recipe.py +0 -0
  59. {letify-2.10.0 → letify-2.10.1}/letify/cli.py +0 -0
  60. {letify-2.10.0 → letify-2.10.1}/letify/config/__init__.py +0 -0
  61. {letify-2.10.0 → letify-2.10.1}/letify/config/inventory.py +0 -0
  62. {letify-2.10.0 → letify-2.10.1}/letify/config/login.py +0 -0
  63. {letify-2.10.0 → letify-2.10.1}/letify/config/schema.py +0 -0
  64. {letify-2.10.0 → letify-2.10.1}/letify/config/secrets.py +0 -0
  65. {letify-2.10.0 → letify-2.10.1}/letify/config/writer.py +0 -0
  66. {letify-2.10.0 → letify-2.10.1}/letify/declare/__init__.py +0 -0
  67. {letify-2.10.0 → letify-2.10.1}/letify/declare/cache.py +0 -0
  68. {letify-2.10.0 → letify-2.10.1}/letify/declare/env.py +0 -0
  69. {letify-2.10.0 → letify-2.10.1}/letify/declare/exit.py +0 -0
  70. {letify-2.10.0 → letify-2.10.1}/letify/declare/function.py +0 -0
  71. {letify-2.10.0 → letify-2.10.1}/letify/declare/instance.py +0 -0
  72. {letify-2.10.0 → letify-2.10.1}/letify/declare/reduce.py +0 -0
  73. {letify-2.10.0 → letify-2.10.1}/letify/declare/replicas.py +0 -0
  74. {letify-2.10.0 → letify-2.10.1}/letify/errors.py +0 -0
  75. {letify-2.10.0 → letify-2.10.1}/letify/install.py +0 -0
  76. {letify-2.10.0 → letify-2.10.1}/letify/launcher.py +0 -0
  77. {letify-2.10.0 → letify-2.10.1}/letify/paths.py +0 -0
  78. {letify-2.10.0 → letify-2.10.1}/letify/protocol/__init__.py +0 -0
  79. {letify-2.10.0 → letify-2.10.1}/letify/protocol/codec.py +0 -0
  80. {letify-2.10.0 → letify-2.10.1}/letify/protocol/driver.py +0 -0
  81. {letify-2.10.0 → letify-2.10.1}/letify/protocol/handle.py +0 -0
  82. {letify-2.10.0 → letify-2.10.1}/letify/protocol/wire.py +0 -0
  83. {letify-2.10.0 → letify-2.10.1}/letify/protocol/worker.py +0 -0
  84. {letify-2.10.0 → letify-2.10.1}/letify/providers/__init__.py +0 -0
  85. {letify-2.10.0 → letify-2.10.1}/letify/providers/base.py +0 -0
  86. {letify-2.10.0 → letify-2.10.1}/letify/providers/colab.py +0 -0
  87. {letify-2.10.0 → letify-2.10.1}/letify/providers/colab_files.py +0 -0
  88. {letify-2.10.0 → letify-2.10.1}/letify/providers/colab_keepalive.py +0 -0
  89. {letify-2.10.0 → letify-2.10.1}/letify/providers/elice.py +0 -0
  90. {letify-2.10.0 → letify-2.10.1}/letify/providers/kaggle.py +0 -0
  91. {letify-2.10.0 → letify-2.10.1}/letify/providers/kaggle_adapter.py +0 -0
  92. {letify-2.10.0 → letify-2.10.1}/letify/providers/local.py +0 -0
  93. {letify-2.10.0 → letify-2.10.1}/letify/providers/modal.py +0 -0
  94. {letify-2.10.0 → letify-2.10.1}/letify/providers/modal_adapter.py +0 -0
  95. {letify-2.10.0 → letify-2.10.1}/letify/providers/naming.py +0 -0
  96. {letify-2.10.0 → letify-2.10.1}/letify/providers/shell.py +0 -0
  97. {letify-2.10.0 → letify-2.10.1}/letify/providers/tunnel.py +0 -0
  98. {letify-2.10.0 → letify-2.10.1}/letify/providers/usage.py +0 -0
  99. {letify-2.10.0 → letify-2.10.1}/letify/remoting/__init__.py +0 -0
  100. {letify-2.10.0 → letify-2.10.1}/letify/remoting/device/__init__.py +0 -0
  101. {letify-2.10.0 → letify-2.10.1}/letify/remoting/device/cuda.py +0 -0
  102. {letify-2.10.0 → letify-2.10.1}/letify/remoting/device/frames.py +0 -0
  103. {letify-2.10.0 → letify-2.10.1}/letify/remoting/device/guard.py +0 -0
  104. {letify-2.10.0 → letify-2.10.1}/letify/remoting/device/tensor.py +0 -0
  105. {letify-2.10.0 → letify-2.10.1}/letify/remoting/device/trace.py +0 -0
  106. {letify-2.10.0 → letify-2.10.1}/letify/remoting/device/value.py +0 -0
  107. {letify-2.10.0 → letify-2.10.1}/letify/render.py +0 -0
  108. {letify-2.10.0 → letify-2.10.1}/letify/runtime/__init__.py +0 -0
  109. {letify-2.10.0 → letify-2.10.1}/letify/runtime/bootstrap.py +0 -0
  110. {letify-2.10.0 → letify-2.10.1}/letify/runtime/broker.py +0 -0
  111. {letify-2.10.0 → letify-2.10.1}/letify/runtime/channel.py +0 -0
  112. {letify-2.10.0 → letify-2.10.1}/letify/runtime/control.py +0 -0
  113. {letify-2.10.0 → letify-2.10.1}/letify/runtime/lease.py +0 -0
  114. {letify-2.10.0 → letify-2.10.1}/letify/runtime/pool.py +0 -0
  115. {letify-2.10.0 → letify-2.10.1}/letify/runtime/session.py +0 -0
  116. {letify-2.10.0 → letify-2.10.1}/letify/runtime/takeover.py +0 -0
  117. {letify-2.10.0 → letify-2.10.1}/letify/runtime/telemetry.py +0 -0
  118. {letify-2.10.0 → letify-2.10.1}/letify/runtime/workers.py +0 -0
  119. {letify-2.10.0 → letify-2.10.1}/letify/store/__init__.py +0 -0
  120. {letify-2.10.0 → letify-2.10.1}/letify/store/backends/__init__.py +0 -0
  121. {letify-2.10.0 → letify-2.10.1}/letify/store/backends/drive.py +0 -0
  122. {letify-2.10.0 → letify-2.10.1}/letify/store/backends/filesystem.py +0 -0
  123. {letify-2.10.0 → letify-2.10.1}/letify/store/backends/google_auth.py +0 -0
  124. {letify-2.10.0 → letify-2.10.1}/letify/store/backends/layout.py +0 -0
  125. {letify-2.10.0 → letify-2.10.1}/letify/store/backends/objects.py +0 -0
  126. {letify-2.10.0 → letify-2.10.1}/letify/store/cas.py +0 -0
  127. {letify-2.10.0 → letify-2.10.1}/letify/store/handoff.py +0 -0
  128. {letify-2.10.0 → letify-2.10.1}/letify/store/sendorder.py +0 -0
  129. {letify-2.10.0 → letify-2.10.1}/letify/store/volume.py +0 -0
  130. {letify-2.10.0 → letify-2.10.1}/letify/stubs.py +0 -0
  131. {letify-2.10.0 → letify-2.10.1}/letify/tools.py +0 -0
  132. {letify-2.10.0 → letify-2.10.1}/letify/transport/__init__.py +0 -0
  133. {letify-2.10.0 → letify-2.10.1}/letify/transport/agent.py +0 -0
  134. {letify-2.10.0 → letify-2.10.1}/letify/transport/announce.py +0 -0
  135. {letify-2.10.0 → letify-2.10.1}/letify/transport/nat.py +0 -0
  136. {letify-2.10.0 → letify-2.10.1}/letify/transport/pipeline.py +0 -0
  137. {letify-2.10.0 → letify-2.10.1}/letify/transport/portmap.py +0 -0
  138. {letify-2.10.0 → letify-2.10.1}/letify/transport/probe.py +0 -0
  139. {letify-2.10.0 → letify-2.10.1}/letify/transport/quic.py +0 -0
  140. {letify-2.10.0 → letify-2.10.1}/letify/transport/rendezvous.py +0 -0
  141. {letify-2.10.0 → letify-2.10.1}/letify/transport/setup.py +0 -0
  142. {letify-2.10.0 → letify-2.10.1}/letify/transport/sshopts.py +0 -0
  143. {letify-2.10.0 → letify-2.10.1}/letify/transport/strategies.py +0 -0
  144. {letify-2.10.0 → letify-2.10.1}/letify-core/.gitignore +0 -0
  145. {letify-2.10.0 → letify-2.10.1}/letify-core/build.py +0 -0
  146. {letify-2.10.0 → letify-2.10.1}/letify-core/letify/Cargo.toml +0 -0
  147. {letify-2.10.0 → letify-2.10.1}/letify-core/letify/LICENSE +0 -0
  148. {letify-2.10.0 → letify-2.10.1}/letify-core/letify/README.md +0 -0
  149. {letify-2.10.0 → letify-2.10.1}/letify-core/letify/src/lib.rs +0 -0
  150. {letify-2.10.0 → letify-2.10.1}/letify-core/letify-quic/Cargo.toml +0 -0
  151. {letify-2.10.0 → letify-2.10.1}/letify-core/letify-quic/src/main.rs +0 -0
  152. {letify-2.10.0 → letify-2.10.1}/letify-core/rust-toolchain.toml +0 -0
  153. {letify-2.10.0 → letify-2.10.1}/letify-ext/.vscodeignore +0 -0
  154. {letify-2.10.0 → letify-2.10.1}/letify-ext/LICENSE +0 -0
  155. {letify-2.10.0 → letify-2.10.1}/letify-ext/README.md +0 -0
  156. {letify-2.10.0 → letify-2.10.1}/letify-ext/media/icon.svg +0 -0
  157. {letify-2.10.0 → letify-2.10.1}/letify-ext/src/cli.ts +0 -0
  158. {letify-2.10.0 → letify-2.10.1}/letify-ext/src/extension.ts +0 -0
  159. {letify-2.10.0 → letify-2.10.1}/letify-ext/src/history.ts +0 -0
  160. {letify-2.10.0 → letify-2.10.1}/letify-ext/src/model.ts +0 -0
  161. {letify-2.10.0 → letify-2.10.1}/letify-ext/src/poll.ts +0 -0
  162. {letify-2.10.0 → letify-2.10.1}/letify-ext/src/render.ts +0 -0
  163. {letify-2.10.0 → letify-2.10.1}/letify-ext/test/cli.test.ts +0 -0
  164. {letify-2.10.0 → letify-2.10.1}/letify-ext/test/manifest.test.ts +0 -0
  165. {letify-2.10.0 → letify-2.10.1}/letify-ext/test/model.test.ts +0 -0
  166. {letify-2.10.0 → letify-2.10.1}/letify-ext/test/regressions.test.ts +0 -0
  167. {letify-2.10.0 → letify-2.10.1}/letify-ext/tsconfig.json +0 -0
  168. {letify-2.10.0 → letify-2.10.1}/letify_providers.py +0 -0
  169. {letify-2.10.0 → letify-2.10.1}/scripts/sync-main.sh +0 -0
  170. {letify-2.10.0 → letify-2.10.1}/scripts/version.py +0 -0
  171. {letify-2.10.0 → letify-2.10.1}/tests/conftest.py +0 -0
  172. {letify-2.10.0 → letify-2.10.1}/tests/fake_kaggle_adapter.py +0 -0
  173. {letify-2.10.0 → letify-2.10.1}/tests/fake_modal_adapter.py +0 -0
  174. {letify-2.10.0 → letify-2.10.1}/tests/test_cli.py +0 -0
  175. {letify-2.10.0 → letify-2.10.1}/tests/test_cli_output.py +0 -0
  176. {letify-2.10.0 → letify-2.10.1}/tests/test_colab_keepalive.py +0 -0
  177. {letify-2.10.0 → letify-2.10.1}/tests/test_config.py +0 -0
  178. {letify-2.10.0 → letify-2.10.1}/tests/test_core.py +0 -0
  179. {letify-2.10.0 → letify-2.10.1}/tests/test_data.py +0 -0
  180. {letify-2.10.0 → letify-2.10.1}/tests/test_data_streaming.py +0 -0
  181. {letify-2.10.0 → letify-2.10.1}/tests/test_declare.py +0 -0
  182. {letify-2.10.0 → letify-2.10.1}/tests/test_device_compile.py +0 -0
  183. {letify-2.10.0 → letify-2.10.1}/tests/test_distributed.py +0 -0
  184. {letify-2.10.0 → letify-2.10.1}/tests/test_drive_handoff.py +0 -0
  185. {letify-2.10.0 → letify-2.10.1}/tests/test_drive_store.py +0 -0
  186. {letify-2.10.0 → letify-2.10.1}/tests/test_editor_sessions.py +0 -0
  187. {letify-2.10.0 → letify-2.10.1}/tests/test_elice.py +0 -0
  188. {letify-2.10.0 → letify-2.10.1}/tests/test_frames.py +0 -0
  189. {letify-2.10.0 → letify-2.10.1}/tests/test_install.py +0 -0
  190. {letify-2.10.0 → letify-2.10.1}/tests/test_kaggle.py +0 -0
  191. {letify-2.10.0 → letify-2.10.1}/tests/test_kaggle_cookie.py +0 -0
  192. {letify-2.10.0 → letify-2.10.1}/tests/test_kaggle_link.py +0 -0
  193. {letify-2.10.0 → letify-2.10.1}/tests/test_link_log.py +0 -0
  194. {letify-2.10.0 → letify-2.10.1}/tests/test_notebook.py +0 -0
  195. {letify-2.10.0 → letify-2.10.1}/tests/test_paths.py +0 -0
  196. {letify-2.10.0 → letify-2.10.1}/tests/test_portmap.py +0 -0
  197. {letify-2.10.0 → letify-2.10.1}/tests/test_protocol.py +0 -0
  198. {letify-2.10.0 → letify-2.10.1}/tests/test_providers.py +0 -0
  199. {letify-2.10.0 → letify-2.10.1}/tests/test_punch_sockets.py +0 -0
  200. {letify-2.10.0 → letify-2.10.1}/tests/test_quic.py +0 -0
  201. {letify-2.10.0 → letify-2.10.1}/tests/test_remoting.py +0 -0
  202. {letify-2.10.0 → letify-2.10.1}/tests/test_render.py +0 -0
  203. {letify-2.10.0 → letify-2.10.1}/tests/test_run_control.py +0 -0
  204. {letify-2.10.0 → letify-2.10.1}/tests/test_run_takeover.py +0 -0
  205. {letify-2.10.0 → letify-2.10.1}/tests/test_runtime.py +0 -0
  206. {letify-2.10.0 → letify-2.10.1}/tests/test_shell_workers.py +0 -0
  207. {letify-2.10.0 → letify-2.10.1}/tests/test_ship.py +0 -0
  208. {letify-2.10.0 → letify-2.10.1}/tests/test_store.py +0 -0
  209. {letify-2.10.0 → letify-2.10.1}/tests/test_strategies.py +0 -0
  210. {letify-2.10.0 → letify-2.10.1}/tests/test_stubs.py +0 -0
  211. {letify-2.10.0 → letify-2.10.1}/tests/test_transport.py +0 -0
  212. {letify-2.10.0 → letify-2.10.1}/tests/test_tunnel_setup.py +0 -0
  213. {letify-2.10.0 → letify-2.10.1}/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.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
@@ -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.1"
74
74
 
75
75
 
76
76
  def fetch(tensor): # type: ignore[no-untyped-def]
@@ -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.1"
142
142
 
143
143
  [[package]]
144
144
  name = "letify-quic"
145
- version = "2.10.0"
145
+ version = "2.10.1"
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.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": "2.10.0",
3
+ "version": "2.10.1",
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.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": "2.10.0",
5
+ "version": "2.10.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 = "2.10.0"
3
+ version = "2.10.1"
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
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