letify 2.5.4__tar.gz → 2.6.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (210) hide show
  1. {letify-2.5.4 → letify-2.6.0}/PKG-INFO +1 -1
  2. {letify-2.5.4 → letify-2.6.0}/docs/SPEC.md +24 -1
  3. {letify-2.5.4 → letify-2.6.0}/letify/__init__.py +1 -1
  4. {letify-2.5.4 → letify-2.6.0}/letify/cli.py +9 -2
  5. {letify-2.5.4 → letify-2.6.0}/letify/store/pathdata.py +330 -45
  6. {letify-2.5.4 → letify-2.6.0}/letify-core/Cargo.lock +3 -3
  7. {letify-2.5.4 → letify-2.6.0}/letify-core/Cargo.toml +1 -1
  8. {letify-2.5.4 → letify-2.6.0}/letify-ext/package-lock.json +2 -2
  9. {letify-2.5.4 → letify-2.6.0}/letify-ext/package.json +1 -1
  10. {letify-2.5.4 → letify-2.6.0}/pyproject.toml +1 -1
  11. {letify-2.5.4 → letify-2.6.0}/tests/test_cli.py +14 -0
  12. {letify-2.5.4 → letify-2.6.0}/tests/test_parallel_placement.py +114 -1
  13. {letify-2.5.4 → letify-2.6.0}/.github/workflows/ci.yml +0 -0
  14. {letify-2.5.4 → letify-2.6.0}/.github/workflows/docs.yml +0 -0
  15. {letify-2.5.4 → letify-2.6.0}/.github/workflows/publish.yml +0 -0
  16. {letify-2.5.4 → letify-2.6.0}/.gitignore +0 -0
  17. {letify-2.5.4 → letify-2.6.0}/.researchtree +0 -0
  18. {letify-2.5.4 → letify-2.6.0}/CLAUDE.md +0 -0
  19. {letify-2.5.4 → letify-2.6.0}/LICENSE +0 -0
  20. {letify-2.5.4 → letify-2.6.0}/PROJECT.md +0 -0
  21. {letify-2.5.4 → letify-2.6.0}/README.md +0 -0
  22. {letify-2.5.4 → letify-2.6.0}/docs/COMPONENT.md +0 -0
  23. {letify-2.5.4 → letify-2.6.0}/docs/INTENT.md +0 -0
  24. {letify-2.5.4 → letify-2.6.0}/docs/NETWORK.md +0 -0
  25. {letify-2.5.4 → letify-2.6.0}/docs/guide/01-getting-started.md +0 -0
  26. {letify-2.5.4 → letify-2.6.0}/docs/guide/02-providers.md +0 -0
  27. {letify-2.5.4 → letify-2.6.0}/docs/guide/03-execution-modes.md +0 -0
  28. {letify-2.5.4 → letify-2.6.0}/docs/guide/04-environments-and-data.md +0 -0
  29. {letify-2.5.4 → letify-2.6.0}/docs/guide/05-concurrency.md +0 -0
  30. {letify-2.5.4 → letify-2.6.0}/docs/guide/06-cost.md +0 -0
  31. {letify-2.5.4 → letify-2.6.0}/docs/guide/07-troubleshooting.md +0 -0
  32. {letify-2.5.4 → letify-2.6.0}/docs/guide/08-releasing.md +0 -0
  33. {letify-2.5.4 → letify-2.6.0}/docs/guide/README.md +0 -0
  34. {letify-2.5.4 → letify-2.6.0}/docs/guide/ko/01-/354/213/234/354/236/221/355/225/230/352/270/260.md" +0 -0
  35. {letify-2.5.4 → letify-2.6.0}/docs/guide/ko/02-/355/224/204/353/241/234/353/260/224/354/235/264/353/215/224.md" +0 -0
  36. {letify-2.5.4 → letify-2.6.0}/docs/guide/ko/03-/354/213/244/355/226/211-/353/260/251/354/213/235.md" +0 -0
  37. {letify-2.5.4 → letify-2.6.0}/docs/guide/ko/04-/355/231/230/352/262/275/352/263/274-/353/215/260/354/235/264/355/204/260.md" +0 -0
  38. {letify-2.5.4 → letify-2.6.0}/docs/guide/ko/05-/353/217/231/354/213/234-/354/213/244/355/226/211.md" +0 -0
  39. {letify-2.5.4 → letify-2.6.0}/docs/guide/ko/06-/353/271/204/354/232/251.md" +0 -0
  40. {letify-2.5.4 → letify-2.6.0}/docs/guide/ko/07-/353/254/270/354/240/234-/355/225/264/352/262/260.md" +0 -0
  41. {letify-2.5.4 → letify-2.6.0}/docs/guide/ko/08-/353/246/264/353/246/254/354/212/244.md" +0 -0
  42. {letify-2.5.4 → letify-2.6.0}/docs/guide/ko/README.md +0 -0
  43. {letify-2.5.4 → letify-2.6.0}/docs/locales/README_ko.md +0 -0
  44. {letify-2.5.4 → letify-2.6.0}/docs/mkdocs.yml +0 -0
  45. {letify-2.5.4 → letify-2.6.0}/examples/README.md +0 -0
  46. {letify-2.5.4 → letify-2.6.0}/examples/nvfp4-lora/.letify.example +0 -0
  47. {letify-2.5.4 → letify-2.6.0}/examples/nvfp4-lora/00_smoke.py +0 -0
  48. {letify-2.5.4 → letify-2.6.0}/examples/nvfp4-lora/01_sweep.py +0 -0
  49. {letify-2.5.4 → letify-2.6.0}/examples/nvfp4-lora/02_resume.py +0 -0
  50. {letify-2.5.4 → letify-2.6.0}/examples/nvfp4-lora/03_watch.py +0 -0
  51. {letify-2.5.4 → letify-2.6.0}/examples/nvfp4-lora/README.md +0 -0
  52. {letify-2.5.4 → letify-2.6.0}/examples/nvfp4-lora/README_ko.md +0 -0
  53. {letify-2.5.4 → letify-2.6.0}/examples/nvfp4-lora/common.py +0 -0
  54. {letify-2.5.4 → letify-2.6.0}/examples/nvfp4-lora/recipe.py +0 -0
  55. {letify-2.5.4 → letify-2.6.0}/letify/config/__init__.py +0 -0
  56. {letify-2.5.4 → letify-2.6.0}/letify/config/inventory.py +0 -0
  57. {letify-2.5.4 → letify-2.6.0}/letify/config/login.py +0 -0
  58. {letify-2.5.4 → letify-2.6.0}/letify/config/schema.py +0 -0
  59. {letify-2.5.4 → letify-2.6.0}/letify/config/secrets.py +0 -0
  60. {letify-2.5.4 → letify-2.6.0}/letify/config/writer.py +0 -0
  61. {letify-2.5.4 → letify-2.6.0}/letify/declare/__init__.py +0 -0
  62. {letify-2.5.4 → letify-2.6.0}/letify/declare/cache.py +0 -0
  63. {letify-2.5.4 → letify-2.6.0}/letify/declare/env.py +0 -0
  64. {letify-2.5.4 → letify-2.6.0}/letify/declare/function.py +0 -0
  65. {letify-2.5.4 → letify-2.6.0}/letify/declare/instance.py +0 -0
  66. {letify-2.5.4 → letify-2.6.0}/letify/declare/reduce.py +0 -0
  67. {letify-2.5.4 → letify-2.6.0}/letify/declare/replicas.py +0 -0
  68. {letify-2.5.4 → letify-2.6.0}/letify/errors.py +0 -0
  69. {letify-2.5.4 → letify-2.6.0}/letify/install.py +0 -0
  70. {letify-2.5.4 → letify-2.6.0}/letify/launcher.py +0 -0
  71. {letify-2.5.4 → letify-2.6.0}/letify/paths.py +0 -0
  72. {letify-2.5.4 → letify-2.6.0}/letify/protocol/__init__.py +0 -0
  73. {letify-2.5.4 → letify-2.6.0}/letify/protocol/codec.py +0 -0
  74. {letify-2.5.4 → letify-2.6.0}/letify/protocol/driver.py +0 -0
  75. {letify-2.5.4 → letify-2.6.0}/letify/protocol/handle.py +0 -0
  76. {letify-2.5.4 → letify-2.6.0}/letify/protocol/wire.py +0 -0
  77. {letify-2.5.4 → letify-2.6.0}/letify/protocol/worker.py +0 -0
  78. {letify-2.5.4 → letify-2.6.0}/letify/providers/__init__.py +0 -0
  79. {letify-2.5.4 → letify-2.6.0}/letify/providers/base.py +0 -0
  80. {letify-2.5.4 → letify-2.6.0}/letify/providers/colab.py +0 -0
  81. {letify-2.5.4 → letify-2.6.0}/letify/providers/colab_files.py +0 -0
  82. {letify-2.5.4 → letify-2.6.0}/letify/providers/colab_keepalive.py +0 -0
  83. {letify-2.5.4 → letify-2.6.0}/letify/providers/elice.py +0 -0
  84. {letify-2.5.4 → letify-2.6.0}/letify/providers/kaggle.py +0 -0
  85. {letify-2.5.4 → letify-2.6.0}/letify/providers/kaggle_adapter.py +0 -0
  86. {letify-2.5.4 → letify-2.6.0}/letify/providers/local.py +0 -0
  87. {letify-2.5.4 → letify-2.6.0}/letify/providers/modal.py +0 -0
  88. {letify-2.5.4 → letify-2.6.0}/letify/providers/modal_adapter.py +0 -0
  89. {letify-2.5.4 → letify-2.6.0}/letify/providers/naming.py +0 -0
  90. {letify-2.5.4 → letify-2.6.0}/letify/providers/shell.py +0 -0
  91. {letify-2.5.4 → letify-2.6.0}/letify/providers/tunnel.py +0 -0
  92. {letify-2.5.4 → letify-2.6.0}/letify/providers/usage.py +0 -0
  93. {letify-2.5.4 → letify-2.6.0}/letify/remoting/__init__.py +0 -0
  94. {letify-2.5.4 → letify-2.6.0}/letify/remoting/capability.py +0 -0
  95. {letify-2.5.4 → letify-2.6.0}/letify/remoting/device/__init__.py +0 -0
  96. {letify-2.5.4 → letify-2.6.0}/letify/remoting/device/client.py +0 -0
  97. {letify-2.5.4 → letify-2.6.0}/letify/remoting/device/cuda.py +0 -0
  98. {letify-2.5.4 → letify-2.6.0}/letify/remoting/device/executor.py +0 -0
  99. {letify-2.5.4 → letify-2.6.0}/letify/remoting/device/frames.py +0 -0
  100. {letify-2.5.4 → letify-2.6.0}/letify/remoting/device/guard.py +0 -0
  101. {letify-2.5.4 → letify-2.6.0}/letify/remoting/device/tensor.py +0 -0
  102. {letify-2.5.4 → letify-2.6.0}/letify/remoting/device/trace.py +0 -0
  103. {letify-2.5.4 → letify-2.6.0}/letify/remoting/device/value.py +0 -0
  104. {letify-2.5.4 → letify-2.6.0}/letify/remoting/loader.py +0 -0
  105. {letify-2.5.4 → letify-2.6.0}/letify/remoting/probe.py +0 -0
  106. {letify-2.5.4 → letify-2.6.0}/letify/render.py +0 -0
  107. {letify-2.5.4 → letify-2.6.0}/letify/runtime/__init__.py +0 -0
  108. {letify-2.5.4 → letify-2.6.0}/letify/runtime/bootstrap.py +0 -0
  109. {letify-2.5.4 → letify-2.6.0}/letify/runtime/broker.py +0 -0
  110. {letify-2.5.4 → letify-2.6.0}/letify/runtime/channel.py +0 -0
  111. {letify-2.5.4 → letify-2.6.0}/letify/runtime/lease.py +0 -0
  112. {letify-2.5.4 → letify-2.6.0}/letify/runtime/pool.py +0 -0
  113. {letify-2.5.4 → letify-2.6.0}/letify/runtime/session.py +0 -0
  114. {letify-2.5.4 → letify-2.6.0}/letify/runtime/telemetry.py +0 -0
  115. {letify-2.5.4 → letify-2.6.0}/letify/store/__init__.py +0 -0
  116. {letify-2.5.4 → letify-2.6.0}/letify/store/backends/__init__.py +0 -0
  117. {letify-2.5.4 → letify-2.6.0}/letify/store/backends/filesystem.py +0 -0
  118. {letify-2.5.4 → letify-2.6.0}/letify/store/backends/google_auth.py +0 -0
  119. {letify-2.5.4 → letify-2.6.0}/letify/store/backends/layout.py +0 -0
  120. {letify-2.5.4 → letify-2.6.0}/letify/store/backends/objects.py +0 -0
  121. {letify-2.5.4 → letify-2.6.0}/letify/store/cas.py +0 -0
  122. {letify-2.5.4 → letify-2.6.0}/letify/store/sendorder.py +0 -0
  123. {letify-2.5.4 → letify-2.6.0}/letify/store/volume.py +0 -0
  124. {letify-2.5.4 → letify-2.6.0}/letify/stubs.py +0 -0
  125. {letify-2.5.4 → letify-2.6.0}/letify/tools.py +0 -0
  126. {letify-2.5.4 → letify-2.6.0}/letify/transport/__init__.py +0 -0
  127. {letify-2.5.4 → letify-2.6.0}/letify/transport/agent.py +0 -0
  128. {letify-2.5.4 → letify-2.6.0}/letify/transport/announce.py +0 -0
  129. {letify-2.5.4 → letify-2.6.0}/letify/transport/link.py +0 -0
  130. {letify-2.5.4 → letify-2.6.0}/letify/transport/nat.py +0 -0
  131. {letify-2.5.4 → letify-2.6.0}/letify/transport/pipeline.py +0 -0
  132. {letify-2.5.4 → letify-2.6.0}/letify/transport/portmap.py +0 -0
  133. {letify-2.5.4 → letify-2.6.0}/letify/transport/probe.py +0 -0
  134. {letify-2.5.4 → letify-2.6.0}/letify/transport/quic.py +0 -0
  135. {letify-2.5.4 → letify-2.6.0}/letify/transport/rendezvous.py +0 -0
  136. {letify-2.5.4 → letify-2.6.0}/letify/transport/setup.py +0 -0
  137. {letify-2.5.4 → letify-2.6.0}/letify/transport/sshopts.py +0 -0
  138. {letify-2.5.4 → letify-2.6.0}/letify/transport/strategies.py +0 -0
  139. {letify-2.5.4 → letify-2.6.0}/letify-core/.gitignore +0 -0
  140. {letify-2.5.4 → letify-2.6.0}/letify-core/build.py +0 -0
  141. {letify-2.5.4 → letify-2.6.0}/letify-core/letify-agent/Cargo.toml +0 -0
  142. {letify-2.5.4 → letify-2.6.0}/letify-core/letify-agent/src/driver.rs +0 -0
  143. {letify-2.5.4 → letify-2.6.0}/letify-core/letify-agent/src/main.rs +0 -0
  144. {letify-2.5.4 → letify-2.6.0}/letify-core/letify-driver/Cargo.toml +0 -0
  145. {letify-2.5.4 → letify-2.6.0}/letify-core/letify-driver/src/client.rs +0 -0
  146. {letify-2.5.4 → letify-2.6.0}/letify-core/letify-driver/src/exports.rs +0 -0
  147. {letify-2.5.4 → letify-2.6.0}/letify-core/letify-driver/src/image.rs +0 -0
  148. {letify-2.5.4 → letify-2.6.0}/letify-core/letify-driver/src/lib.rs +0 -0
  149. {letify-2.5.4 → letify-2.6.0}/letify-core/letify-driver/src/status.rs +0 -0
  150. {letify-2.5.4 → letify-2.6.0}/letify-core/letify-driver/src/table.rs +0 -0
  151. {letify-2.5.4 → letify-2.6.0}/letify-core/letify-quic/Cargo.toml +0 -0
  152. {letify-2.5.4 → letify-2.6.0}/letify-core/letify-quic/src/main.rs +0 -0
  153. {letify-2.5.4 → letify-2.6.0}/letify-core/letify-wire/Cargo.toml +0 -0
  154. {letify-2.5.4 → letify-2.6.0}/letify-core/letify-wire/src/lib.rs +0 -0
  155. {letify-2.5.4 → letify-2.6.0}/letify-core/letify-wire/tests/throughput.rs +0 -0
  156. {letify-2.5.4 → letify-2.6.0}/letify-core/rust-toolchain.toml +0 -0
  157. {letify-2.5.4 → letify-2.6.0}/letify-ext/.vscodeignore +0 -0
  158. {letify-2.5.4 → letify-2.6.0}/letify-ext/LICENSE +0 -0
  159. {letify-2.5.4 → letify-2.6.0}/letify-ext/README.md +0 -0
  160. {letify-2.5.4 → letify-2.6.0}/letify-ext/media/icon.svg +0 -0
  161. {letify-2.5.4 → letify-2.6.0}/letify-ext/src/cli.ts +0 -0
  162. {letify-2.5.4 → letify-2.6.0}/letify-ext/src/extension.ts +0 -0
  163. {letify-2.5.4 → letify-2.6.0}/letify-ext/src/history.ts +0 -0
  164. {letify-2.5.4 → letify-2.6.0}/letify-ext/src/model.ts +0 -0
  165. {letify-2.5.4 → letify-2.6.0}/letify-ext/src/poll.ts +0 -0
  166. {letify-2.5.4 → letify-2.6.0}/letify-ext/src/render.ts +0 -0
  167. {letify-2.5.4 → letify-2.6.0}/letify-ext/test/cli.test.ts +0 -0
  168. {letify-2.5.4 → letify-2.6.0}/letify-ext/test/manifest.test.ts +0 -0
  169. {letify-2.5.4 → letify-2.6.0}/letify-ext/test/model.test.ts +0 -0
  170. {letify-2.5.4 → letify-2.6.0}/letify-ext/test/regressions.test.ts +0 -0
  171. {letify-2.5.4 → letify-2.6.0}/letify-ext/tsconfig.json +0 -0
  172. {letify-2.5.4 → letify-2.6.0}/letify_providers.py +0 -0
  173. {letify-2.5.4 → letify-2.6.0}/scripts/sync-main.sh +0 -0
  174. {letify-2.5.4 → letify-2.6.0}/scripts/version.py +0 -0
  175. {letify-2.5.4 → letify-2.6.0}/tests/conftest.py +0 -0
  176. {letify-2.5.4 → letify-2.6.0}/tests/fake_kaggle_adapter.py +0 -0
  177. {letify-2.5.4 → letify-2.6.0}/tests/fake_modal_adapter.py +0 -0
  178. {letify-2.5.4 → letify-2.6.0}/tests/test_cli_output.py +0 -0
  179. {letify-2.5.4 → letify-2.6.0}/tests/test_colab_keepalive.py +0 -0
  180. {letify-2.5.4 → letify-2.6.0}/tests/test_config.py +0 -0
  181. {letify-2.5.4 → letify-2.6.0}/tests/test_core.py +0 -0
  182. {letify-2.5.4 → letify-2.6.0}/tests/test_data.py +0 -0
  183. {letify-2.5.4 → letify-2.6.0}/tests/test_data_streaming.py +0 -0
  184. {letify-2.5.4 → letify-2.6.0}/tests/test_declare.py +0 -0
  185. {letify-2.5.4 → letify-2.6.0}/tests/test_device.py +0 -0
  186. {letify-2.5.4 → letify-2.6.0}/tests/test_distributed.py +0 -0
  187. {letify-2.5.4 → letify-2.6.0}/tests/test_editor_sessions.py +0 -0
  188. {letify-2.5.4 → letify-2.6.0}/tests/test_elice.py +0 -0
  189. {letify-2.5.4 → letify-2.6.0}/tests/test_frames.py +0 -0
  190. {letify-2.5.4 → letify-2.6.0}/tests/test_install.py +0 -0
  191. {letify-2.5.4 → letify-2.6.0}/tests/test_kaggle.py +0 -0
  192. {letify-2.5.4 → letify-2.6.0}/tests/test_kaggle_cookie.py +0 -0
  193. {letify-2.5.4 → letify-2.6.0}/tests/test_kaggle_link.py +0 -0
  194. {letify-2.5.4 → letify-2.6.0}/tests/test_link_log.py +0 -0
  195. {letify-2.5.4 → letify-2.6.0}/tests/test_notebook.py +0 -0
  196. {letify-2.5.4 → letify-2.6.0}/tests/test_paths.py +0 -0
  197. {letify-2.5.4 → letify-2.6.0}/tests/test_portmap.py +0 -0
  198. {letify-2.5.4 → letify-2.6.0}/tests/test_protocol.py +0 -0
  199. {letify-2.5.4 → letify-2.6.0}/tests/test_providers.py +0 -0
  200. {letify-2.5.4 → letify-2.6.0}/tests/test_quic.py +0 -0
  201. {letify-2.5.4 → letify-2.6.0}/tests/test_remoting.py +0 -0
  202. {letify-2.5.4 → letify-2.6.0}/tests/test_render.py +0 -0
  203. {letify-2.5.4 → letify-2.6.0}/tests/test_runtime.py +0 -0
  204. {letify-2.5.4 → letify-2.6.0}/tests/test_ship.py +0 -0
  205. {letify-2.5.4 → letify-2.6.0}/tests/test_store.py +0 -0
  206. {letify-2.5.4 → letify-2.6.0}/tests/test_strategies.py +0 -0
  207. {letify-2.5.4 → letify-2.6.0}/tests/test_stubs.py +0 -0
  208. {letify-2.5.4 → letify-2.6.0}/tests/test_transport.py +0 -0
  209. {letify-2.5.4 → letify-2.6.0}/tests/test_tunnel_setup.py +0 -0
  210. {letify-2.5.4 → letify-2.6.0}/tests/test_version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: letify
