letify 2.8.3__tar.gz → 2.8.4__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 (214) hide show
  1. {letify-2.8.3 → letify-2.8.4}/PKG-INFO +1 -1
  2. {letify-2.8.3 → letify-2.8.4}/docs/SPEC.md +6 -2
  3. {letify-2.8.3 → letify-2.8.4}/letify/__init__.py +1 -1
  4. {letify-2.8.3 → letify-2.8.4}/letify/protocol/codec.py +28 -6
  5. {letify-2.8.3 → letify-2.8.4}/letify/protocol/worker.py +42 -24
  6. {letify-2.8.3 → letify-2.8.4}/letify/runtime/session.py +16 -8
  7. {letify-2.8.3 → letify-2.8.4}/letify/store/handoff.py +7 -1
  8. {letify-2.8.3 → letify-2.8.4}/letify/store/pathdata.py +9 -7
  9. {letify-2.8.3 → letify-2.8.4}/letify/transport/nat.py +30 -1
  10. {letify-2.8.3 → letify-2.8.4}/letify-core/Cargo.lock +3 -3
  11. {letify-2.8.3 → letify-2.8.4}/letify-core/Cargo.toml +1 -1
  12. {letify-2.8.3 → letify-2.8.4}/letify-ext/package-lock.json +2 -2
  13. {letify-2.8.3 → letify-2.8.4}/letify-ext/package.json +1 -1
  14. {letify-2.8.3 → letify-2.8.4}/pyproject.toml +1 -1
  15. {letify-2.8.3 → letify-2.8.4}/tests/conftest.py +17 -0
  16. {letify-2.8.3 → letify-2.8.4}/tests/test_protocol.py +3 -1
  17. {letify-2.8.3 → letify-2.8.4}/tests/test_runtime.py +34 -0
  18. {letify-2.8.3 → letify-2.8.4}/tests/test_store.py +54 -0
  19. {letify-2.8.3 → letify-2.8.4}/tests/test_strategies.py +32 -2
  20. {letify-2.8.3 → letify-2.8.4}/.github/workflows/ci.yml +0 -0
  21. {letify-2.8.3 → letify-2.8.4}/.github/workflows/docs.yml +0 -0
  22. {letify-2.8.3 → letify-2.8.4}/.github/workflows/publish.yml +0 -0
  23. {letify-2.8.3 → letify-2.8.4}/.gitignore +0 -0
  24. {letify-2.8.3 → letify-2.8.4}/.researchtree +0 -0
  25. {letify-2.8.3 → letify-2.8.4}/CLAUDE.md +0 -0
  26. {letify-2.8.3 → letify-2.8.4}/LICENSE +0 -0
  27. {letify-2.8.3 → letify-2.8.4}/PROJECT.md +0 -0
  28. {letify-2.8.3 → letify-2.8.4}/README.md +0 -0
  29. {letify-2.8.3 → letify-2.8.4}/docs/COMPONENT.md +0 -0
  30. {letify-2.8.3 → letify-2.8.4}/docs/INTENT.md +0 -0
  31. {letify-2.8.3 → letify-2.8.4}/docs/NETWORK.md +0 -0
  32. {letify-2.8.3 → letify-2.8.4}/docs/guide/01-getting-started.md +0 -0
  33. {letify-2.8.3 → letify-2.8.4}/docs/guide/02-providers.md +0 -0
  34. {letify-2.8.3 → letify-2.8.4}/docs/guide/03-execution-modes.md +0 -0
  35. {letify-2.8.3 → letify-2.8.4}/docs/guide/04-environments-and-data.md +0 -0
  36. {letify-2.8.3 → letify-2.8.4}/docs/guide/05-concurrency.md +0 -0
  37. {letify-2.8.3 → letify-2.8.4}/docs/guide/06-cost.md +0 -0
  38. {letify-2.8.3 → letify-2.8.4}/docs/guide/07-troubleshooting.md +0 -0
  39. {letify-2.8.3 → letify-2.8.4}/docs/guide/08-releasing.md +0 -0
  40. {letify-2.8.3 → letify-2.8.4}/docs/guide/README.md +0 -0
  41. {letify-2.8.3 → letify-2.8.4}/docs/guide/ko/01-/354/213/234/354/236/221/355/225/230/352/270/260.md" +0 -0
  42. {letify-2.8.3 → letify-2.8.4}/docs/guide/ko/02-/355/224/204/353/241/234/353/260/224/354/235/264/353/215/224.md" +0 -0
  43. {letify-2.8.3 → letify-2.8.4}/docs/guide/ko/03-/354/213/244/355/226/211-/353/260/251/354/213/235.md" +0 -0
  44. {letify-2.8.3 → letify-2.8.4}/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
  45. {letify-2.8.3 → letify-2.8.4}/docs/guide/ko/05-/353/217/231/354/213/234-/354/213/244/355/226/211.md" +0 -0
  46. {letify-2.8.3 → letify-2.8.4}/docs/guide/ko/06-/353/271/204/354/232/251.md" +0 -0
  47. {letify-2.8.3 → letify-2.8.4}/docs/guide/ko/07-/353/254/270/354/240/234-/355/225/264/352/262/260.md" +0 -0
  48. {letify-2.8.3 → letify-2.8.4}/docs/guide/ko/08-/353/246/264/353/246/254/354/212/244.md" +0 -0
  49. {letify-2.8.3 → letify-2.8.4}/docs/guide/ko/README.md +0 -0
  50. {letify-2.8.3 → letify-2.8.4}/docs/locales/README_ko.md +0 -0
  51. {letify-2.8.3 → letify-2.8.4}/docs/mkdocs.yml +0 -0
  52. {letify-2.8.3 → letify-2.8.4}/examples/README.md +0 -0
  53. {letify-2.8.3 → letify-2.8.4}/examples/nvfp4-lora/.letify.example +0 -0
  54. {letify-2.8.3 → letify-2.8.4}/examples/nvfp4-lora/00_smoke.py +0 -0
  55. {letify-2.8.3 → letify-2.8.4}/examples/nvfp4-lora/01_sweep.py +0 -0
  56. {letify-2.8.3 → letify-2.8.4}/examples/nvfp4-lora/02_resume.py +0 -0
  57. {letify-2.8.3 → letify-2.8.4}/examples/nvfp4-lora/03_watch.py +0 -0
  58. {letify-2.8.3 → letify-2.8.4}/examples/nvfp4-lora/README.md +0 -0
  59. {letify-2.8.3 → letify-2.8.4}/examples/nvfp4-lora/README_ko.md +0 -0
  60. {letify-2.8.3 → letify-2.8.4}/examples/nvfp4-lora/common.py +0 -0
  61. {letify-2.8.3 → letify-2.8.4}/examples/nvfp4-lora/recipe.py +0 -0
  62. {letify-2.8.3 → letify-2.8.4}/letify/cli.py +0 -0
  63. {letify-2.8.3 → letify-2.8.4}/letify/config/__init__.py +0 -0
  64. {letify-2.8.3 → letify-2.8.4}/letify/config/inventory.py +0 -0
  65. {letify-2.8.3 → letify-2.8.4}/letify/config/login.py +0 -0
  66. {letify-2.8.3 → letify-2.8.4}/letify/config/schema.py +0 -0
  67. {letify-2.8.3 → letify-2.8.4}/letify/config/secrets.py +0 -0
  68. {letify-2.8.3 → letify-2.8.4}/letify/config/writer.py +0 -0
  69. {letify-2.8.3 → letify-2.8.4}/letify/declare/__init__.py +0 -0
  70. {letify-2.8.3 → letify-2.8.4}/letify/declare/cache.py +0 -0
  71. {letify-2.8.3 → letify-2.8.4}/letify/declare/env.py +0 -0
  72. {letify-2.8.3 → letify-2.8.4}/letify/declare/function.py +0 -0
  73. {letify-2.8.3 → letify-2.8.4}/letify/declare/instance.py +0 -0
  74. {letify-2.8.3 → letify-2.8.4}/letify/declare/reduce.py +0 -0
  75. {letify-2.8.3 → letify-2.8.4}/letify/declare/replicas.py +0 -0
  76. {letify-2.8.3 → letify-2.8.4}/letify/errors.py +0 -0
  77. {letify-2.8.3 → letify-2.8.4}/letify/install.py +0 -0
  78. {letify-2.8.3 → letify-2.8.4}/letify/launcher.py +0 -0
  79. {letify-2.8.3 → letify-2.8.4}/letify/paths.py +0 -0
  80. {letify-2.8.3 → letify-2.8.4}/letify/protocol/__init__.py +0 -0
  81. {letify-2.8.3 → letify-2.8.4}/letify/protocol/driver.py +0 -0
  82. {letify-2.8.3 → letify-2.8.4}/letify/protocol/handle.py +0 -0
  83. {letify-2.8.3 → letify-2.8.4}/letify/protocol/wire.py +0 -0
  84. {letify-2.8.3 → letify-2.8.4}/letify/providers/__init__.py +0 -0
  85. {letify-2.8.3 → letify-2.8.4}/letify/providers/base.py +0 -0
  86. {letify-2.8.3 → letify-2.8.4}/letify/providers/colab.py +0 -0
  87. {letify-2.8.3 → letify-2.8.4}/letify/providers/colab_files.py +0 -0
  88. {letify-2.8.3 → letify-2.8.4}/letify/providers/colab_keepalive.py +0 -0
  89. {letify-2.8.3 → letify-2.8.4}/letify/providers/elice.py +0 -0
  90. {letify-2.8.3 → letify-2.8.4}/letify/providers/kaggle.py +0 -0
  91. {letify-2.8.3 → letify-2.8.4}/letify/providers/kaggle_adapter.py +0 -0
  92. {letify-2.8.3 → letify-2.8.4}/letify/providers/local.py +0 -0
  93. {letify-2.8.3 → letify-2.8.4}/letify/providers/modal.py +0 -0
  94. {letify-2.8.3 → letify-2.8.4}/letify/providers/modal_adapter.py +0 -0
  95. {letify-2.8.3 → letify-2.8.4}/letify/providers/naming.py +0 -0
  96. {letify-2.8.3 → letify-2.8.4}/letify/providers/shell.py +0 -0
  97. {letify-2.8.3 → letify-2.8.4}/letify/providers/tunnel.py +0 -0
  98. {letify-2.8.3 → letify-2.8.4}/letify/providers/usage.py +0 -0
  99. {letify-2.8.3 → letify-2.8.4}/letify/remoting/__init__.py +0 -0
  100. {letify-2.8.3 → letify-2.8.4}/letify/remoting/capability.py +0 -0
  101. {letify-2.8.3 → letify-2.8.4}/letify/remoting/device/__init__.py +0 -0
  102. {letify-2.8.3 → letify-2.8.4}/letify/remoting/device/client.py +0 -0
  103. {letify-2.8.3 → letify-2.8.4}/letify/remoting/device/cuda.py +0 -0
  104. {letify-2.8.3 → letify-2.8.4}/letify/remoting/device/executor.py +0 -0
  105. {letify-2.8.3 → letify-2.8.4}/letify/remoting/device/frames.py +0 -0
  106. {letify-2.8.3 → letify-2.8.4}/letify/remoting/device/guard.py +0 -0
  107. {letify-2.8.3 → letify-2.8.4}/letify/remoting/device/tensor.py +0 -0
  108. {letify-2.8.3 → letify-2.8.4}/letify/remoting/device/trace.py +0 -0
  109. {letify-2.8.3 → letify-2.8.4}/letify/remoting/device/value.py +0 -0
  110. {letify-2.8.3 → letify-2.8.4}/letify/remoting/loader.py +0 -0
  111. {letify-2.8.3 → letify-2.8.4}/letify/remoting/probe.py +0 -0
  112. {letify-2.8.3 → letify-2.8.4}/letify/render.py +0 -0
  113. {letify-2.8.3 → letify-2.8.4}/letify/runtime/__init__.py +0 -0
  114. {letify-2.8.3 → letify-2.8.4}/letify/runtime/bootstrap.py +0 -0
  115. {letify-2.8.3 → letify-2.8.4}/letify/runtime/broker.py +0 -0
  116. {letify-2.8.3 → letify-2.8.4}/letify/runtime/channel.py +0 -0
  117. {letify-2.8.3 → letify-2.8.4}/letify/runtime/lease.py +0 -0
  118. {letify-2.8.3 → letify-2.8.4}/letify/runtime/pool.py +0 -0
  119. {letify-2.8.3 → letify-2.8.4}/letify/runtime/telemetry.py +0 -0
  120. {letify-2.8.3 → letify-2.8.4}/letify/store/__init__.py +0 -0
  121. {letify-2.8.3 → letify-2.8.4}/letify/store/backends/__init__.py +0 -0
  122. {letify-2.8.3 → letify-2.8.4}/letify/store/backends/drive.py +0 -0
  123. {letify-2.8.3 → letify-2.8.4}/letify/store/backends/filesystem.py +0 -0
  124. {letify-2.8.3 → letify-2.8.4}/letify/store/backends/google_auth.py +0 -0
  125. {letify-2.8.3 → letify-2.8.4}/letify/store/backends/layout.py +0 -0
  126. {letify-2.8.3 → letify-2.8.4}/letify/store/backends/objects.py +0 -0
  127. {letify-2.8.3 → letify-2.8.4}/letify/store/cas.py +0 -0
  128. {letify-2.8.3 → letify-2.8.4}/letify/store/sendorder.py +0 -0
  129. {letify-2.8.3 → letify-2.8.4}/letify/store/volume.py +0 -0
  130. {letify-2.8.3 → letify-2.8.4}/letify/stubs.py +0 -0
  131. {letify-2.8.3 → letify-2.8.4}/letify/tools.py +0 -0
  132. {letify-2.8.3 → letify-2.8.4}/letify/transport/__init__.py +0 -0
  133. {letify-2.8.3 → letify-2.8.4}/letify/transport/agent.py +0 -0
  134. {letify-2.8.3 → letify-2.8.4}/letify/transport/announce.py +0 -0
  135. {letify-2.8.3 → letify-2.8.4}/letify/transport/link.py +0 -0
  136. {letify-2.8.3 → letify-2.8.4}/letify/transport/pipeline.py +0 -0
  137. {letify-2.8.3 → letify-2.8.4}/letify/transport/portmap.py +0 -0
  138. {letify-2.8.3 → letify-2.8.4}/letify/transport/probe.py +0 -0
  139. {letify-2.8.3 → letify-2.8.4}/letify/transport/quic.py +0 -0
  140. {letify-2.8.3 → letify-2.8.4}/letify/transport/rendezvous.py +0 -0
  141. {letify-2.8.3 → letify-2.8.4}/letify/transport/setup.py +0 -0
  142. {letify-2.8.3 → letify-2.8.4}/letify/transport/sshopts.py +0 -0
  143. {letify-2.8.3 → letify-2.8.4}/letify/transport/strategies.py +0 -0
  144. {letify-2.8.3 → letify-2.8.4}/letify-core/.gitignore +0 -0
  145. {letify-2.8.3 → letify-2.8.4}/letify-core/build.py +0 -0
  146. {letify-2.8.3 → letify-2.8.4}/letify-core/letify-agent/Cargo.toml +0 -0
  147. {letify-2.8.3 → letify-2.8.4}/letify-core/letify-agent/src/driver.rs +0 -0
  148. {letify-2.8.3 → letify-2.8.4}/letify-core/letify-agent/src/main.rs +0 -0
  149. {letify-2.8.3 → letify-2.8.4}/letify-core/letify-driver/Cargo.toml +0 -0
  150. {letify-2.8.3 → letify-2.8.4}/letify-core/letify-driver/src/client.rs +0 -0
  151. {letify-2.8.3 → letify-2.8.4}/letify-core/letify-driver/src/exports.rs +0 -0
  152. {letify-2.8.3 → letify-2.8.4}/letify-core/letify-driver/src/image.rs +0 -0
  153. {letify-2.8.3 → letify-2.8.4}/letify-core/letify-driver/src/lib.rs +0 -0
  154. {letify-2.8.3 → letify-2.8.4}/letify-core/letify-driver/src/status.rs +0 -0
  155. {letify-2.8.3 → letify-2.8.4}/letify-core/letify-driver/src/table.rs +0 -0
  156. {letify-2.8.3 → letify-2.8.4}/letify-core/letify-quic/Cargo.toml +0 -0
  157. {letify-2.8.3 → letify-2.8.4}/letify-core/letify-quic/src/main.rs +0 -0
  158. {letify-2.8.3 → letify-2.8.4}/letify-core/letify-wire/Cargo.toml +0 -0
  159. {letify-2.8.3 → letify-2.8.4}/letify-core/letify-wire/src/lib.rs +0 -0
  160. {letify-2.8.3 → letify-2.8.4}/letify-core/letify-wire/tests/throughput.rs +0 -0
  161. {letify-2.8.3 → letify-2.8.4}/letify-core/rust-toolchain.toml +0 -0
  162. {letify-2.8.3 → letify-2.8.4}/letify-ext/.vscodeignore +0 -0
  163. {letify-2.8.3 → letify-2.8.4}/letify-ext/LICENSE +0 -0
  164. {letify-2.8.3 → letify-2.8.4}/letify-ext/README.md +0 -0
  165. {letify-2.8.3 → letify-2.8.4}/letify-ext/media/icon.svg +0 -0
  166. {letify-2.8.3 → letify-2.8.4}/letify-ext/src/cli.ts +0 -0
  167. {letify-2.8.3 → letify-2.8.4}/letify-ext/src/extension.ts +0 -0
  168. {letify-2.8.3 → letify-2.8.4}/letify-ext/src/history.ts +0 -0
  169. {letify-2.8.3 → letify-2.8.4}/letify-ext/src/model.ts +0 -0
  170. {letify-2.8.3 → letify-2.8.4}/letify-ext/src/poll.ts +0 -0
  171. {letify-2.8.3 → letify-2.8.4}/letify-ext/src/render.ts +0 -0
  172. {letify-2.8.3 → letify-2.8.4}/letify-ext/test/cli.test.ts +0 -0
  173. {letify-2.8.3 → letify-2.8.4}/letify-ext/test/manifest.test.ts +0 -0
  174. {letify-2.8.3 → letify-2.8.4}/letify-ext/test/model.test.ts +0 -0
  175. {letify-2.8.3 → letify-2.8.4}/letify-ext/test/regressions.test.ts +0 -0
  176. {letify-2.8.3 → letify-2.8.4}/letify-ext/tsconfig.json +0 -0
  177. {letify-2.8.3 → letify-2.8.4}/letify_providers.py +0 -0
  178. {letify-2.8.3 → letify-2.8.4}/scripts/sync-main.sh +0 -0
  179. {letify-2.8.3 → letify-2.8.4}/scripts/version.py +0 -0
  180. {letify-2.8.3 → letify-2.8.4}/tests/fake_kaggle_adapter.py +0 -0
  181. {letify-2.8.3 → letify-2.8.4}/tests/fake_modal_adapter.py +0 -0
  182. {letify-2.8.3 → letify-2.8.4}/tests/test_cli.py +0 -0
  183. {letify-2.8.3 → letify-2.8.4}/tests/test_cli_output.py +0 -0
  184. {letify-2.8.3 → letify-2.8.4}/tests/test_colab_keepalive.py +0 -0
  185. {letify-2.8.3 → letify-2.8.4}/tests/test_config.py +0 -0
  186. {letify-2.8.3 → letify-2.8.4}/tests/test_core.py +0 -0
  187. {letify-2.8.3 → letify-2.8.4}/tests/test_data.py +0 -0
  188. {letify-2.8.3 → letify-2.8.4}/tests/test_data_streaming.py +0 -0
  189. {letify-2.8.3 → letify-2.8.4}/tests/test_declare.py +0 -0
  190. {letify-2.8.3 → letify-2.8.4}/tests/test_device.py +0 -0
  191. {letify-2.8.3 → letify-2.8.4}/tests/test_distributed.py +0 -0
  192. {letify-2.8.3 → letify-2.8.4}/tests/test_drive_handoff.py +0 -0
  193. {letify-2.8.3 → letify-2.8.4}/tests/test_drive_store.py +0 -0
  194. {letify-2.8.3 → letify-2.8.4}/tests/test_editor_sessions.py +0 -0
  195. {letify-2.8.3 → letify-2.8.4}/tests/test_elice.py +0 -0
  196. {letify-2.8.3 → letify-2.8.4}/tests/test_frames.py +0 -0
  197. {letify-2.8.3 → letify-2.8.4}/tests/test_install.py +0 -0
  198. {letify-2.8.3 → letify-2.8.4}/tests/test_kaggle.py +0 -0
  199. {letify-2.8.3 → letify-2.8.4}/tests/test_kaggle_cookie.py +0 -0
  200. {letify-2.8.3 → letify-2.8.4}/tests/test_kaggle_link.py +0 -0
  201. {letify-2.8.3 → letify-2.8.4}/tests/test_link_log.py +0 -0
  202. {letify-2.8.3 → letify-2.8.4}/tests/test_notebook.py +0 -0
  203. {letify-2.8.3 → letify-2.8.4}/tests/test_parallel_placement.py +0 -0
  204. {letify-2.8.3 → letify-2.8.4}/tests/test_paths.py +0 -0
  205. {letify-2.8.3 → letify-2.8.4}/tests/test_portmap.py +0 -0
  206. {letify-2.8.3 → letify-2.8.4}/tests/test_providers.py +0 -0
  207. {letify-2.8.3 → letify-2.8.4}/tests/test_quic.py +0 -0
  208. {letify-2.8.3 → letify-2.8.4}/tests/test_remoting.py +0 -0
  209. {letify-2.8.3 → letify-2.8.4}/tests/test_render.py +0 -0
  210. {letify-2.8.3 → letify-2.8.4}/tests/test_ship.py +0 -0
  211. {letify-2.8.3 → letify-2.8.4}/tests/test_stubs.py +0 -0
  212. {letify-2.8.3 → letify-2.8.4}/tests/test_transport.py +0 -0
  213. {letify-2.8.3 → letify-2.8.4}/tests/test_tunnel_setup.py +0 -0
  214. {letify-2.8.3 → letify-2.8.4}/tests/test_version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: letify