3
- Version: 2.5.4
3
+ Version: 2.6.0
4
4
  Summary: Declarations that become infrastructure.
5
5
  Project-URL: Homepage, https://github.com/DarkPyonix/letify
6
6
  Project-URL: Documentation, https://github.com/DarkPyonix/letify/tree/main/docs
@@ -316,7 +316,11 @@ Separately, and without needing a threshold at all, a refusal says how long ago
316
316
 
317
317
  The record therefore notes the process as well as the ref, `<username>/<slug>` and the pid separated by a tab, so a notebook a live process is still using is never deleted. A ref whose pid is still running on this machine is reported as in use and skipped. A ref with no pid, written by a letify older than this, cannot be checked and counts as an orphan, because the record is per machine and an older letify is not running.
318
318
 
319
- `letify stop` with no `--orphans` deletes nothing and says so, because a bare `stop` reads as ending everything. The command lists what it will delete and asks before deleting, `-y` answers yes, and `--alias` limits it to one account. Each ref is deleted with the same CLI call `stop` uses and dropped from the record only when the delete succeeds, so a failure leaves the ref to be tried again rather than losing track of it. The exit status is 1 when any delete failed.
319
+ `letify stop` with no `--orphans` deletes nothing and says why rather than only that: a session whose
320
+ creating process is still running ends when that process ends, because taking the session from under
321
+ it leaves it calling a runtime that is gone. So the line names the way out, `send SIGTERM to the
322
+ process that started it, which letify handles by closing its sessions`, and then what `--orphans` is
323
+ for. A bare `stop` otherwise reads as ending everything. The command lists what it will delete and asks before deleting, `-y` answers yes, and `--alias` limits it to one account. Each ref is deleted with the same CLI call `stop` uses and dropped from the record only when the delete succeeds, so a failure leaves the ref to be tried again rather than losing track of it. The exit status is 1 when any delete failed.
320
324
 