3
- Version: 2.8.3
3
+ Version: 2.8.4
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
@@ -924,6 +924,8 @@ A top-level argument whose serialized size is 64 KiB or more is replaced by a `B
924
924
 
925
925
  A `bytes` argument is hashed as it is. Any other argument is pickled with protocol 5 and a `buffer_callback` through `codec.pickle_value_parts`, cloudpickle underneath rather than the standard library's `pickle.Pickler`, and the digest is taken over the pickle and then each out-of-band buffer in order, with no joined copy. The digest is blake3 with 16 byte output, or blake2b from the standard library where blake3 is missing.
926
926
 
927
+ A digest names its own algorithm. A blake3 digest is the 32 hexadecimal characters of the 16 byte output, unchanged, so every digest already stored in a cache, a volume or Drive stays valid. A digest computed with blake2b because blake3 is missing is `b2-` followed by the 32 hexadecimal characters of the 16 byte blake2b output. Whoever checks a digest picks the algorithm from the digest, through `codec.hasher_for`, so a worker without blake3 and a client with it agree on every blob, archive, write-back file and handoff, and no handshake is needed. A side that cannot compute the algorithm of a digest it was given, a worker without blake3 handed a blake3 digest, does not check it and trusts the sender, and a name it computes itself uses its own algorithm. A placed file is compared by the algorithm of its recorded digest, so a worker without blake3 does not report an unchanged file as an output.
928
+
927
929
  cloudpickle rather than plain `pickle` because a `pickle.Pickler` writes a class or function defined in the caller's own run script as a name reference, `__main__.<name>`, and the worker's own `__main__` is not that script, so unpickling such a blob fails with `AttributeError: Can't get attribute '<name>' on <module '__main__' ...>`. cloudpickle ships such an object by value instead, the same as it already does for the call itself in `codec.dumps_call_parts`, so an argument too large to inline behaves the same as one that is not. The cost of cloudpickle's extra dispatch over plain `pickle` is in the noise: it only intercepts function, class and module objects, not the bulk of a large value, so a 256 MiB buffer or a multi-megabyte list of plain values serializes within a few percent of the same time either way, measured on this machine. There is accordingly no branch between the two picklers.