321
325
  **Deleting the notebook, through the CLI only.** `stop` deletes the notebook with the official CLI, `kaggle kernels delete -y <username>/<slug>`, authenticated by the account's API token in `KAGGLE_API_TOKEN`; deletion of a notebook created for a cookie-authenticated interactive session is confirmed on the live account. The cookie is never asked, not even as a fallback, because deleting a notebook is not part of running or ending the interactive session. The call never fails the runtime, because the accelerator quota is already released by cancelling the run before it is tried. When it cannot run at all, for want of a token, of uv, or of the slug Kaggle did not return, or when the command itself fails, `stop` prints one warning line naming the notebook by its slug or id, never its cookie, so the user can delete it by hand on kaggle.com. The chain that starts the session, all on `https://www.kaggle.com/api/i/`, authenticated by the cookie:
322
326
 
@@ -1125,6 +1129,25 @@ So a blob above `transfer_parallel_mib`, 64 MiB by default, on a link whose meas
1125
1129
 
1126
1130
  The round trip is a condition because the gain depends on it. A single stream is window limited only when the window takes a long time to fill, so on a short link it already carries everything the link will carry and the extra connections are pure cost. Measured to a lab server over forward SSH at 0.25 ms, 256 MiB went at 36.1 MiB/s on one stream and 33.5 MiB/s on four, about 7% worse, which is the cost of opening the channels and their worker processes. The link reports what the probe measured, and a link with no measurement, such as a provider fallback, is treated as short and keeps one stream. A provider whose channel is not a connection it can open more of keeps one stream too: Modal's sandbox reads and writes through its own API, and a Kaggle session that never punched is carried by a kernel bridge, so neither has a second connection to offer. A Kaggle session that did punch is reached over SSH and does: each transfer stream asks the link for its own SSH command, which lands on a local forwarding port of its own and so makes the link punch again.
1127
1131
 
1132
+ The connections carry different blobs at the same time as well as different ranges of one, because a
1133
+ call whose blobs are each under the split threshold would otherwise send them one after another and
1134
+ reach one connection's ceiling however many files it has. Measured on a Kaggle runtime at a 190 ms
1135
+ round trip, 20 files of 10 MB each went at 5.0 MiB/s on one connection, which is the 0.9 MiB window
1136
+ over that round trip. So the sender runs `transfer_streams` senders over the pool rather than one, and
1137
+ the condition for using more than one connection is the link's round trip alone: the size threshold
1138
+ decides whether one blob is split, not whether blobs may overlap. A blob the worker asked for by
1139
+ `data_want` still goes next, because the queue is reordered before a sender takes from it.
1140
+
1141
+ The connections are opened once per call and reused by every blob of it, not opened and closed per
1142
+ blob. A call that sends a hundred blobs over a punched link paid a hundred rounds of punching when
1143
+ each blob opened its own, which is both the cost the estimate below weighs and a hundred chances for
1144
+ a punch to be refused. They are closed when the call's transfer ends.
1145
+
1146
+ Reuse is what makes the cost worth paying, so the estimate charges it only while the connections are
1147
+ not yet open. The first blob large enough to want them pays the punch and weighs it; every blob after
1148
+ it finds them open and weighs nothing, so a blob under the size threshold still goes on one
1149
+ connection, but a call's second large blob is split whenever splitting is faster at all.
1150
+
1128
1151
  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.