928
930
 
929
931
  The digest of an immutable argument is cached on the `Runtime` for the life of the session, so a repeated argument is not hashed again. Immutable means a `bytes` object, or an object that supports weak references and exposes a read-only buffer, such as a NumPy array with `writeable` set to `False`. A `bytes` entry holds a reference to its object so its id cannot be reused, and at most 16 such entries are kept, least recently used first out. Any other argument is pickled and hashed on every call, because it may have changed.
@@ -1088,7 +1090,7 @@ neither side holds it whole:
1088
1090
  returns `{"digest", "size", "dir"}`. The client receives the file with the stream a write-back
1089
1091
  uses, checks the digest as the pieces arrive into a scratch file beside the store, and renames it
1090
1092
  into the store with `Store.adopt_file`. The staging directory is removed afterwards. The digest
1091
- is the store's content digest (blake3 truncated to 16 bytes, blake2b without blake3).
1093
+ is the store's content digest (blake3 truncated to 16 bytes, or `b2-` and blake2b where the worker has no blake3), and the client checks it with the algorithm the digest names.
1092
1094
  - Restore: a blob of more than one piece (8 MiB) that the store holds as a file is sent with
1093
1095
  `data_put` pieces into a staging directory beside the destination, then moved into place and
1094
1096
  unpacked by the worker op `place_blob`. Smaller blobs, and any blob from a store that does not
@@ -1114,6 +1116,8 @@ the kind this rule exists to catch, and the session that syncs instead both reco
1114
1116
  decides afresh whether the archive is worth keeping. The cost of that is one sync; the cost of
1115
1117
  trusting it is the transfer this rule was measured to avoid.
1116
1118
 
1119
+ A failure to cache the environment through the client's own store at boot, the pack, the stream back or the store write, never fails the session: it says `letify: <runtime>: not caching the environment (<reason>)` and the session goes on, the same as a failed pack on the workspace root.
1120
+
1117
1121
  Packing is said, because it is a step that can take minutes and had nothing reporting it:
1118
1122
  `letify: <runtime>: caching the environment, 2.1 GB`. Skipping it is said too, because it is a
1119
1123
  decision letify made rather than a thing that did not
@@ -1949,7 +1953,7 @@ Colab limits outbound UDP to roughly 200 packets per second, so rank 3 is expect
1949
1953
 
1950
1954
  Kaggle reports 2.0 MiB/s, measured on a live account sending 64 MiB over the kernel bridge, and an account overrides it with `fallback_mib_per_s`. Without this a live session refused a punched link at down 8.1 MiB/s for being under the 10 MiB/s floor and then carried 26 GB over the 2 MiB/s bridge instead, which is the opposite of what the floor is for. Taking the fallback is also never silent: the reason is printed, because a session that quietly changed channel is one nobody can account for.
1951
1955
 