1129
1152
 
1130
1153
  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 redials for each, so each transfer channel is its own kernel connection.
@@ -66,7 +66,7 @@ distributed: Final = Host.distributed
66
66
  # The enum class stays importable from letify.declare.instance for letify's own use.
67
67
  del Host
68
68
 
69
- __version__ = "2.5.4"
69
+ __version__ = "2.6.0"
70
70
 
71
71
 
72
72
  def fetch(tensor): # type: ignore[no-untyped-def]
@@ -755,8 +755,15 @@ def _dispatch(args: argparse.Namespace) -> int:
755
755
 
756
756
  if args.command == "stop":
757
757
  if not args.orphans:
758
- # A bare `stop` reads as ending everything, so it ends nothing.
759
- print("nothing was stopped. Pass --orphans to delete what a dead process left")
758
+ # Spec "Kaggle", stopping what a dead process left behind: a bare stop reads as
759
+ # ending everything, so it ends nothing, and says why rather than only that.
760
+ print(
761
+ "nothing was stopped. A session whose process is still running ends when "
762
+ "that process ends, because taking it away leaves that process calling a "
763
+ "runtime that is gone: send SIGTERM to the process that started it, which "
764
+ "letify handles by closing its sessions. Pass --orphans to delete what a "
765
+ "dead process left."
766
+ )
760
767
  return 0
761
768
  rows = let.orphans(args.alias)
762
769
  found = [(row["alias"], ref) for row in rows for ref in row.get("orphans", ())]
@@ -18,6 +18,7 @@ import sys
18
18
  import threading
19
19
  import time
20
20
  import tomllib
21
+ from collections.abc import Callable
21
22
  from dataclasses import dataclass, field
22
23
  from importlib import import_module
23
24
  from pathlib import Path
@@ -485,11 +486,16 @@ def _mib(size: int) -> str:
485
486
 
486
487
 
487
488
  class _Uploaded:
488
- """Bytes sent so far, drawn as a progress line when the upload is large."""
489
+ """Bytes sent so far, drawn as a progress line when the upload is large.
490
+
491
+ Several senders advance this together once blobs overlap, spec "Several
492
+ connections at once", so ``base`` is kept under a lock rather than left to the GIL.
493
+ """
489
494
 
490
495
  def __init__(self, files: int, total: int) -> None:
491
496
  self.base = 0
492
497
  self.progress = None
498
+ self._lock = threading.Lock()
493
499
  if total >= PROGRESS_THRESHOLD:
494
500
  from ..install import _Progress
495
501
 
@@ -497,11 +503,14 @@ class _Uploaded:
497
503
 
498
504
  def update(self, done: int) -> None:
499
505
  if self.progress is not None:
500
- self.progress.update(self.base + done)
506
+ with self._lock:
507
+ self.progress.update(self.base + done)
501
508
 
502
509
  def advance(self, size: int) -> None:
503
- self.base += size
504
- self.update(0)
510
+ with self._lock:
511
+ self.base += size
512
+ if self.progress is not None:
513
+ self.progress.update(self.base)
505
514
 
506
515
  def finish(self) -> None:
507
516
  if self.progress is not None:
@@ -628,6 +637,9 @@ class Stream:
628
637
  self._token: str | None = None
629
638
  #: The one stream every piece of this call travels on, opened at the first piece.
630
639
  self._pipeline: Any = None
640
+ #: The transfer connections of this call, opened lazily at the first large blob
641
+ #: and reused by every one after it. Spec "Several connections at once".
642
+ self._channels: _Channels | None = None
631
643
 
632
644
  # -- before the call -------------------------------------------------------
633
645
 
@@ -638,9 +650,31 @@ class Stream:
638
650
  started = time.monotonic()
639
651
  total = sum(self.plan.weight(digest)[1] for digest in wave)
640
652
  meter = _Uploaded(len(wave), total)
641
- for digest in wave:
642
- self._send(digest, meter)
643
- meter.advance(self.plan.sizes.get(digest, 0))
653
+ senders = self._senders(len(wave))
654
+ if senders <= 1:
655
+ for digest in wave:
656
+ self._send(digest, meter)
657
+ meter.advance(self.plan.sizes.get(digest, 0))
658
+ else:
659
+ # Spec "Several connections at once": the condition for using more than one
660
+ # connection is the link's round trip alone, not the size threshold, so the
661
+ # first wave is sent with several senders too whenever the link allows it.
662
+ remaining = collections.deque(wave)
663
+ pop_lock = threading.Lock()
664
+
665
+ def pop() -> str | None:
666
+ with pop_lock:
667
+ return remaining.popleft() if remaining else None
668
+
669
+ def worker(stop: threading.Event) -> None:
670
+ while not stop.is_set():
671
+ digest = pop()
672
+ if digest is None:
673
+ return
674
+ self._send(digest, meter, parallel=True)
675
+ meter.advance(self.plan.sizes.get(digest, 0))
676
+
677
+ self._send_parallel(senders, worker)
644
678
  # Every piece answered, so the wave is placed when the call goes out.
645
679
  self._flush()
646
680
  meter.finish()
@@ -659,9 +693,26 @@ class Stream:
659
693
  def _run(self) -> None:
660
694
  started = time.monotonic()
661
695
  try:
662
- while not self._cancelled.is_set():
663
- if not self._pump():
664
- break
696
+ with self._lock:
697
+ left = len(self._queue)
698
+ senders = self._senders(left)
699
+ if senders <= 1:
700
+ while not self._cancelled.is_set():
701
+ if not self._pump():
702
+ break
703
+ else:
704
+ # Spec "Several connections at once": several senders over the pool,
705
+ # each reordering and taking from the same queue the single sender
706
+ # would have used, rather than one sender on the call's own stream.
707
+ def worker(stop: threading.Event) -> None:
708
+ while not self._cancelled.is_set() and not stop.is_set():
709
+ self.reorder()
710
+ digest = self._take()
711
+ if digest is None:
712
+ return
713
+ self._send(digest, None, parallel=True)
714
+
715
+ self._send_parallel(senders, worker)
665
716
  # The pieces still in flight are answered before the stream is released.
666
717
  self._flush()
667
718
  except Exception as exc:
@@ -741,6 +792,8 @@ class Stream:
741
792
  if (thread is None or not thread.is_alive()) and self._pipeline is not None:
742
793
  self._pipeline.close()
743
794
  self._pipeline = None
795
+ if self._channels is not None:
796
+ self._channels.close()
744
797
  self._drop_partials()
745
798
 
746
799
  def _drop_partials(self) -> None:
@@ -766,6 +819,14 @@ class Stream:
766
819
  self._pipeline = self.runtime.pipeline(window=WINDOW, timeout=3600)
767
820
  return self._pipeline
768
821
 
822
+ def _pool(self) -> _Channels:
823
+ """The call's transfer connections, opened lazily and reused by every blob."""
824
+ with self._lock:
825
+ if self._channels is None:
826
+ limit = int(getattr(self.runtime.provider, "transfer_streams", STREAMS) or 1)
827
+ self._channels = _Channels(self.runtime, limit)
828
+ return self._channels
829
+
769
830
  def _flush(self) -> None:
770
831
  """Read every outstanding reply, so a piece the worker refused fails here."""
771
832
  if self._pipeline is None:
@@ -775,9 +836,79 @@ class Stream:
775
836
  except RemoteError as exc:
776
837
  raise RuntimeFailure(f"{self.runtime.name}: data_put failed: {exc}") from exc
777
838
 
839
+ # -- several senders at once -------------------------------------------------
840
+
841
+ def _senders(self, available: int) -> int:
842
+ """How many blobs this call may have in flight at once, never above ``available``.
843
+
844
+ Spec "Several connections at once": the connections carry different blobs at the
845
+ same time as well as different ranges of one, and the condition for more than one
846
+ is the link's round trip alone. The size threshold decides whether one blob is
847
+ split, not whether blobs may overlap.
848
+ """
849
+ provider = getattr(self.runtime, "provider", None)
850
+ if not _has_transfer_channel(provider):
851
+ return 1
852
+ rtt = _measured_rtt(provider, self.runtime)
853
+ if rtt is None or rtt < PARALLEL_RTT_MS:
854
+ return 1
855
+ limit = int(getattr(provider, "transfer_streams", STREAMS) or 1)
856
+ return max(1, min(limit, available))
857
+
858
+ def _send_parallel(self, count: int, worker: Callable[[threading.Event], None]) -> None:
859
+ """Run ``worker(stop)`` on ``count`` threads; the first failure stops the rest.
860
+
861
+ One worker failing must not leave the others sending into a dead transfer, so
862
+ ``stop`` is set as soon as any of them raises, and the first failure is what
863
+ reaches the caller once every thread has joined.
864
+ """
865
+ failures: list[BaseException] = []
866
+ failures_lock = threading.Lock()
867
+ stop = threading.Event()
868
+
869
+ def guarded() -> None:
870
+ try:
871
+ worker(stop)
872
+ except BaseException as exc:
873
+ with failures_lock:
874
+ failures.append(exc)
875
+ stop.set()
876
+
877
+ threads = [
878
+ threading.Thread(target=guarded, name=f"letify-data-{index}", daemon=True)
879
+ for index in range(count)
880
+ ]
881
+ for thread in threads:
882
+ thread.start()
883
+ for thread in threads:
884
+ thread.join()
885
+ if failures:
886
+ raise failures[0]
887
+
888
+ def _send_alone(self, action: Callable[[Any], None]) -> None:
889
+ """Run ``action(pipeline)`` on a pooled channel held for the call it makes.
890
+
891
+ Used when several senders share the call's pool: ``self._link()`` carries only
892
+ one sender's ordering at a time, so a blob that needs a pipeline for more than
893
+ one request borrows a channel of its own for as long as that takes. Spec
894
+ "Several connections at once".
895
+ """
896
+ channel = self._pool().take()
897
+ ok = False
898
+ try:
899
+ pipeline = channel.pipeline(window=WINDOW, timeout=3600)
900
+ action(pipeline)
901
+ pipeline.drain()
902
+ ok = True
903
+ finally:
904
+ if ok:
905
+ self._pool().give(channel)
906
+ else:
907
+ self._pool().drop(channel)
908
+
778
909
  # -- one blob ---------------------------------------------------------------
779
910
 
780
- def _send(self, digest: str, meter: _Uploaded | None) -> None:
911
+ def _send(self, digest: str, meter: _Uploaded | None, *, parallel: bool = False) -> None:
781
912
  with self._lock:
782
913
  if digest in self._sent or self._cancelled.is_set():
783
914
  return
@@ -787,10 +918,48 @@ class Stream:
787
918
  bucket = self.plan.bucket
788
919
  uploaded = True
789
920
  if bucket is None:
790
- streams = _streams_for(self.runtime, size)
791
- if streams > 1:
921
+ # The cost of opening the connections is weighed only while the pool is cold:
922
+ # the first blob large enough to want them pays the punch, every one after it
923
+ # finds them open. Spec "Several connections at once".
924
+ warm = self._channels is not None and self._channels.warm
925
+ streams = _streams_for(self.runtime, size, warm=warm)
926
+ if streams > 1 and parallel:
927
+ # self._link() carries only one sender's ordering at a time, so the
928
+ # data_begin/data_seal pair this blob needs borrows a pooled channel of
929
+ # its own instead, the same way the ranges already do.
930
+ self._send_alone(
931
+ lambda pipeline: _put_parallel(
932
+ self.runtime,
933
+ pipeline,
934
+ self.blobs,
935
+ digest,
936
+ local,
937
+ size,
938
+ meter,
939
+ streams,
940
+ self._pool(),
941
+ )
942
+ )
943
+ elif streams > 1:
792
944
  _put_parallel(
793
- self.runtime, self._link(), self.blobs, digest, local, size, meter, streams
945
+ self.runtime,
946
+ self._link(),
947
+ self.blobs,
948
+ digest,
949
+ local,
950
+ size,
951
+ meter,
952
+ streams,
953
+ self._pool(),
954
+ )
955
+ elif parallel:
956
+ # Not split, but two senders cannot share self._link()'s ordering
957
+ # either, so this blob takes a pooled channel of its own for its
958
+ # whole send. Spec "Several connections at once".
959
+ self._send_alone(
960
+ lambda pipeline: _put(
961
+ self.runtime, pipeline, self.blobs, digest, local, size, meter
962
+ )
794
963
  )