1952
- A `channel = "exec"` entry skips the pipeline and uses the fallback directly. A Colab VM has no SSH server on the port letify splices to, so the rendezvous request asks the remote half to start one first. The remote half connects to `127.0.0.1:<ssh_port>`, and when no line starting with `SSH-` arrives within 3 s it installs `openssh-server` if `/usr/sbin/sshd` is missing, creates `/run/sshd`, runs `ssh-keygen -A` so the server has host keys, and starts `/usr/sbin/sshd -p <ssh_port> -o ListenAddress=127.0.0.1`. `ssh-keygen -A` creates only the keys that are missing, and it is needed because a Kaggle image ships `sshd` with no host key at all: the install step above is skipped since the package is already there, and a server with no host key accepts the connection and closes it before the banner, which the client reports as `Connection closed by 127.0.0.1 port <port>` with no reason. The port is explicit because a Colab image ships `sshd` configured for `127.0.0.1:2222`, so starting it with its own configuration leaves port 22 closed. An SSH server that already answers on the port is used as it is. The request carries the account's public key, `key` with `.pub` appended, which the remote side adds to `authorized_keys`. An account with no `key`, or whose `.pub` file is missing, has nothing the VM could authorize, so its Colab rendezvous is unavailable with the reason `no key`: `tcp_punch` and `tailcat` are skipped with that reason and the fallback carries the session. SSH over a punched or Tailcat link logs in as `root` unless `user` says otherwise.
1956
+ A `channel = "exec"` entry skips the pipeline and uses the fallback directly. A Colab VM has no SSH server on the port letify splices to, so the rendezvous request asks the remote half to start one first. The remote half connects to `127.0.0.1:<ssh_port>`, and when no line starting with `SSH-` arrives within 3 s it installs `openssh-server` if `/usr/sbin/sshd` is missing, creates `/run/sshd`, runs `ssh-keygen -A` so the server has host keys, and starts `/usr/sbin/sshd -f /etc/ssh/sshd_config.letify -p <ssh_port> -o ListenAddress=127.0.0.1`. That file is the image's `/etc/ssh/sshd_config` with every `MaxStartups` line removed and `MaxStartups 64:30:128` as its first line, or that line alone when the image has no file. A Colab image's file sets `MaxStartups 1`, so the server dropped every connection that arrived while another was still authenticating, and a command-line `-o MaxStartups` does not override the file. Over `reverse_ssh` the transfer streams of a session arrive at once, so every one after the first ended with `Connection closed by 127.0.0.1 port <port>` and the call failed with `the worker exited before it was ready`. With the file, one 190.7 MiB blob crossed a reverse SSH link from a Colab CPU runtime in 5.4 s, 35.5 MiB/s. `ssh-keygen -A` creates only the keys that are missing, and it is needed because a Kaggle image ships `sshd` with no host key at all: the install step above is skipped since the package is already there, and a server with no host key accepts the connection and closes it before the banner, which the client reports as `Connection closed by 127.0.0.1 port <port>` with no reason. The port is explicit because a Colab image ships `sshd` configured for `127.0.0.1:2222`, so starting it with its own configuration leaves port 22 closed. An SSH server that already answers on the port is used as it is. The request carries the account's public key, `key` with `.pub` appended, which the remote side adds to `authorized_keys`. An account with no `key`, or whose `.pub` file is missing, has nothing the VM could authorize, so its Colab rendezvous is unavailable with the reason `no key`: `tcp_punch` and `tailcat` are skipped with that reason and the fallback carries the session. SSH over a punched or Tailcat link logs in as `root` unless `user` says otherwise.
1953
1957
 
1954
1958
  A Colab VM keeps its GPU driver libraries in `/usr/lib64-nvidia`, and only the notebook kernel's own environment names that directory in `LD_LIBRARY_PATH`. A login shell does not, so a worker started over SSH finds no `libnvidia-ml.so` and no `libcuda.so`: `nvidia-smi` fails and `torch.cuda.is_available()` is false on a session that holds a GPU. Every command letify runs over a Colab link, the worker and the `check` command included, is therefore prefixed with `LD_LIBRARY_PATH=/usr/lib64-nvidia${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}`, which keeps a path the machine already set. The `colab exec` fallback needs no prefix, because it runs inside the kernel.
1955
1959
 
@@ -68,7 +68,7 @@ distributed: Final = Host.distributed
68
68
  # The enum class stays importable from letify.declare.instance for letify's own use.
69
69
  del Host
70
70
 
71
- __version__ = "2.8.3"
71
+ __version__ = "2.8.4"
72
72
 
73
73
 
74
74
  def fetch(tensor): # type: ignore[no-untyped-def]
@@ -130,6 +130,10 @@ class _CallPickler(cloudpickle.Pickler):
130
130
  return super().reducer_override(obj)
131
131
 
132
132
 
133
+ #: Prefix of a digest computed with blake2b because blake3 was missing. A blake3 digest has none.
134
+ BLAKE2B_PREFIX = "b2-"
135
+
136
+
133
137
  def _hasher() -> Any:
134
138
  """A fresh incremental hasher: blake3 on every core, or blake2b where blake3 is missing."""
135
139
  try:
@@ -146,10 +150,28 @@ def new_hasher() -> Any:
146
150
  return _hasher()
147
151
 
148
152
 
153
+ def hasher_for(digest: str) -> Any | None:
154
+ """A fresh hasher of the algorithm ``digest`` names, or None where it cannot be computed.
155
+
156
+ Spec "Argument addressing": a digest names its own algorithm, so a side checking one
157
+ follows the side that computed it. None means blake3 is missing here and the digest is
158
+ blake3, so the check cannot be made.
159
+ """
160
+ if digest.startswith(BLAKE2B_PREFIX):
161
+ import hashlib
162
+
163
+ return hashlib.blake2b(digest_size=16)
164
+ try:
165
+ import blake3
166
+ except ImportError:
167
+ return None
168
+ return blake3.blake3(max_threads=blake3.blake3.AUTO)
169
+
170
+
149
171
  def finish_digest(hasher: Any) -> str:
150
- """The content address a hasher from ``new_hasher`` has accumulated."""
172
+ """The content address a hasher from ``new_hasher`` or ``hasher_for`` has accumulated."""
151
173
  if hasher.name == "blake2b":
152
- return str(hasher.hexdigest())
174
+ return BLAKE2B_PREFIX + str(hasher.hexdigest())
153
175
  return str(hasher.hexdigest(length=16))
154
176
 
155
177
 
@@ -158,14 +180,13 @@ def digest_parts(parts: Iterable[Any]) -> str:
158
180
 
159
181
  blake3 is preferred because it hashes at several gigabytes per second, which
160
182
  keeps hashing orders of magnitude away from being the bottleneck on any real
161
- network link. blake2b from the standard library is the fallback.
183
+ network link. blake2b from the standard library is the fallback, and its digests
184
+ carry ``BLAKE2B_PREFIX``.
162
185
  """
163
186
  hasher = _hasher()
164
187
  for part in parts:
165
188
  hasher.update(part)
166
- if hasher.name == "blake2b":
167
- return hasher.hexdigest()
168
- return hasher.hexdigest(length=16)
189
+ return finish_digest(hasher)
169
190
 
170
191
 
171
192
  def digest_of(payload: bytes) -> str:
@@ -297,6 +318,7 @@ __all__ = [
297
318
  "dumps_call",
298
319
  "dumps_call_parts",
299
320
  "finish_digest",
321
+ "hasher_for",
300
322
  "new_hasher",
301
323
  "parse",
302
324
  "split_output",
@@ -247,11 +247,9 @@ def _release_output():
247
247
 
248
248
 
249
249
  def _digest(payload):
250
- try:
251
- import blake3
252
- return blake3.blake3(payload).hexdigest(length=16)
253
- except ImportError:
254
- return hashlib.blake2b(payload, digest_size=16).hexdigest()
250
+ hasher = _data_new_hasher()
251
+ hasher.update(payload)
252
+ return _data_finish(hasher)
255
253
 
256
254
 
257
255
  def _resolve(value):
@@ -394,20 +392,22 @@ def _letify_replica(replica):
394
392
  _replicas._WHO.rate_bps = replica.get("rate_bps")
395
393
 
396
394
 
397
- def _data_hash_file(path):
398
- """The file digest of a file on the runtime: blake3 when present, else blake2b."""
399
- hasher = _data_hasher()
395
+ def _data_hash_file(path, like=None):
396
+ """The file digest of a file on the runtime: blake3 when present, else blake2b.
397
+
398
+ With ``like``, a digest the file may already be named by, the algorithm that digest
399
+ names is used when it can be, so the result is comparable with it.
400
+ """
401
+ hasher = _data_hasher(like) if like is not None else None
400
402
  if hasher is None:
401
- hasher = hashlib.blake2b(digest_size=16)
403
+ hasher = _data_new_hasher()
402
404
  with open(path, "rb") as handle:
403
405
  while True:
404
406
  piece = handle.read(1 << 22)
405
407
  if not piece:
406
408
  break
407
409
  hasher.update(piece)
408
- if hasher.name == "blake2b":
409
- return hasher.hexdigest()
410
- return hasher.hexdigest(length=16)
410
+ return _data_finish(hasher)
411
411
 
412
412
 
413
413
  def _data_same_bytes(path, digest):
@@ -418,7 +418,7 @@ def _data_same_bytes(path, digest):
418
418
  Never raises; a file that cannot be read is not the file the digest promised.
419
419
  """