795
964
  else:
796
965
  _put(self.runtime, self._link(), self.blobs, digest, local, size, meter)
@@ -816,12 +985,12 @@ class Stream:
816
985
  files, total = self.plan.weight(digest)
817
986
  with self._lock:
818
987
  self.done.add(digest)
819
- if self._thread is None:
820
- self.before_files += files
821
- self.before_bytes += total
822
- else:
823
- self.during_files += files
824
- self.during_bytes += total
988
+ if self._thread is None:
989
+ self.before_files += files
990
+ self.before_bytes += total
991
+ else:
992
+ self.during_files += files
993
+ self.during_bytes += total
825
994
 
826
995
  @property
827
996
  def sent(self) -> int:
@@ -903,37 +1072,137 @@ def streams_for(
903
1072
  return count
904
1073
 
905
1074
 
906
- def _streams_for(runtime: Any, size: int) -> int:
907
- """``streams_for`` with the account's settings and the link's measured round trip."""
908
- provider = getattr(runtime, "provider", None)
909
- # On the class, not the instance: a provider's __getattr__ answers accelerator names
910
- # and raises UnknownInstance for anything else, which hasattr does not catch.
911
- if not hasattr(type(provider), "transfer_channel"):
912
- # A provider whose channel is not a connection it can open more of, such as
913
- # Modal's sandbox pipes or Kaggle's kernel bridge. Spec "Several connections at
914
- # once".
915
- return 1
916
- rtt = None
917
- try:
918
- link = provider.link(runtime) if provider is not None else None
919
- rtt = getattr(link, "rtt_ms", None)
920
- except Exception:
921
- rtt = None
922
- rate = None
1075
+ def _has_transfer_channel(provider: Any) -> bool:
1076
+ """Whether the provider's class can open another transfer connection.
1077
+
1078
+ On the class, not the instance: a provider's ``__getattr__`` answers accelerator
1079
+ names and raises ``UnknownInstance`` for anything else, which ``hasattr`` does not
1080
+ catch. A provider whose channel is not a connection it can open more of, such as
1081
+ Modal's sandbox pipes or Kaggle's kernel bridge, answers False. Spec "Several
1082
+ connections at once".
1083
+ """
1084
+ return hasattr(type(provider), "transfer_channel")
1085
+
1086
+
1087
+ def _measured_link(provider: Any, runtime: Any) -> Any:
1088
+ """The provider's link for ``runtime``, or None where asking for it fails."""
923
1089
  try:
924
- rate = getattr(link, "upload_bps", None)
1090
+ return provider.link(runtime) if provider is not None else None
925
1091
  except Exception:
926
- rate = None
1092
+ return None
1093
+
1094
+
1095
+ def _measured_rtt(provider: Any, runtime: Any) -> float | None:
1096
+ """The link's measured round trip in milliseconds, or None with no measurement."""
1097
+ return getattr(_measured_link(provider, runtime), "rtt_ms", None)
1098
+
1099
+
1100
+ def _streams_for(runtime: Any, size: int, *, warm: bool = False) -> int:
1101
+ """``streams_for`` with the account's settings and the link's measured round trip.
1102
+
1103
+ ``warm`` is True once the call's transfer connections are already open, so there is
1104
+ nothing left to pay for opening them. Spec "Several connections at once": reuse is
1105
+ what makes the cost worth paying, so the estimate charges it only while the pool is
1106
+ cold.
1107
+ """
1108
+ provider = getattr(runtime, "provider", None)
1109
+ if not _has_transfer_channel(provider):
1110
+ return 1
1111
+ link = _measured_link(provider, runtime)
1112
+ rtt = getattr(link, "rtt_ms", None)
1113
+ rate = getattr(link, "upload_bps", None)
1114
+ cost_s = 0.0 if warm else float(getattr(provider, "transfer_connection_cost_s", 0.0) or 0.0)
927
1115
  return streams_for(
928
1116
  streams=int(getattr(provider, "transfer_streams", STREAMS) or 1),
929
1117
  threshold_mib=int(getattr(provider, "transfer_parallel_mib", PARALLEL_MIB) or 0),
930
1118
  rate_bps=rate,
931
- cost_s=float(getattr(provider, "transfer_connection_cost_s", 0.0) or 0.0),
1119
+ cost_s=cost_s,
932
1120
  rtt_ms=rtt,
933
1121
  size=size,
934
1122
  )
935
1123
 
936
1124
 
1125
+ class _Channels:
1126
+ """The transfer connections of one call, opened once and reused by every blob of it.
1127
+
1128
+ Spec "Several connections at once": the connections are opened once per call and
1129
+ reused by every blob of it, not opened and closed per blob, which is both the cost the
1130
+ estimate weighs and a chance for a punch to be refused each time it happens. Used from
1131
+ several threads at once, one per range of a parallel put.
1132
+ """
1133
+
1134
+ def __init__(self, runtime: Any, limit: int) -> None:
1135
+ self._runtime = runtime
1136
+ self._limit = max(1, int(limit))
1137
+ self._free: list[Any] = []
1138
+ self._opened = 0
1139
+ self._closed = False
1140
+ self._lock = threading.Lock()
1141
+ self._condition = threading.Condition(self._lock)
1142
+
1143
+ @property
1144
+ def warm(self) -> bool:
1145
+ """True once at least one channel of this pool has been opened."""
1146
+ with self._lock:
1147
+ return self._opened > 0
1148
+
1149
+ def take(self) -> Any:
1150
+ """A free channel, a newly opened one, or the next one ``give``n back.
1151
+
1152
+ Raises RuntimeFailure, naming the runtime, if the pool is already closed. Never
1153
+ hangs past a ``close()`` that arrives while this call is waiting.
1154
+ """
1155
+ with self._condition:
1156
+ while True:
1157
+ if self._closed:
1158
+ raise RuntimeFailure(
1159
+ f"{self._runtime.name}: the transfer pool is closed"
1160
+ )
1161
+ if self._free:
1162
+ return self._free.pop()
1163
+ if self._opened < self._limit:
1164
+ self._opened += 1
1165
+ break
1166
+ self._condition.wait()
1167
+ try:
1168
+ return self._runtime.provider.transfer_channel(self._runtime)
1169
+ except BaseException:
1170
+ # The slot was reserved before opening, and must be released on failure so a
1171
+ # waiter is not left blocked on a channel that never came to exist.
1172
+ with self._condition:
1173
+ self._opened -= 1
1174
+ self._condition.notify_all()
1175
+ raise
1176
+
1177
+ def give(self, channel: Any) -> None:
1178
+ """Return a channel to the free list, for the next ``take()`` to reuse."""
1179
+ with self._condition:
1180
+ if self._closed:
1181
+ closed_already = True
1182
+ else:
1183
+ self._free.append(channel)
1184
+ self._condition.notify_all()
1185
+ closed_already = False
1186
+ if closed_already:
1187
+ channel.close()
1188
+
1189
+ def drop(self, channel: Any) -> None:
1190
+ """A channel that failed: close it and free its slot for a replacement."""
1191
+ channel.close()
1192
+ with self._condition:
1193
+ self._opened = max(0, self._opened - 1)
1194
+ self._condition.notify_all()
1195
+
1196
+ def close(self) -> None:
1197
+ """Close every channel this pool opened, and refuse any ``take()`` after it."""
1198
+ with self._condition:
1199
+ self._closed = True
1200
+ free, self._free = self._free, []
1201
+ self._condition.notify_all()
1202
+ for channel in free:
1203
+ channel.close()
1204
+
1205
+
937
1206
  def _put_parallel(
938
1207
  runtime: Any,
939
1208
  pipeline: Any,
@@ -943,12 +1212,15 @@ def _put_parallel(
943
1212
  size: int,
944
1213
  meter: _Uploaded | None,
945
1214
  streams: int,
1215
+ channels: _Channels,
946
1216
  ) -> None:
947
1217
  """Send one file over several connections, each carrying one contiguous range.
948
1218
 
949
1219
  Spec "Several connections at once". The call's own stream makes the file and seals it,
950
1220
  so a failure there is reported where every other data failure is. The ranges go out on
951
- channels of their own, each its own kernel connection.
1221
+ the call's pooled transfer connections, each its own kernel connection, taken from
1222
+ ``channels`` and given back when the range finishes so the next blob of this call
1223
+ reuses it rather than opening its own.
952
1224
  """
953
1225
  split = ranges(size, streams)
954
1226
  try:
@@ -962,9 +1234,14 @@ def _put_parallel(
962
1234
  lock = threading.Lock()
963
1235
 
964
1236
  def carry(index: int, offset: int, length: int) -> None:
965
- channel = None
966
1237
  try:
967
- channel = runtime.provider.transfer_channel(runtime)
1238
+ channel = channels.take()
1239
+ except BaseException as exc:
1240
+ with lock:
1241
+ failures.append(exc)
1242
+ return
1243
+ ok = False
1244
+ try:
968
1245
  stream = channel.pipeline(window=WINDOW, timeout=3600)
969
1246
  with open(local, "rb") as handle:
970
1247
  handle.seek(offset)
@@ -991,12 +1268,19 @@ def _put_parallel(
991
1268
  meter.update(sum(sent))
992
1269
  stream.drain()
993
1270
  stream.close()
1271
+ ok = True
994
1272
  except BaseException as exc:
995
1273
  with lock:
996
1274
  failures.append(exc)
997
1275
  finally:
998
- if channel is not None:
999
- channel.close()
1276
+ # A range that finished cleanly gives the channel back for the next blob of
1277
+ # this call to reuse. One that failed partway cannot be trusted to still be
1278
+ # at a clean protocol boundary, so it is closed instead and the pool opens a
1279
+ # replacement next time one is needed.
1280
+ if ok:
1281
+ channels.give(channel)
1282
+ else:
1283
+ channels.drop(channel)
1000
1284
 
1001
1285
  workers = [
1002
1286
  threading.Thread(target=carry, args=(index, offset, length), daemon=True)
@@ -1080,6 +1364,7 @@ class _Downloaded(_Uploaded):
1080
1364
  def __init__(self, files: int, total: int) -> None:
1081
1365
  self.base = 0
1082
1366
  self.progress = None
1367
+ self._lock = threading.Lock()
1083
1368
  if total >= PROGRESS_THRESHOLD:
1084
1369
  from ..install import _Progress
1085
1370
 
@@ -138,7 +138,7 @@ dependencies = [
138
138
 
139
139
  [[package]]
140
140
  name = "letify-agent"
141
- version = "2.5.4"
141
+ version = "2.6.0"
142
142
  dependencies = [
143
143
  "letify-wire",
144
144
  "libloading",
@@ -146,7 +146,7 @@ dependencies = [
146
146
 
147
147
  [[package]]
148
148
  name = "letify-driver"
149
- version = "2.5.4"
149
+ version = "2.6.0"
150
150
  dependencies = [
151
151
  "letify-wire",
152
152
  ]
@@ -164,7 +164,7 @@ dependencies = [
164
164
 
165
165
  [[package]]
166
166
  name = "letify-wire"
167
- version = "2.5.4"
167
+ version = "2.6.0"
168
168
 
169
169
  [[package]]
170
170
  name = "libc"
@@ -8,7 +8,7 @@ resolver = "3"
8
8
  members = ["letify-wire", "letify-driver", "letify-agent", "letify-quic"]
9
9
 
10
10
  [workspace.package]
11
- version = "2.5.4"
11
+ version = "2.6.0"
12
12
  edition = "2024"
13
13
  license = "MIT"
14
14
  repository = "https://github.com/DarkPyonix/letify"
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "letify-status",
3
- "version": "2.5.4",
3
+ "version": "2.6.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "letify-status",
9
- "version": "2.5.4",
9
+ "version": "2.6.0",
10
10
  "license": "SEE LICENSE IN LICENSE",
11
11
  "devDependencies": {
12
12
  "@types/node": "^18.19.0",
@@ -2,7 +2,7 @@
2
2
  "name": "letify-status",
3
3
  "displayName": "letify Status",
4
4
  "description": "letify quota and GPU activity in the status bar, read from the letify CLI.",
5
- "version": "2.5.4",
5
+ "version": "2.6.0",
6
6
  "publisher": "darkpyonix",
7
7
  "license": "SEE LICENSE IN LICENSE",
8
8
  "repository": {
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "letify"
3
- version = "2.5.4"
3
+ version = "2.6.0"
4
4
  description = "Declarations that become infrastructure."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"