420
420
  try:
421
- return _data_hash_file(path) == digest
421
+ return _data_hash_file(path, digest) == digest
422
422
  except OSError:
423
423
  return False
424
424
 
@@ -453,7 +453,7 @@ def _data_collect(data, placed):
453
453
  # The placed record ends with the cache stamp, which is not part of the comparison.
454
454
  if known is not None and known[1:4] == (info.st_ino, info.st_size, info.st_mtime_ns):
455
455
  continue
456
- digest = _data_hash_file(full)
456
+ digest = _data_hash_file(full, known[0] if known is not None else None)
457
457
  if known is not None and known[0] == digest:
458
458
  continue
459
459
  final = _data_file(data["blobs"], digest)
@@ -667,7 +667,14 @@ def _data_file(root, digest):
667
667
  return os.path.join(root, digest[:2], digest)
668
668
 
669
669
 
670
- def _data_hasher():
670
+ def _data_hasher(digest):
671
+ """A hasher of the algorithm ``digest`` names, or None where it cannot be computed here.
672
+
673
+ A digest with the ``b2-`` prefix is blake2b and one without is blake3, so a runtime
674
+ without blake3 cannot check a blake3 digest and trusts the sender.
675
+ """
676
+ if digest.startswith("b2-"):
677
+ return hashlib.blake2b(digest_size=16)
671
678
  try:
672
679
  import blake3
673
680
  except ImportError:
@@ -675,10 +682,23 @@ def _data_hasher():
675
682
  return blake3.blake3(max_threads=blake3.blake3.AUTO)
676
683
 
677
684
 
685
+ def _data_new_hasher():
686
+ """The hasher this runtime names new content with: blake3 when present, else blake2b."""
687
+ hasher = _data_hasher("")
688
+ return hasher if hasher is not None else hashlib.blake2b(digest_size=16)
689
+
690
+
691
+ def _data_finish(hasher):
692
+ """The digest a hasher has accumulated, with the prefix a blake2b digest carries."""
693
+ if hasher.name == "blake2b":
694
+ return "b2-" + hasher.hexdigest()
695
+ return hasher.hexdigest(length=16)
696
+
697
+
678
698
  def _data_commit(partial, final, digest, hasher):
679
699
  """Rename a received file into the cache once its digest matches, read-only."""
680
700
  if hasher is not None:
681
- found = hasher.hexdigest(length=16)
701
+ found = _data_finish(hasher)
682
702
  if found != digest:
683
703
  os.remove(partial)
684
704
  raise ValueError("file blob %s arrived with digest %s" % (digest, found))
@@ -838,7 +858,7 @@ def _op_data_put(request):
838
858
  stale = _DATA_OPEN.pop(digest, None)
839
859
  if stale is not None:
840
860
  stale[0].close()
841
- _DATA_OPEN[digest] = (open(partial, "wb"), _data_hasher())
861
+ _DATA_OPEN[digest] = (open(partial, "wb"), _data_hasher(digest))
842
862
  handle, hasher = _DATA_OPEN[digest]
843
863
  handle.write(chunk)
844
864
  if hasher is not None:
@@ -938,7 +958,7 @@ def _op_data_seal(request):
938
958
  raise ValueError(
939
959
  "file blob %s is short: %d of %d bytes arrived" % (digest, found, int(size))
940
960
  )
941
- hasher = _data_hasher()
961
+ hasher = _data_hasher(digest)
942
962
  if hasher is not None:
943
963
  with open(partial, "rb") as handle:
944
964
  while True:
@@ -946,7 +966,7 @@ def _op_data_seal(request):
946
966
  if not piece:
947
967
  break
948
968
  hasher.update(piece)
949
- got = hasher.hexdigest(length=16)
969
+ got = _data_finish(hasher)
950
970
  if got != digest:
951
971
  os.remove(partial)
952
972
  raise ValueError("file blob %s arrived with digest %s" % (digest, got))
@@ -979,7 +999,7 @@ def _op_data_pull(request):
979
999
  partial = "%s.partial.%d.%d" % (final, os.getpid(), threading.get_ident())
980
1000
  try:
981
1001
  os.makedirs(os.path.dirname(final), exist_ok=True)
982
- hasher = _data_hasher()
1002
+ hasher = _data_hasher(digest)
983
1003
  wanted = urllib.request.Request(url, headers=headers)
984
1004
  with urllib.request.urlopen(wanted, timeout=3600) as response, \
985
1005
  open(partial, "wb") as out:
@@ -2334,16 +2354,14 @@ def _op_pack_blob(request):
2334
2354
  directory = os.path.expanduser(request["dir"])
2335
2355
  os.makedirs(directory, exist_ok=True)
2336
2356
  partial = os.path.join(directory, ".pack.%d.partial" % os.getpid())
2337
- hasher = _data_hasher()
2338
- if hasher is None:
2339
- hasher = hashlib.blake2b(digest_size=16)
2357
+ hasher = _data_new_hasher()
2340
2358
  try:
2341
2359
  with open(partial, "wb") as handle:
2342
2360
  hashed = _HashedFile(handle, hasher)
2343
2361
  mode = "w:gz" if request.get("gzip", True) else "w"
2344
2362
  with tarfile.open(fileobj=hashed, mode=mode) as archive:
2345
2363
  archive.add(root, arcname=os.path.basename(root.rstrip("/")), recursive=True)
2346
- digest = hasher.hexdigest() if hasher.name == "blake2b" else hasher.hexdigest(length=16)
2364
+ digest = _data_finish(hasher)
2347
2365
  final = _data_file(directory, digest)
2348
2366
  os.makedirs(os.path.dirname(final), exist_ok=True)
2349
2367
  os.replace(partial, final)
@@ -86,6 +86,13 @@ def _against_the_build(size: int, rate: float, build_s: float) -> str:
86
86
  )
87
87
 
88
88
 
89
+ def _reason_of(error: BaseException) -> str:
90
+ """One line naming an error's type and first message line, for a status message."""
91
+ lines = str(error).strip().splitlines()
92
+ reason = f"{type(error).__name__}: {lines[0]}" if lines else type(error).__name__
93
+ return reason[:197] + "..." if len(reason) > 200 else reason
94
+
95
+
89
96
  def _sync_rather_than_restore(archive_bytes: int, rate: float, build_s: float) -> str:
90
97
  """The line explaining why a session syncs instead of restoring a cached archive.
91
98
 
@@ -243,9 +250,14 @@ class Runtime:
243
250
  if volume.store.backend.moves_on_runtime:
244
251
  self._start_drive_pack(volume, root)
245
252
  else:
246
- volume.cache_env_from(
247
- self, self.env, root, platform=self.platform, build_s=self.env_build_s
248
- )
253
+ try:
254
+ volume.cache_env_from(
255
+ self, self.env, root, platform=self.platform, build_s=self.env_build_s
256
+ )
257
+ except Exception as error:
258
+ # The environment is built and checked, so a failed cache must not
259
+ # fail the session. Spec "Materializing into a runtime".
260
+ self._say(f"not caching the environment ({_reason_of(error)})")
249
261
  self.keep_blobs_on_disk()
250
262
  for volume in self.volumes:
251
263
  self.attach(volume)
@@ -831,11 +843,7 @@ class Runtime:
831
843
  self.eval(source, timeout=3600)
832
844
  except Exception as error:
833
845
  # The environment is built and checked, so a failed cache must not fail the call.
834
- lines = str(error).strip().splitlines()
835
- reason = f"{type(error).__name__}: {lines[0]}" if lines else type(error).__name__
836
- if len(reason) > 200:
837
- reason = reason[:197] + "..."
838
- self._say(f"not caching the environment on the workspace root ({reason})")
846
+ self._say(f"not caching the environment on the workspace root ({_reason_of(error)})")
839
847
 
840
848
  def _environment_volumes(self) -> tuple[Volume, ...]:
841
849
  """Explicit archive volumes, or the automatic cache where the project does not survive.
@@ -248,7 +248,13 @@ def unstash(name: str, path: str | os.PathLike[str]) -> Path | None:
248
248
 
249
249
 
250
250
  def _verify(name: str, archive: Path, digest: str) -> None:
251
- found = digest_of_file(archive)
251
+ hasher = codec.hasher_for(digest)
252
+ if hasher is None:
253
+ return
254
+ with open(archive, "rb") as handle:
255
+ while chunk := handle.read(1 << 20):
256
+ hasher.update(chunk)
257
+ found = codec.finish_digest(hasher)
252
258
  if found != digest:
253
259
  raise RuntimeFailure(
254
260
  f"handoff {name!r}: the blob downloaded from Drive has digest {found}, "
@@ -139,9 +139,9 @@ def hash_file(path: Path, size: int) -> str:
139
139
  else:
140
140
  while chunk := handle.read(1 << 20):
141
141
  hasher.update(chunk)
142
- if hasher.name == "blake2b":
143
- return hasher.hexdigest()
144
- return hasher.hexdigest(length=16)
142
+ from ..protocol.codec import finish_digest
143
+
144
+ return finish_digest(hasher)
145
145
 
146
146
 
147
147
  class DigestCache:
@@ -1608,7 +1608,7 @@ def _get(
1608
1608
  Spec "Writing back". One writing thread takes the pieces, so receiving the next one
1609
1609
  overlaps writing and hashing the one before it and the link does not idle.
1610
1610
  """
1611
- from ..protocol.codec import _hasher
1611
+ from ..protocol.codec import finish_digest, hasher_for
1612
1612
 
1613
1613
  # Spec "Writing back": a stream that fails after these were created would leave them
1614
1614
  # empty, and a resume that picks the newest directory by name then trusts a checkpoint
@@ -1617,7 +1617,8 @@ def _get(
1617
1617
  partial = target.with_name(
1618
1618
  f".{target.name}.letify-partial.{os.getpid()}.{threading.get_ident()}"
1619
1619
  )
1620
- hasher = _hasher()
1620
+ # The algorithm the digest names, which is the worker's when its venv has no blake3.
1621
+ hasher = hasher_for(digest)
1621
1622
  pieces: queue.Queue = queue.Queue(maxsize=WRITE_QUEUE)
1622
1623
  failure: list[BaseException] = []
1623
1624
 
@@ -1630,7 +1631,8 @@ def _get(
1630
1631
  if piece is None:
1631
1632
  return
1632
1633
  handle.write(piece)
1633
- hasher.update(piece)
1634
+ if hasher is not None:
1635
+ hasher.update(piece)
1634
1636
  except BaseException as exc: # pragma: no cover - a local disk failure
1635
1637
  failure.append(exc)
1636
1638
  # Drained, so the receiving thread is never left waiting on a full queue.
@@ -1657,7 +1659,7 @@ def _get(
1657
1659
  raise RuntimeFailure(
1658
1660
  f"{runtime.name}: file blob {digest} arrived with {received} bytes, not {size}"
1659
1661
  )
1660
- found = hasher.hexdigest() if hasher.name == "blake2b" else hasher.hexdigest(length=16)
1662
+ found = finish_digest(hasher) if hasher is not None else digest
1661
1663
  if found != digest:
1662
1664
  raise RuntimeFailure(f"{runtime.name}: file blob {digest} arrived with digest {found}")
1663
1665
  os.replace(partial, target)
@@ -659,6 +659,31 @@ def _authorize(public_key: str) -> None:
659
659
  #: The SSH server binary the remote half starts on a machine that has none on the port.
660
660
  SSHD = "/usr/sbin/sshd"
661
661
 
662
+ #: The image's own server configuration, which the started server's is copied from.
663
+ SSHD_CONFIG = "/etc/ssh/sshd_config"
664
+
665
+ #: The configuration the remote half starts the server with.
666
+ LETIFY_SSHD_CONFIG = "/etc/ssh/sshd_config.letify"
667
+
668
+ #: Unauthenticated connections the server takes at once. Spec "Colab": a Colab image sets 1,
669
+ #: and the transfer streams of one session arrive together.
670
+ SSHD_MAX_STARTUPS = "64:30:128"
671
+
672
+
673
+ def _write_sshd_config(source: str, target: str) -> None:
674
+ """Copy ``source`` to ``target`` with its MaxStartups replaced by ``SSHD_MAX_STARTUPS``.
675
+
676
+ A command-line ``-o MaxStartups`` does not override the file's value, so the file the
677
+ server reads has to carry it.
678
+ """
679
+ try:
680
+ lines = Path(source).read_text().splitlines()
681
+ except OSError:
682
+ lines = []
683
+ kept = [line for line in lines if not line.strip().lower().startswith("maxstartups")]
684
+ # First, ahead of any Match block, so it applies to the whole server.
685
+ Path(target).write_text("\n".join([f"MaxStartups {SSHD_MAX_STARTUPS}", *kept]) + "\n")
686
+
662
687
 
663
688
  def _ssh_answers(port: int, timeout: float = 3.0) -> bool:
664
689
  """Whether a line starting with ``SSH-`` arrives from ``127.0.0.1:<port>``."""
@@ -691,7 +716,11 @@ def _start_sshd(port: int = 22) -> None:
691
716
  # server closes every connection before the banner. ssh-keygen -A makes only what is
692
717
  # missing, so an image that already has its keys is untouched.
693
718
  subprocess.run(["ssh-keygen", "-A"], check=False)
694
- subprocess.run([SSHD, "-p", str(port), "-o", "ListenAddress=127.0.0.1"], check=False)
719
+ _write_sshd_config(SSHD_CONFIG, LETIFY_SSHD_CONFIG)
720
+ subprocess.run(
721
+ [SSHD, "-f", LETIFY_SSHD_CONFIG, "-p", str(port), "-o", "ListenAddress=127.0.0.1"],
722
+ check=False,
723
+ )
695
724
 
696
725
 
697
726
  def run_detached(request_json: str, source: str) -> None: # pragma: no cover - remote entry
@@ -138,7 +138,7 @@ dependencies = [
138
138
 
139
139
  [[package]]
140
140
  name = "letify-agent"
141
- version = "2.8.3"
141
+ version = "2.8.4"
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.8.3"
149
+ version = "2.8.4"
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.8.3"
167
+ version = "2.8.4"
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.8.3"
11
+ version = "2.8.4"
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.8.3",
3
+ "version": "2.8.4",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "letify-status",
9
- "version": "2.8.3",
9
+ "version": "2.8.4",
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.8.3",
5
+ "version": "2.8.4",
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.8.3"
3
+ version = "2.8.4"
4
4
  description = "Declarations that become infrastructure."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -124,6 +124,23 @@ class PreparingLocal(Local):
124
124
  remote_env = True
125
125
 
126
126
 
127
+ class NoBlake3Local(Local):
128
+ """A local provider whose worker cannot import blake3, like a runtime venv without letify.
129
+
130
+ The worker is the real one behind the real framed protocol. Hiding the module before
131
+ the bootstrap runs makes it hash with blake2b, which the client then has to follow.
132
+ """
133
+
134
+ def open_channel(self, runtime: Any) -> Any:
135
+ from letify.protocol.worker import BOOTSTRAP
136
+ from letify.runtime.channel import PersistentChannel
137
+
138
+ hide = "import sys; sys.modules['blake3'] = None\n"
139
+ return PersistentChannel(
140
+ [sys.executable, "-u", "-c", hide + BOOTSTRAP], name=runtime.name
141
+ )
142
+
143
+
127
144
  @pytest.fixture
128
145
  def uv_project(tmp_path: Path, monkeypatch) -> Path:
129
146
  """A tiny uv project that depends on this checkout of letify, locked with the real uv.
@@ -60,8 +60,10 @@ def test_content_addressing_falls_back_to_the_standard_library(no_module) -> Non
60
60
  # blake3 is preferred for speed, but it is a wheel, so blake2b has to carry the same
61
61
  # guarantee when it is absent.
62
62
  no_module("blake3")
63
+ # Its digests carry the b2- prefix, so a side with blake3 does not mistake them for its own.
63
64
  digest = codec.digest_of(b"same contents")
64
- assert len(digest) == 32
65
+ assert digest.startswith("b2-")
66
+ assert len(digest) == 35
65
67
  assert digest == codec.digest_of(b"same contents")
66
68
 
67
69
 
@@ -2982,3 +2982,37 @@ def test_a_small_environment_is_still_packed_and_says_so(
2982
2982
  runtime.shutdown()
2983
2983
  assert volume.cached_env(env, platform)
2984
2984
  assert "caching the environment" in capsys.readouterr().err
2985
+
2986
+
2987
+ def test_a_failure_to_cache_the_environment_through_the_client_store_does_not_fail_the_session(
2988
+ uv_project: Path, tmp_path: Path, monkeypatch, capsys
2989
+ ) -> None:
2990
+ # Spec "Materializing into a runtime": a failed cache at boot is said and the
2991
+ # session goes on, the same as a failed pack on the workspace root.
2992
+ from letify.runtime.session import Runtime
2993
+ from letify.store.volume import Volume
2994
+
2995
+ provider = provider_of(PreparingLocal, "lab", persistent=False)
2996
+ volume = provider.volume(
2997
+ "cache", backend="filesystem", root=str(tmp_path / "store"), mount=str(tmp_path / "mount")
2998
+ )
2999
+ env = Env()
3000
+ instance = Instance(provider, gpu=None)._placed("remote")
3001
+ real = Runtime.install_env
3002
+
3003
+ def slow_build(self: Runtime) -> None:
3004
+ real(self)
3005
+ self.env_build_s = 10_000.0
3006
+
3007
+ def refuse(*args: object, **kwargs: object) -> str:
3008
+ raise RuntimeFailure("lab-1: file blob 9870 arrived with digest d0aa")
3009
+
3010
+ monkeypatch.setattr(Runtime, "install_env", slow_build)
3011
+ monkeypatch.setattr(Volume, "cache_env_from", refuse)
3012
+ runtime = provider.start(instance, env, name="lab-1", volumes=(volume,))
3013
+ try:
3014
+ assert runtime.ready
3015
+ finally:
3016
+ runtime.shutdown()
3017
+ err = capsys.readouterr().err
3018
+ assert "not caching the environment (RuntimeFailure: lab-1: file blob 9870" in err, err
@@ -875,3 +875,57 @@ def test_a_one_shot_runtime_pulls_an_archive_without_relaying_it_through_the_cli
875
875
  blobs = [r for r in fake_gcs.downloads() if "/blobs/" in unquote(r["path"])]
876
876
  assert [r["authorization"] for r in blobs] == ["Bearer down-token-1"]
877
877
  let.pool.shutdown()
878
+
879
+
880
+ # -- Spec: Argument addressing, a digest names its own algorithm -----------------
881
+
882
+
883
+ def test_an_environment_packed_by_a_worker_without_blake3_is_checked_with_blake2b(
884
+ tmp_path: Path,
885
+ ) -> None:
886
+ # The worker hashes the archive with blake2b because its venv has no blake3. The
887
+ # client has blake3, so a client that checked with its own algorithm called the same
888
+ # bytes corrupted: "file blob ... arrived with digest ...".
889
+ from conftest import NoBlake3Local, provider_of
890
+
891
+ from letify.declare.env import Env
892
+ from letify.declare.instance import Instance
893
+
894
+ provider = provider_of(NoBlake3Local, "nob3")
895
+ volume = provider.volume("cache", backend="filesystem", root=str(tmp_path / "store"))
896
+ runtime = provider.start(
897
+ Instance(provider, gpu=None)._placed("remote"), Env(lock=str(tmp_path / "x.lock")),
898
+ name="nob3-1",
899
+ )
900
+ try:
901
+ installed = tmp_path / "site"
902
+ installed.mkdir()
903
+ (installed / "marker.txt").write_text("installed", encoding="utf-8")
904
+ digest = volume.cache_env_from(runtime, Env(lock=str(tmp_path / "x.lock")), str(installed))
905
+ finally:
906
+ runtime.shutdown()
907
+ assert digest.startswith("b2-")
908
+ assert volume.store.backend.has(digest)
909
+
910
+
911
+ def test_a_digest_is_checked_with_the_algorithm_it_names() -> None:
912
+ from letify.protocol import codec
913
+
914
+ hasher = codec.hasher_for("b2-" + "0" * 32)
915
+ hasher.update(b"x")
916
+ assert codec.finish_digest(hasher).startswith("b2-")
917
+ other = codec.hasher_for("0" * 32)
918
+ other.update(b"x")
919
+ assert not codec.finish_digest(other).startswith("b2-")
920
+ assert codec.digest_of(b"x") == codec.finish_digest(other)
921
+
922
+
923
+ def test_blake2b_is_named_by_a_prefix_when_blake3_is_missing(no_module) -> None:
924
+ from letify.protocol import codec
925
+
926
+ no_module("blake3")
927
+ digest = codec.digest_of(b"same contents")
928
+ assert digest.startswith("b2-")
929
+ assert len(digest) == 35
930
+ assert codec.hasher_for("0" * 32) is None
931
+ assert codec.hasher_for(digest).name == "blake2b"