letify 2.8.4__tar.gz → 2.8.6__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 (215) hide show
  1. {letify-2.8.4 → letify-2.8.6}/PKG-INFO +1 -1
  2. {letify-2.8.4 → letify-2.8.6}/PROJECT.md +1 -0
  3. {letify-2.8.4 → letify-2.8.6}/docs/SPEC.md +15 -7
  4. {letify-2.8.4 → letify-2.8.6}/letify/__init__.py +19 -3
  5. {letify-2.8.4 → letify-2.8.6}/letify/protocol/worker.py +8 -2
  6. {letify-2.8.4 → letify-2.8.6}/letify/providers/modal.py +75 -11
  7. {letify-2.8.4 → letify-2.8.6}/letify/providers/modal_adapter.py +1 -0
  8. {letify-2.8.4 → letify-2.8.6}/letify/runtime/channel.py +92 -7
  9. {letify-2.8.4 → letify-2.8.6}/letify/store/backends/drive.py +35 -7
  10. {letify-2.8.4 → letify-2.8.6}/letify/store/handoff.py +243 -18
  11. {letify-2.8.4 → letify-2.8.6}/letify/transport/nat.py +96 -19
  12. {letify-2.8.4 → letify-2.8.6}/letify/transport/pipeline.py +9 -2
  13. {letify-2.8.4 → letify-2.8.6}/letify/transport/rendezvous.py +31 -0
  14. {letify-2.8.4 → letify-2.8.6}/letify/transport/strategies.py +29 -19
  15. {letify-2.8.4 → letify-2.8.6}/letify-core/Cargo.lock +3 -3
  16. {letify-2.8.4 → letify-2.8.6}/letify-core/Cargo.toml +1 -1
  17. {letify-2.8.4 → letify-2.8.6}/letify-ext/package-lock.json +2 -2
  18. {letify-2.8.4 → letify-2.8.6}/letify-ext/package.json +1 -1
  19. {letify-2.8.4 → letify-2.8.6}/pyproject.toml +1 -1
  20. {letify-2.8.4 → letify-2.8.6}/tests/conftest.py +12 -1
  21. {letify-2.8.4 → letify-2.8.6}/tests/test_drive_handoff.py +335 -0
  22. {letify-2.8.4 → letify-2.8.6}/tests/test_providers.py +103 -0
  23. letify-2.8.6/tests/test_quic.py +370 -0
  24. {letify-2.8.4 → letify-2.8.6}/tests/test_runtime.py +92 -0
  25. {letify-2.8.4 → letify-2.8.6}/tests/test_strategies.py +64 -0
  26. {letify-2.8.4 → letify-2.8.6}/tests/test_transport.py +63 -0
  27. letify-2.8.4/tests/test_quic.py +0 -187
  28. {letify-2.8.4 → letify-2.8.6}/.github/workflows/ci.yml +0 -0
  29. {letify-2.8.4 → letify-2.8.6}/.github/workflows/docs.yml +0 -0
  30. {letify-2.8.4 → letify-2.8.6}/.github/workflows/publish.yml +0 -0
  31. {letify-2.8.4 → letify-2.8.6}/.gitignore +0 -0
  32. {letify-2.8.4 → letify-2.8.6}/.researchtree +0 -0
  33. {letify-2.8.4 → letify-2.8.6}/CLAUDE.md +0 -0
  34. {letify-2.8.4 → letify-2.8.6}/LICENSE +0 -0
  35. {letify-2.8.4 → letify-2.8.6}/README.md +0 -0
  36. {letify-2.8.4 → letify-2.8.6}/docs/COMPONENT.md +0 -0
  37. {letify-2.8.4 → letify-2.8.6}/docs/INTENT.md +0 -0
  38. {letify-2.8.4 → letify-2.8.6}/docs/NETWORK.md +0 -0
  39. {letify-2.8.4 → letify-2.8.6}/docs/guide/01-getting-started.md +0 -0
  40. {letify-2.8.4 → letify-2.8.6}/docs/guide/02-providers.md +0 -0
  41. {letify-2.8.4 → letify-2.8.6}/docs/guide/03-execution-modes.md +0 -0
  42. {letify-2.8.4 → letify-2.8.6}/docs/guide/04-environments-and-data.md +0 -0
  43. {letify-2.8.4 → letify-2.8.6}/docs/guide/05-concurrency.md +0 -0
  44. {letify-2.8.4 → letify-2.8.6}/docs/guide/06-cost.md +0 -0
  45. {letify-2.8.4 → letify-2.8.6}/docs/guide/07-troubleshooting.md +0 -0
  46. {letify-2.8.4 → letify-2.8.6}/docs/guide/08-releasing.md +0 -0
  47. {letify-2.8.4 → letify-2.8.6}/docs/guide/README.md +0 -0
  48. {letify-2.8.4 → letify-2.8.6}/docs/guide/ko/01-/354/213/234/354/236/221/355/225/230/352/270/260.md" +0 -0
  49. {letify-2.8.4 → letify-2.8.6}/docs/guide/ko/02-/355/224/204/353/241/234/353/260/224/354/235/264/353/215/224.md" +0 -0
  50. {letify-2.8.4 → letify-2.8.6}/docs/guide/ko/03-/354/213/244/355/226/211-/353/260/251/354/213/235.md" +0 -0
  51. {letify-2.8.4 → letify-2.8.6}/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
  52. {letify-2.8.4 → letify-2.8.6}/docs/guide/ko/05-/353/217/231/354/213/234-/354/213/244/355/226/211.md" +0 -0
  53. {letify-2.8.4 → letify-2.8.6}/docs/guide/ko/06-/353/271/204/354/232/251.md" +0 -0
  54. {letify-2.8.4 → letify-2.8.6}/docs/guide/ko/07-/353/254/270/354/240/234-/355/225/264/352/262/260.md" +0 -0
  55. {letify-2.8.4 → letify-2.8.6}/docs/guide/ko/08-/353/246/264/353/246/254/354/212/244.md" +0 -0
  56. {letify-2.8.4 → letify-2.8.6}/docs/guide/ko/README.md +0 -0
  57. {letify-2.8.4 → letify-2.8.6}/docs/locales/README_ko.md +0 -0
  58. {letify-2.8.4 → letify-2.8.6}/docs/mkdocs.yml +0 -0
  59. {letify-2.8.4 → letify-2.8.6}/examples/README.md +0 -0
  60. {letify-2.8.4 → letify-2.8.6}/examples/nvfp4-lora/.letify.example +0 -0
  61. {letify-2.8.4 → letify-2.8.6}/examples/nvfp4-lora/00_smoke.py +0 -0
  62. {letify-2.8.4 → letify-2.8.6}/examples/nvfp4-lora/01_sweep.py +0 -0
  63. {letify-2.8.4 → letify-2.8.6}/examples/nvfp4-lora/02_resume.py +0 -0
  64. {letify-2.8.4 → letify-2.8.6}/examples/nvfp4-lora/03_watch.py +0 -0
  65. {letify-2.8.4 → letify-2.8.6}/examples/nvfp4-lora/README.md +0 -0
  66. {letify-2.8.4 → letify-2.8.6}/examples/nvfp4-lora/README_ko.md +0 -0
  67. {letify-2.8.4 → letify-2.8.6}/examples/nvfp4-lora/common.py +0 -0
  68. {letify-2.8.4 → letify-2.8.6}/examples/nvfp4-lora/recipe.py +0 -0
  69. {letify-2.8.4 → letify-2.8.6}/letify/cli.py +0 -0
  70. {letify-2.8.4 → letify-2.8.6}/letify/config/__init__.py +0 -0
  71. {letify-2.8.4 → letify-2.8.6}/letify/config/inventory.py +0 -0
  72. {letify-2.8.4 → letify-2.8.6}/letify/config/login.py +0 -0
  73. {letify-2.8.4 → letify-2.8.6}/letify/config/schema.py +0 -0
  74. {letify-2.8.4 → letify-2.8.6}/letify/config/secrets.py +0 -0
  75. {letify-2.8.4 → letify-2.8.6}/letify/config/writer.py +0 -0
  76. {letify-2.8.4 → letify-2.8.6}/letify/declare/__init__.py +0 -0
  77. {letify-2.8.4 → letify-2.8.6}/letify/declare/cache.py +0 -0
  78. {letify-2.8.4 → letify-2.8.6}/letify/declare/env.py +0 -0
  79. {letify-2.8.4 → letify-2.8.6}/letify/declare/function.py +0 -0
  80. {letify-2.8.4 → letify-2.8.6}/letify/declare/instance.py +0 -0
  81. {letify-2.8.4 → letify-2.8.6}/letify/declare/reduce.py +0 -0
  82. {letify-2.8.4 → letify-2.8.6}/letify/declare/replicas.py +0 -0
  83. {letify-2.8.4 → letify-2.8.6}/letify/errors.py +0 -0
  84. {letify-2.8.4 → letify-2.8.6}/letify/install.py +0 -0
  85. {letify-2.8.4 → letify-2.8.6}/letify/launcher.py +0 -0
  86. {letify-2.8.4 → letify-2.8.6}/letify/paths.py +0 -0
  87. {letify-2.8.4 → letify-2.8.6}/letify/protocol/__init__.py +0 -0
  88. {letify-2.8.4 → letify-2.8.6}/letify/protocol/codec.py +0 -0
  89. {letify-2.8.4 → letify-2.8.6}/letify/protocol/driver.py +0 -0
  90. {letify-2.8.4 → letify-2.8.6}/letify/protocol/handle.py +0 -0
  91. {letify-2.8.4 → letify-2.8.6}/letify/protocol/wire.py +0 -0
  92. {letify-2.8.4 → letify-2.8.6}/letify/providers/__init__.py +0 -0
  93. {letify-2.8.4 → letify-2.8.6}/letify/providers/base.py +0 -0
  94. {letify-2.8.4 → letify-2.8.6}/letify/providers/colab.py +0 -0
  95. {letify-2.8.4 → letify-2.8.6}/letify/providers/colab_files.py +0 -0
  96. {letify-2.8.4 → letify-2.8.6}/letify/providers/colab_keepalive.py +0 -0
  97. {letify-2.8.4 → letify-2.8.6}/letify/providers/elice.py +0 -0
  98. {letify-2.8.4 → letify-2.8.6}/letify/providers/kaggle.py +0 -0
  99. {letify-2.8.4 → letify-2.8.6}/letify/providers/kaggle_adapter.py +0 -0
  100. {letify-2.8.4 → letify-2.8.6}/letify/providers/local.py +0 -0
  101. {letify-2.8.4 → letify-2.8.6}/letify/providers/naming.py +0 -0
  102. {letify-2.8.4 → letify-2.8.6}/letify/providers/shell.py +0 -0
  103. {letify-2.8.4 → letify-2.8.6}/letify/providers/tunnel.py +0 -0
  104. {letify-2.8.4 → letify-2.8.6}/letify/providers/usage.py +0 -0
  105. {letify-2.8.4 → letify-2.8.6}/letify/remoting/__init__.py +0 -0
  106. {letify-2.8.4 → letify-2.8.6}/letify/remoting/capability.py +0 -0
  107. {letify-2.8.4 → letify-2.8.6}/letify/remoting/device/__init__.py +0 -0
  108. {letify-2.8.4 → letify-2.8.6}/letify/remoting/device/client.py +0 -0
  109. {letify-2.8.4 → letify-2.8.6}/letify/remoting/device/cuda.py +0 -0
  110. {letify-2.8.4 → letify-2.8.6}/letify/remoting/device/executor.py +0 -0
  111. {letify-2.8.4 → letify-2.8.6}/letify/remoting/device/frames.py +0 -0
  112. {letify-2.8.4 → letify-2.8.6}/letify/remoting/device/guard.py +0 -0
  113. {letify-2.8.4 → letify-2.8.6}/letify/remoting/device/tensor.py +0 -0
  114. {letify-2.8.4 → letify-2.8.6}/letify/remoting/device/trace.py +0 -0
  115. {letify-2.8.4 → letify-2.8.6}/letify/remoting/device/value.py +0 -0
  116. {letify-2.8.4 → letify-2.8.6}/letify/remoting/loader.py +0 -0
  117. {letify-2.8.4 → letify-2.8.6}/letify/remoting/probe.py +0 -0
  118. {letify-2.8.4 → letify-2.8.6}/letify/render.py +0 -0
  119. {letify-2.8.4 → letify-2.8.6}/letify/runtime/__init__.py +0 -0
  120. {letify-2.8.4 → letify-2.8.6}/letify/runtime/bootstrap.py +0 -0
  121. {letify-2.8.4 → letify-2.8.6}/letify/runtime/broker.py +0 -0
  122. {letify-2.8.4 → letify-2.8.6}/letify/runtime/lease.py +0 -0
  123. {letify-2.8.4 → letify-2.8.6}/letify/runtime/pool.py +0 -0
  124. {letify-2.8.4 → letify-2.8.6}/letify/runtime/session.py +0 -0
  125. {letify-2.8.4 → letify-2.8.6}/letify/runtime/telemetry.py +0 -0
  126. {letify-2.8.4 → letify-2.8.6}/letify/store/__init__.py +0 -0
  127. {letify-2.8.4 → letify-2.8.6}/letify/store/backends/__init__.py +0 -0
  128. {letify-2.8.4 → letify-2.8.6}/letify/store/backends/filesystem.py +0 -0
  129. {letify-2.8.4 → letify-2.8.6}/letify/store/backends/google_auth.py +0 -0
  130. {letify-2.8.4 → letify-2.8.6}/letify/store/backends/layout.py +0 -0
  131. {letify-2.8.4 → letify-2.8.6}/letify/store/backends/objects.py +0 -0
  132. {letify-2.8.4 → letify-2.8.6}/letify/store/cas.py +0 -0
  133. {letify-2.8.4 → letify-2.8.6}/letify/store/pathdata.py +0 -0
  134. {letify-2.8.4 → letify-2.8.6}/letify/store/sendorder.py +0 -0
  135. {letify-2.8.4 → letify-2.8.6}/letify/store/volume.py +0 -0
  136. {letify-2.8.4 → letify-2.8.6}/letify/stubs.py +0 -0
  137. {letify-2.8.4 → letify-2.8.6}/letify/tools.py +0 -0
  138. {letify-2.8.4 → letify-2.8.6}/letify/transport/__init__.py +0 -0
  139. {letify-2.8.4 → letify-2.8.6}/letify/transport/agent.py +0 -0
  140. {letify-2.8.4 → letify-2.8.6}/letify/transport/announce.py +0 -0
  141. {letify-2.8.4 → letify-2.8.6}/letify/transport/link.py +0 -0
  142. {letify-2.8.4 → letify-2.8.6}/letify/transport/portmap.py +0 -0
  143. {letify-2.8.4 → letify-2.8.6}/letify/transport/probe.py +0 -0
  144. {letify-2.8.4 → letify-2.8.6}/letify/transport/quic.py +0 -0
  145. {letify-2.8.4 → letify-2.8.6}/letify/transport/setup.py +0 -0
  146. {letify-2.8.4 → letify-2.8.6}/letify/transport/sshopts.py +0 -0
  147. {letify-2.8.4 → letify-2.8.6}/letify-core/.gitignore +0 -0
  148. {letify-2.8.4 → letify-2.8.6}/letify-core/build.py +0 -0
  149. {letify-2.8.4 → letify-2.8.6}/letify-core/letify-agent/Cargo.toml +0 -0
  150. {letify-2.8.4 → letify-2.8.6}/letify-core/letify-agent/src/driver.rs +0 -0
  151. {letify-2.8.4 → letify-2.8.6}/letify-core/letify-agent/src/main.rs +0 -0
  152. {letify-2.8.4 → letify-2.8.6}/letify-core/letify-driver/Cargo.toml +0 -0
  153. {letify-2.8.4 → letify-2.8.6}/letify-core/letify-driver/src/client.rs +0 -0
  154. {letify-2.8.4 → letify-2.8.6}/letify-core/letify-driver/src/exports.rs +0 -0
  155. {letify-2.8.4 → letify-2.8.6}/letify-core/letify-driver/src/image.rs +0 -0
  156. {letify-2.8.4 → letify-2.8.6}/letify-core/letify-driver/src/lib.rs +0 -0
  157. {letify-2.8.4 → letify-2.8.6}/letify-core/letify-driver/src/status.rs +0 -0
  158. {letify-2.8.4 → letify-2.8.6}/letify-core/letify-driver/src/table.rs +0 -0
  159. {letify-2.8.4 → letify-2.8.6}/letify-core/letify-quic/Cargo.toml +0 -0
  160. {letify-2.8.4 → letify-2.8.6}/letify-core/letify-quic/src/main.rs +0 -0
  161. {letify-2.8.4 → letify-2.8.6}/letify-core/letify-wire/Cargo.toml +0 -0
  162. {letify-2.8.4 → letify-2.8.6}/letify-core/letify-wire/src/lib.rs +0 -0
  163. {letify-2.8.4 → letify-2.8.6}/letify-core/letify-wire/tests/throughput.rs +0 -0
  164. {letify-2.8.4 → letify-2.8.6}/letify-core/rust-toolchain.toml +0 -0
  165. {letify-2.8.4 → letify-2.8.6}/letify-ext/.vscodeignore +0 -0
  166. {letify-2.8.4 → letify-2.8.6}/letify-ext/LICENSE +0 -0
  167. {letify-2.8.4 → letify-2.8.6}/letify-ext/README.md +0 -0
  168. {letify-2.8.4 → letify-2.8.6}/letify-ext/media/icon.svg +0 -0
  169. {letify-2.8.4 → letify-2.8.6}/letify-ext/src/cli.ts +0 -0
  170. {letify-2.8.4 → letify-2.8.6}/letify-ext/src/extension.ts +0 -0
  171. {letify-2.8.4 → letify-2.8.6}/letify-ext/src/history.ts +0 -0
  172. {letify-2.8.4 → letify-2.8.6}/letify-ext/src/model.ts +0 -0
  173. {letify-2.8.4 → letify-2.8.6}/letify-ext/src/poll.ts +0 -0
  174. {letify-2.8.4 → letify-2.8.6}/letify-ext/src/render.ts +0 -0
  175. {letify-2.8.4 → letify-2.8.6}/letify-ext/test/cli.test.ts +0 -0
  176. {letify-2.8.4 → letify-2.8.6}/letify-ext/test/manifest.test.ts +0 -0
  177. {letify-2.8.4 → letify-2.8.6}/letify-ext/test/model.test.ts +0 -0
  178. {letify-2.8.4 → letify-2.8.6}/letify-ext/test/regressions.test.ts +0 -0
  179. {letify-2.8.4 → letify-2.8.6}/letify-ext/tsconfig.json +0 -0
  180. {letify-2.8.4 → letify-2.8.6}/letify_providers.py +0 -0
  181. {letify-2.8.4 → letify-2.8.6}/scripts/sync-main.sh +0 -0
  182. {letify-2.8.4 → letify-2.8.6}/scripts/version.py +0 -0
  183. {letify-2.8.4 → letify-2.8.6}/tests/fake_kaggle_adapter.py +0 -0
  184. {letify-2.8.4 → letify-2.8.6}/tests/fake_modal_adapter.py +0 -0
  185. {letify-2.8.4 → letify-2.8.6}/tests/test_cli.py +0 -0
  186. {letify-2.8.4 → letify-2.8.6}/tests/test_cli_output.py +0 -0
  187. {letify-2.8.4 → letify-2.8.6}/tests/test_colab_keepalive.py +0 -0
  188. {letify-2.8.4 → letify-2.8.6}/tests/test_config.py +0 -0
  189. {letify-2.8.4 → letify-2.8.6}/tests/test_core.py +0 -0
  190. {letify-2.8.4 → letify-2.8.6}/tests/test_data.py +0 -0
  191. {letify-2.8.4 → letify-2.8.6}/tests/test_data_streaming.py +0 -0
  192. {letify-2.8.4 → letify-2.8.6}/tests/test_declare.py +0 -0
  193. {letify-2.8.4 → letify-2.8.6}/tests/test_device.py +0 -0
  194. {letify-2.8.4 → letify-2.8.6}/tests/test_distributed.py +0 -0
  195. {letify-2.8.4 → letify-2.8.6}/tests/test_drive_store.py +0 -0
  196. {letify-2.8.4 → letify-2.8.6}/tests/test_editor_sessions.py +0 -0
  197. {letify-2.8.4 → letify-2.8.6}/tests/test_elice.py +0 -0
  198. {letify-2.8.4 → letify-2.8.6}/tests/test_frames.py +0 -0
  199. {letify-2.8.4 → letify-2.8.6}/tests/test_install.py +0 -0
  200. {letify-2.8.4 → letify-2.8.6}/tests/test_kaggle.py +0 -0
  201. {letify-2.8.4 → letify-2.8.6}/tests/test_kaggle_cookie.py +0 -0
  202. {letify-2.8.4 → letify-2.8.6}/tests/test_kaggle_link.py +0 -0
  203. {letify-2.8.4 → letify-2.8.6}/tests/test_link_log.py +0 -0
  204. {letify-2.8.4 → letify-2.8.6}/tests/test_notebook.py +0 -0
  205. {letify-2.8.4 → letify-2.8.6}/tests/test_parallel_placement.py +0 -0
  206. {letify-2.8.4 → letify-2.8.6}/tests/test_paths.py +0 -0
  207. {letify-2.8.4 → letify-2.8.6}/tests/test_portmap.py +0 -0
  208. {letify-2.8.4 → letify-2.8.6}/tests/test_protocol.py +0 -0
  209. {letify-2.8.4 → letify-2.8.6}/tests/test_remoting.py +0 -0
  210. {letify-2.8.4 → letify-2.8.6}/tests/test_render.py +0 -0
  211. {letify-2.8.4 → letify-2.8.6}/tests/test_ship.py +0 -0
  212. {letify-2.8.4 → letify-2.8.6}/tests/test_store.py +0 -0
  213. {letify-2.8.4 → letify-2.8.6}/tests/test_stubs.py +0 -0
  214. {letify-2.8.4 → letify-2.8.6}/tests/test_tunnel_setup.py +0 -0
  215. {letify-2.8.4 → letify-2.8.6}/tests/test_version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: letify
3
- Version: 2.8.4
3
+ Version: 2.8.6
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
@@ -195,6 +195,7 @@ cache.resume(runtime, "run-1", path) # put the newest one inside a session
195
195
  ```python
196
196
  letify.stash("ckpt", ckpt_dir) # put a file or directory on the machine's Drive store
197
197
  letify.unstash("ckpt", ckpt_dir) # put it back, or None when no handoff has that name
198
+ letify.unstash("ckpt") # no path: a dict of file path to bytes, nothing written to disk
198
199
  ```
199
200
 
200
201
  Both work in the local process and inside a shipped function, on any provider. They need `drive_login` and `drive_project` in the machine configuration and raise `ConfigError` without them. On a one-shot channel, which has no stream back to the local process, they raise `UnsupportedMode` naming the provider. `letify.fetch(tensor)` is a different call and still reads a tensor back.
@@ -777,7 +777,7 @@ The protocol is one JSON object per line. letify sends `{"id": <int>, "op": <nam
777
777
  | Op | Fields | Value |
778
778
  |---|---|---|
779
779
  | `hello` | none | `{"modal": <installed Modal version>}` |
780
- | `create` | `app`, `args`, `packages`, `gpu`, `timeout`, `idle_timeout`, `volumes`, `ports` | `{"sandbox": <id>}`. Runs `app` as an ephemeral app on first use, builds `debian_slim` with `packages` installed, and starts `args` in a sandbox. Each port in `ports` is exposed with Modal `encrypted_ports` |
780
+ | `create` | `app`, `args`, `packages`, `gpu`, `timeout`, `idle_timeout`, `memory`, `volumes`, `ports` | `{"sandbox": <id>}`. Runs `app` as an ephemeral app on first use, builds `debian_slim` with `packages` installed, and starts `args` in a sandbox. Each port in `ports` is exposed with Modal `encrypted_ports` |
781
781
  | `tunnel` | `sandbox`, `port` | `{"host": <text>, "port": <int>, "tls": <bool>}`. The address that reaches `port` inside the sandbox, from `Sandbox.tunnels()`. `tls` is true when the connection has to be made with TLS, which an encrypted port needs |
782
782
  | `write` | `sandbox`, `data` | `null`. `data` is base64 of at most 1 MiB. Writes the decoded bytes to the sandbox's standard input and drains it. Modal refuses a write that would buffer more than 2 MiB, so the channel splits a larger frame into `write` requests of 1 MiB, in order |
783
783
  | `read_until` | `sandbox`, `prefixes` | `{"lines": [...], "eof": <bool>}`. The sandbox's stdout lines up to and including the first that starts with one of `prefixes`, or every line left when the stream ends |
@@ -812,8 +812,14 @@ Modal also bounds a sandbox that nothing terminates. `create` passes `idle_timeo
812
812
 
813
813
  It passes `timeout`, the entry option `timeout`, as the sandbox's maximum lifetime, and the default is the longest Modal accepts, 86400 s, measured as accepted. It is not a shorter number letify picked, for the reason The declaration gives for `timeout` having no default: a deadline letify invented would end a two hour training run at whatever hour it guessed. The default was 3600 s, exactly one hour, and it ended two Modal sessions of a real training run at one hour each with nothing saying why, which is that sentence happening. An account that wants a hard cap sets `timeout` itself.
814
814
 
815
+ It passes `memory`, the entry option `memory_gib` converted to MiB, as the sandbox's memory request, and passes nothing when the account does not set it, so Modal's default applies. A training process whose RAM grows past what the sandbox gets is killed with SIGKILL, which the channel sees as a worker that stopped without replying; an account whose runs need more sets `memory_gib`, for example `memory_gib = 64`. A value that is not a positive number raises `ConfigError` naming `memory_gib`.
816
+
815
817
  A sandbox that reaches that lifetime takes its worker with it, so the channel sees a worker that stopped without replying. Where the session's age is within a minute of the lifetime letify asked for, the failure says so instead of listing causes: `the Modal sandbox reached the <seconds> s lifetime letify asked for; raise timeout on the account to run longer`.
816
818
 
819
+ Otherwise a Modal worker that stopped without replying is followed by one `poll` of its sandbox from a second adapter process, waiting at most 30 s for the exit code, and the failure names what the poll found instead of listing causes: `the Modal sandbox exited with code <n>` for a sandbox that ended, with `, which is SIGKILL, most often the container running out of memory` added for 137, and `the Modal sandbox is still running, so the worker process died inside it` for one still running. A poll that fails or times out leaves the message as it was. The message is what tells an out-of-memory kill from a preemption, which a training run that died twice in one day could not.
820
+
821
+ A worker reached through a subprocess (`PersistentChannel`, for example `ssh` to a Colab or Kaggle machine) that exits before it says hello, or while a call is running, has its exit code named in the message text, so a user can tell a lost connection from a kill from a crash. A signal death is reported as the shell does, 128 plus the signal number. The message reads `the worker exited with code <n>` followed by a short meaning: `255` adds `: ssh could not reach or lost the machine`, `128+N` adds `: killed by signal N`, with `137` adding `, which is SIGKILL, most often out of memory`, and any other nonzero code adds nothing. A worker that never said hello reads `the worker exited before it was ready, with code <n>` followed by the same meaning. The last 2000 characters of the process's standard error follow under `--- last remote output ---`, and the startup failure also keeps the whole text as its `stderr` attribute. A channel whose provider can name a cause itself, such as Modal, keeps that cause. When the command's program is `ssh` or `ssh.exe`, `PersistentChannel` starts it as `ssh -E <file> ...`, with a fresh temporary file per channel placed right after the program. `ControlPersist` forks a master whose standard error is /dev/null, so its errors (`Timeout, server not responding`, `Connection reset`, `closed by remote host`, `Broken pipe`) reach only that file. A death message, both before hello and during a call, appends the last 2000 characters of the file under `--- ssh log ---` when it is not empty, after the standard error of the ssh client. Other commands are not changed. The file is deleted when the channel closes.
822
+
817
823
  ### Modal data channel <!-- id: modal-data-channel -->
818
824
 
819
825
  > Once the worker has said hello over the sandbox's standard input and output, the channel moves its frames to a TCP connection through a Modal encrypted port. Standard input and output stay the control path and the fallback.
@@ -1556,10 +1562,12 @@ A token is reused until 60 s before it expires. A service account key file is re
1556
1562
 
1557
1563
  A handoff is what a volume cannot be: a value written on one provider and read on another, such as a checkpoint written on Kaggle and resumed on `dept_gpu`. A volume belongs to one provider and stays attached to it, so letify keeps the two apart. `stash` and `unstash` take no provider and no volume.
1558
1564
 
1559
- `stash(name, path)` packs `path`, a file or a directory, into an uncompressed tar whose members are relative to `path`'s parent, so the tar holds the one entry `path`'s base name. Its digest, letify's content hash as the blob store uses it, names the blob in the `letify` folder, and the ref `ref stash/<name>` holds that digest. It returns the digest once the upload has finished and the ref is written. Stashing a name again moves the ref, and the blob the ref named before is deleted unless another `stash/` ref still names it. An upload of the same digest that Drive already holds sends nothing. The tar keeps each member's modification time, so a file rewritten with the same bytes gives a new digest.
1565
+ `stash(name, path)` packs `path`, a file or a directory, into an uncompressed tar whose members are relative to `path`'s parent, so the tar holds the one entry `path`'s base name. Its digest, letify's content hash as the blob store uses it, names the blob in the `letify` folder, and the ref `ref stash/<name>` holds that digest. It returns the digest once the upload has finished and the ref is written. Stashing a name again moves the ref, and the blob the ref named before is deleted unless another `stash/` ref still names it. Whether another ref names it is decided from one listing of the `letify` folder: a ref holds the digest text and nothing else, so its Drive `md5Checksum` names its content, and no ref is downloaded. An upload of the same digest that Drive already holds sends nothing. The tar keeps each member's modification time, so a file rewritten with the same bytes gives a new digest.
1560
1566
 
1561
1567
  `unstash(name, path)` reads the ref, downloads the blob, checks its digest, and unpacks it so that the tar's one entry lands at `path`, replacing what `path` held. It returns `path` as a `pathlib.Path`. When no ref `stash/<name>` exists it returns `None` and changes nothing, so `if letify.unstash("ckpt", ckpt_dir) is None:` starts from scratch. A digest that does not match raises `RuntimeFailure` and leaves `path` untouched.
1562
1568
 
1569
+ `unstash(name)` with no path returns the handoff in memory and writes nothing to disk: a `dict[str, bytes]` that maps the path of each regular file in the tar, as stored (`ckpt/model.safetensors` for a directory handoff, `checkpoint.safetensors` for a one-file handoff), to that file's bytes. Directories are not entries. It returns `None` when no ref `stash/<name>` exists. Every byte of the download is hashed as it is read, and the dict is returned only after the digest matches; a digest that does not match raises `RuntimeFailure` and returns no dict. Peak memory is about the handoff's size, not twice it, because the download is read sequentially and each member's bytes are read from the response with one `read(size)` call into the bytes object the caller receives: no chunk list is joined, no archive is held whole, and tar headers (GNU long name records and pax records) are parsed as they arrive. It works in both places below with the same result.
1570
+
1563
1571
  A name is 1 to 128 characters of letters, digits, `.`, `_` and `-`, and does not start with `.`; anything else raises `ValueError` naming the rule. Names are shared by every project on the machine's Drive store, so a project that wants its own space puts its name in the handoff name.
1564
1572
 
1565
1573
  The name `unstash` is not `fetch`, because `letify.fetch(tensor)` already reads a tensor back in `host=local`.
@@ -1567,7 +1575,7 @@ The name `unstash` is not `fetch`, because `letify.fetch(tensor)` already reads
1567
1575
  Both run in two places with the same result:
1568
1576
 
1569
1577
  - **In the local process**, the client moves the bytes itself with the `DriveBackend` of Google Drive is a store, not a volume.
1570
- - **Inside a shipped function**, the worker moves the bytes between the runtime and Drive and they never cross the client's link. The worker asks the local process for a grant with a one-way request on a reserved stream, as `all_reduce` asks for a peer table, and the local process answers with the light op `drive_grant`: for `stash`, the existing digest or a resumable upload session URL for the archive's size, which the local process creates as Google Drive is a store, not a volume describes; for `unstash`, the blob's media URL with the short-lived request headers, or a reply that the name has no ref. After a `stash` upload the worker reports the Drive file id and the local process writes the ref and deletes the replaced blob. The worker holds no refresh token and writes no header to disk.
1578
+ - **Inside a shipped function**, the worker moves the bytes between the runtime and Drive and they never cross the client's link. The worker asks the local process for a grant with a one-way request on a reserved stream, as `all_reduce` asks for a peer table, and the local process answers with the light op `drive_grant`: for `stash`, the existing digest or a resumable upload session URL for the archive's size, which the local process creates as Google Drive is a store, not a volume describes; for `unstash`, the blob's media URL with the short-lived request headers, or a reply that the name has no ref. After a `stash` upload the worker reports the Drive file id and the local process writes the ref and answers. The replaced blob is deleted after that, on a thread of the local process, so the answer never waits for the deletion and the worker's grant timeout counts only the ref write. A deletion that fails prints `letify: drive handoff <name>: the replaced blob <digest> was not deleted: <error>` on stderr and leaves the blob; the worker's `stash` has already returned. A request the worker cannot send prints `letify: drive request <kind> <name> was not sent: <error>` on the worker's stderr, so a grant timeout that follows names its cause. The worker holds no refresh token and writes no header to disk. The local process prints one line on stderr for every grant it answers, `letify: drive grant <kind> <name>: answered in <s> s`, and when sending the answer fails, `letify: drive grant <kind> <name>: the answer was not delivered after <s> s: <error>`. A worker that waits out its grant timeout can then be matched to the local side: either no request arrived, or the answer did not reach it. When the call ends while a grant is still being made, for example a slow Drive call, the watcher waits 30 s for it and then prints `letify: drive grant <kind> <name>: still running after <s> s when the call ended; its result will not be reported`, so the missing `answered in` line has a stated cause.
1571
1579
 
1572
1580
  A body run on a one-shot channel, which has no stream back to the local process, cannot ask for a grant, so `stash` and `unstash` there raise `UnsupportedMode` naming the provider rather than reading the runtime's own configuration.
1573
1581
 
@@ -1723,7 +1731,7 @@ A `Shell` subclass differs from its parent only in its `Rendezvous` and its stra
1723
1731
 
1724
1732
  Strategies are raced rather than tried in turn, so a strategy that times out does not delay the others. Once the first strategy that can carry the probe connects, the pipeline waits a grace period of 2 s for lower ranked strategies before choosing.
1725
1733
 
1726
- A strategy that cannot carry the probe, such as the provider fallback, does not start the grace period when it connects. It is held back until every strategy that can carry the probe has failed, or until the race timeout of 60 s passes with none of them connected. A rendezvous that takes seconds, such as `colab exec` before a TCP punch, therefore runs inside its strategy's own attempt time, not inside the grace period.
1734
+ A strategy that cannot carry the probe, such as the provider fallback, does not start the grace period when it connects. It is held back until every strategy that can carry the probe has failed, or until the race timeout of 180 s passes with none of them connected. A rendezvous that takes seconds, such as `colab exec` before a TCP punch, therefore runs inside its strategy's own attempt time, not inside the grace period.
1727
1735
 
1728
1736
  Every connected strategy is probed: 30 round trips, then a warm-up transfer that is discarded, then 2 s of transfer in each direction. The warm-up is `min(2.0, max(0.5, 8 x round trip))` seconds, long enough for TCP to leave slow start, and it runs as a transfer of its own on the same connection so the congestion window it opens carries into the measured one. Without it the probe charges a far link for its own ramp: the window is fixed in seconds while the ramp is fixed in round trips, so at a 190 ms round trip more than half the 2 s is spent ramping and the reported throughput is the ramp average, while at 47 ms it is a seventh. A live Kaggle runtime measured up 5.0 MiB/s at 190 ms and up 46.1 MiB/s at 47 ms on the same account, and the throughput times the round trip is 0.95 MiB against 2.18 MiB, which is a window that never finished opening rather than a path that cannot carry more. A strategy whose probe is below the account's floor, [Link floor](#link-floor), is rejected outright, before any comparison between strategies. Of what remains, a strategy whose throughput in either direction is below 25% of the fastest connected strategy in that direction is rejected. The lowest ranked strategy that remains is chosen. When only one strategy connects, it is chosen without comparison, but it is still held to the floor: a single connected strategy below the floor fails the connection rather than being used because nothing else was available.
1729
1737
 
@@ -1803,7 +1811,7 @@ A connection to the agent is told apart by its first bytes: `SSH-` is spliced to
1803
1811
 
1804
1812
  Both sides learn their public mapping from STUN servers reached over TCP on port 443, because networks that restrict outbound ports usually still allow 443. A punch starts at a time both sides agree on through the rendezvous. Each side connects from its bound port to the other's mapping and listens on the same port, so whichever direction's SYN arrives first completes the connection.
1805
1813
 
1806
- The window has to cover the rendezvous, not just the agreed start. The start time is fixed before the rendezvous is sent, and the rendezvous takes as long as it takes: for a Colab runtime it is a `colab exec` that also starts an SSH server, bounded only by the 30 s connect timeout. The remote side reaches the agreed time while the answer is still travelling back, so the user's side begins later by the return trip. A window of 15 s therefore closed on the remote side before the user's side had begun whenever the rendezvous was slow, which is why the same account punched in 10.7 s one day and reported `did not connect in time` the next. The default window is 45 s, longer than the connect timeout, so the two sides overlap however long the rendezvous takes.
1814
+ The window has to cover the rendezvous, not just the agreed start. The start time is fixed before the rendezvous is sent, and the rendezvous takes as long as it takes: for a Colab runtime it is a `colab exec` that also starts an SSH server, bounded only by the 120 s connect timeout. The remote side reaches the agreed time while the answer is still travelling back, so the user's side begins later by the return trip. A window of 15 s therefore closed on the remote side before the user's side had begun whenever the rendezvous was slow, which is why the same account punched in 10.7 s one day and reported `did not connect in time` the next. The default window is 150 s, longer than the 120 s connect timeout, so the two sides overlap however long the rendezvous takes.
1807
1815
 
1808
1816
  The remote side also reports `punch_until`, the time on its own clock at which it stops, and the user's side stops then too rather than dialing alone for a window of its own. A punch that is already over fails at once and names that, instead of spending the window finding out.
1809
1817
 
@@ -1817,7 +1825,7 @@ A punch that ends with nothing connected names what happened to its dials, becau
1817
1825
 
1818
1826
  The dial is still repeated until the window closes even when it is refused, because the listener may yet receive the connection the peer is completing from its own side.
1819
1827
 
1820
- The remote side's punch window is 45 s, or the request's `window` in seconds when it sets one. A punch that does not connect within it, or fails with a refused or unreachable connection, is expected, because the Tailcat link is already carrying the session. The agent then writes one line to stderr, `letify agent: TCP punch with <host>:<port> did not connect within <window> s; the Tailcat link is used instead`, or for a refused or unreachable peer `letify agent: TCP punch with <host>:<port> failed (<reason>); the Tailcat link is used instead`, and no traceback. Any other exception in the punch or the splice keeps its traceback.
1828
+ The remote side's punch window is 150 s, or the request's `window` in seconds when it sets one. A punch that does not connect within it, or fails with a refused or unreachable connection, is expected, because the Tailcat link is already carrying the session. The agent then writes one line to stderr, `letify agent: TCP punch with <host>:<port> did not connect within <window> s; the Tailcat link is used instead`, or for a refused or unreachable peer `letify agent: TCP punch with <host>:<port> failed (<reason>); the Tailcat link is used instead`, and no traceback. Any other exception in the punch or the splice keeps its traceback.
1821
1829
 
1822
1830
  Every SSH command letify builds sets `HostKeyAlias=letify-<alias>`, `StrictHostKeyChecking=accept-new` and `UserKnownHostsFile=~/.letify/accounts/<alias>/known_hosts`. Building the command creates that account directory with mode 0700 when it is missing, because `ssh` creates only `~/.ssh` and otherwise cannot record the key: it prints `Failed to add the host to the list of known hosts` on every connection and never pins the key. A host key that differs from the recorded one still fails the connection.
1823
1831
 
@@ -1953,7 +1961,7 @@ Colab limits outbound UDP to roughly 200 packets per second, so rank 3 is expect
1953
1961
 
1954
1962
  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.
1955
1963
 
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.
1964
+ 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. Two strategies rendezvous at once on a Colab VM, so the check, the file write and the start run under an exclusive `flock` on `/run/letify-sshd.lock`, and the check is repeated once the lock is held, so a caller that waited finds the first caller's server and returns. The file is written to a temporary file in `/etc/ssh` and moved onto `sshd_config.letify` with `os.replace`, so a server reading it sees the old file or the whole new one. 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.
1957
1965
 
1958
1966
  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.
1959
1967
 
@@ -29,7 +29,7 @@ from __future__ import annotations
29
29
 
30
30
  import os
31
31
  from pathlib import Path
32
- from typing import Final
32
+ from typing import Final, overload
33
33
 
34
34
  from .declare.cache import session_cache
35
35
  from .declare.env import Env
@@ -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.4"
71
+ __version__ = "2.8.6"
72
72
 
73
73
 
74
74
  def fetch(tensor): # type: ignore[no-untyped-def]
@@ -93,13 +93,29 @@ def stash(name: str, path: str | os.PathLike[str]) -> str:
93
93
  return stash_handoff(name, path)
94
94
 
95
95
 
96
- def unstash(name: str, path: str | os.PathLike[str]) -> Path | None:
96
+ @overload
97
+ def unstash(name: str) -> dict[str, bytes] | None: ...
98
+ @overload
99
+ def unstash(name: str, path: str | os.PathLike[str]) -> Path | None: ...
100
+ def unstash(
101
+ name: str, path: str | os.PathLike[str] | None = None
102
+ ) -> Path | dict[str, bytes] | None:
97
103
  """Put the handoff ``name`` at ``path``, replacing what was there.
98
104
 
99
105
  Returns ``path``, or None when no handoff has that name, in which case nothing changed.
106
+
107
+ With no ``path`` the handoff is returned in memory and nothing is written to disk: a
108
+ ``dict[str, bytes]`` from the path of each regular file in the handoff, as stored
109
+ (``ckpt/model.safetensors``, or ``checkpoint.safetensors`` for a one-file handoff), to
110
+ its bytes. Directories are not entries, and None means no handoff has that name. The
111
+ dict is returned only after the digest matches; a mismatch raises ``RuntimeFailure``.
112
+ Peak memory is about the handoff's size, because members are read straight from the
113
+ download.
100
114
  """
101
115
  from .store.handoff import unstash as unstash_handoff
102
116
 
117
+ if path is None:
118
+ return unstash_handoff(name)
103
119
  return unstash_handoff(name, path)
104
120
 
105
121
 
@@ -1380,8 +1380,14 @@ def _drive_ask(message):
1380
1380
  """
1381
1381
  try:
1382
1382
  _SENDER.message(REQUEST, DRIVE_STREAM, message)
1383
- except OSError:
1384
- pass
1383
+ except OSError as exc:
1384
+ # The stash or unstash then waits out its grant timeout; this line names why.
1385
+ print(
1386
+ f"letify: drive request {message.get('kind')} {message.get('name')} "
1387
+ f"was not sent: {exc}",
1388
+ file=sys.stderr,
1389
+ flush=True,
1390
+ )
1385
1391
 
1386
1392
 
1387
1393
  def _op_drive_grant(request):
@@ -31,7 +31,7 @@ import tempfile
31
31
  import threading
32
32
  import time
33
33
  import weakref
34
- from collections.abc import Mapping
34
+ from collections.abc import Callable, Mapping
35
35
  from typing import IO, TYPE_CHECKING, Any
36
36
 
37
37
  from ..config import ProviderConfig
@@ -133,6 +133,22 @@ def lifetime_detail(*, age_s: float, lifetime_s: int) -> str | None:
133
133
  )
134
134
 
135
135
 
136
+ def exit_cause(found: tuple[bool, int | None] | None) -> str | None:
137
+ """What a poll of a sandbox whose worker stopped says, or None when it is unknown.
138
+
139
+ Spec "Modal": the exit code tells an out of memory kill from a preemption.
140
+ """
141
+ if found is None:
142
+ return None
143
+ ended, code = found
144
+ if not ended:
145
+ return "the Modal sandbox is still running, so the worker process died inside it"
146
+ text = f"the Modal sandbox exited with code {code}"
147
+ if code == 137:
148
+ text += ", which is SIGKILL, most often the container running out of memory"
149
+ return text
150
+
151
+
136
152
  class VolumePathMissing(RuntimeFailure):
137
153
  """The adapter answered that a volume path does not exist."""
138
154
 
@@ -419,6 +435,21 @@ class Modal(Provider):
419
435
  value = self.config.option("idle_timeout", IDLE_TIMEOUT)
420
436
  return max(1, int(value))
421
437
 
438
+ @property
439
+ def sandbox_memory_mib(self) -> int | None:
440
+ """The sandbox's memory request in MiB from ``memory_gib``, or None for Modal's own.
441
+
442
+ Spec "Modal": a training process past what the sandbox gets is killed with SIGKILL.
443
+ """
444
+ value = self.config.option("memory_gib")
445
+ if value is None:
446
+ return None
447
+ if isinstance(value, bool) or not isinstance(value, (int, float)) or value <= 0:
448
+ raise ConfigError(
449
+ f"{self.alias}: memory_gib has to be a positive number of GiB, not {value!r}"
450
+ )
451
+ return int(value * 1024)
452
+
422
453
  def adapter(self) -> Adapter:
423
454
  """The adapter acting as this account, started on its first request."""
424
455
  if self._adapter is None or self._adapter.closed:
@@ -484,6 +515,7 @@ class Modal(Provider):
484
515
  gpu=self.wire_name(runtime.instance) or None,
485
516
  timeout=self.sandbox_timeout_s,
486
517
  idle_timeout=self.sandbox_idle_timeout_s,
518
+ memory=self.sandbox_memory_mib,
487
519
  ports=[] if data_port is None else [data_port],
488
520
  )
489
521
  sandbox = str(created["sandbox"])
@@ -491,17 +523,14 @@ class Modal(Provider):
491
523
  return SandboxChannel(
492
524
  adapter, sandbox, name=runtime.name, data_port=data_port, streams=streams,
493
525
  lifetime_s=self.sandbox_timeout_s,
526
+ probe=lambda: self.poll_sandbox(sandbox),
494
527
  )
495
528
 
496
- def runtime_state(self, runtime: Runtime) -> RuntimeState:
497
- """Poll the sandbox from a second adapter process, never the one carrying the call.
529
+ def poll_sandbox(self, sandbox: str) -> tuple[bool, int | None] | None:
530
+ """Poll a sandbox once from a second adapter: ``(ended, exit code)``, or None if unknown.
498
531
 
499
- Spec "Lifetime", the lease. The adapter carrying the call may be blocked in a
500
- request, so a short-lived one asks. Any failure answers unknown.
532
+ A short-lived adapter asks, because the one carrying the call may be blocked.
501
533
  """
502
- sandbox = self._sandboxes.get(runtime.name)
503
- if sandbox is None:
504
- return RuntimeState()
505
534
  other = Adapter.for_account(self.alias)
506
535
  # A poll that hangs ends the second adapter, which makes the request raise.
507
536
  limit = threading.Timer(POLL_LIMIT, other.abort)
@@ -510,11 +539,26 @@ class Modal(Provider):
510
539
  try:
511
540
  code = other.request("poll", sandbox=sandbox)
512
541
  except (LetifyError, ValueError, OSError):
513
- return RuntimeState()
542
+ return None
514
543
  finally:
515
544
  limit.cancel()
516
545
  other.close()
517
- if code is None:
546
+ return (code is not None, code)
547
+
548
+ def runtime_state(self, runtime: Runtime) -> RuntimeState:
549
+ """Poll the sandbox from a second adapter process, never the one carrying the call.
550
+
551
+ Spec "Lifetime", the lease. The adapter carrying the call may be blocked in a
552
+ request, so a short-lived one asks. Any failure answers unknown.
553
+ """
554
+ sandbox = self._sandboxes.get(runtime.name)
555
+ if sandbox is None:
556
+ return RuntimeState()
557
+ found = self.poll_sandbox(sandbox)
558
+ if found is None:
559
+ return RuntimeState()
560
+ ended, code = found
561
+ if not ended:
518
562
  return RuntimeState("running")
519
563
  return RuntimeState(
520
564
  "ended", f"the Modal sandbox ended (exit code {code}) while the call was running"
@@ -686,8 +730,11 @@ class SandboxChannel(FramedChannel):
686
730
  data_port: int | None = None,
687
731
  streams: int = 1,
688
732
  lifetime_s: int = 0,
733
+ probe: Callable[[], tuple[bool, int | None] | None] | None = None,
689
734
  ):
690
735
  self.adapter = adapter
736
+ #: Polls the sandbox from a second adapter, see ``Modal.poll_sandbox``.
737
+ self.probe = probe
691
738
  self.sandbox = sandbox
692
739
  self.name = name
693
740
  self.data_port = data_port
@@ -716,6 +763,7 @@ class SandboxChannel(FramedChannel):
716
763
  wire.chunks_readinto(self._read_chunks),
717
764
  self._emit,
718
765
  death_detail=self._raw_text,
766
+ death_cause=self._exit_cause,
719
767
  )
720
768
  self._connection = self._stdio
721
769
  self._send_worker()
@@ -730,6 +778,17 @@ class SandboxChannel(FramedChannel):
730
778
  )
731
779
  return f"{said}\n{text}" if said else text
732
780
 
781
+ def _exit_cause(self) -> str | None:
782
+ """What one poll of the sandbox found, or None to keep the usual causes.
783
+
784
+ Spec "Modal": a lifetime death is already named by ``_raw_text``, so it is not polled.
785
+ """
786
+ if self.probe is None or lifetime_detail(
787
+ age_s=time.monotonic() - self.opened_at, lifetime_s=self.lifetime_s
788
+ ):
789
+ return None
790
+ return exit_cause(self.probe())
791
+
733
792
  def _send_worker(self) -> None:
734
793
  # After a reexec the data connection is gone, so the source goes back over stdio.
735
794
  if self._connection is not self._stdio:
@@ -821,7 +880,12 @@ class SandboxChannel(FramedChannel):
821
880
  )
822
881
  carrier.timeout = DATA_CONNECT_TIMEOUT
823
882
  connection = Connection(
824
- self.name, carrier.send, carrier.recv_into, self._emit, death_detail=self._raw_text
883
+ self.name,
884
+ carrier.send,
885
+ carrier.recv_into,
886
+ self._emit,
887
+ death_detail=self._raw_text,
888
+ death_cause=self._exit_cause,
825
889
  )
826
890
  # The connect timeout still applies, so a missing hello fails here.
827
891
  connection.wait_hello()
@@ -109,6 +109,7 @@ class Adapter:
109
109
  gpu=request.get("gpu") or None,
110
110
  timeout=int(request["timeout"]),
111
111
  idle_timeout=int(request["idle_timeout"]) if request.get("idle_timeout") else None,
112
+ memory=int(request["memory"]) if request.get("memory") else None,
112
113
  volumes={
113
114
  str(path): modal.Volume.from_name(str(name), create_if_missing=True)
114
115
  for path, name in (request.get("volumes") or {}).items()
@@ -27,6 +27,7 @@ import os
27
27
  import select
28
28
  import subprocess
29
29
  import sys
30
+ import tempfile
30
31
  import threading
31
32
  import time
32
33
  from collections.abc import Callable, Iterator, Sequence
@@ -258,6 +259,7 @@ class Connection:
258
259
  emit: Callable[[str, bytes], None],
259
260
  *,
260
261
  death_detail: Callable[[], str] = lambda: "",
262
+ death_cause: Callable[[], str | None] = lambda: None,
261
263
  poll_fd: int | None = None,
262
264
  ):
263
265
  self.name = name
@@ -276,6 +278,8 @@ class Connection:
276
278
  self._receiver = wire.Receiver(readinto)
277
279
  self._emit = emit
278
280
  self._death_detail = death_detail
281
+ #: Names what ended the worker when the provider can tell, replacing the usual causes.
282
+ self._death_cause = death_cause
279
283
  #: Guards ``_reading`` and wakes waiters when a reply, a hello or a failure arrives.
280
284
  self._turn = threading.Condition()
281
285
  self._reading = False
@@ -383,10 +387,16 @@ class Connection:
383
387
  return
384
388
  if message is None:
385
389
  detail = self._death_detail()
390
+ cause = self._death_cause()
391
+ causes = (
392
+ f": {cause}."
393
+ if cause
394
+ else ". The usual causes are an out of memory kill, a preempted session, "
395
+ "or a crash below Python."
396
+ )
386
397
  message = (
387
398
  f"{self.name}: the worker stopped without replying, so the process died "
388
- f"before it finished. The usual causes are an out of memory kill, a "
389
- f"preempted session, or a crash below Python.\n--- last remote output ---\n"
399
+ f"before it finished{causes}\n--- last remote output ---\n"
390
400
  f"{self._tail.text()[-2000:]}{detail[-2000:]}"
391
401
  )
392
402
  with self._turn:
@@ -770,6 +780,27 @@ class FramedChannel(Channel):
770
780
  return connection.pipeline(window=window, timeout=timeout, kill=self._kill)
771
781
 
772
782
 
783
+ def exit_text(code: int | None) -> str | None:
784
+ """Name a subprocess exit code with its short meaning, or None while it still runs.
785
+
786
+ The text reads ``code <n>`` plus a meaning, to follow ``the worker exited with``.
787
+
788
+ Spec "Modal": a negative code is a signal, reported as the shell's 128 plus N.
789
+ """
790
+ if code is None:
791
+ return None
792
+ if code < 0:
793
+ code = 128 - code
794
+ text = f"code {code}"
795
+ if code == 255:
796
+ text += ": ssh could not reach or lost the machine"
797
+ elif code > 128:
798
+ text += f": killed by signal {code - 128}"
799
+ if code == 137:
800
+ text += ", which is SIGKILL, most often out of memory"
801
+ return text
802
+
803
+
773
804
  class PersistentChannel(FramedChannel):
774
805
  """One worker process, kept alive behind a pipe pair.
775
806
 
@@ -789,6 +820,8 @@ class PersistentChannel(FramedChannel):
789
820
  self._start_lock = threading.Lock()
790
821
  self._stderr = _Tail()
791
822
  self._stderr_reader: threading.Thread | None = None
823
+ #: Where ``ssh -E`` appends the log of the client and its forked master.
824
+ self._ssh_log: str | None = None
792
825
 
793
826
  # -- lifecycle -----------------------------------------------------------
794
827
 
@@ -796,9 +829,10 @@ class PersistentChannel(FramedChannel):
796
829
  with self._start_lock:
797
830
  if self._process is not None:
798
831
  return
832
+ command = self._with_ssh_log()
799
833
  try:
800
834
  process = subprocess.Popen(
801
- self.command,
835
+ command,
802
836
  stdin=subprocess.PIPE,
803
837
  stdout=subprocess.PIPE,
804
838
  stderr=subprocess.PIPE,
@@ -806,6 +840,7 @@ class PersistentChannel(FramedChannel):
806
840
  env=self.env,
807
841
  )
808
842
  except OSError as exc:
843
+ self._drop_ssh_log()
809
844
  raise RuntimeFailure(
810
845
  f"{self.name}: could not start the worker: {exc}",
811
846
  command=" ".join(self.command),
@@ -825,7 +860,8 @@ class PersistentChannel(FramedChannel):
825
860
  wire.fd_writer(process.stdin.fileno()),
826
861
  process.stdout.readinto, # type: ignore[attr-defined]
827
862
  self._emit,
828
- death_detail=self._stderr_text,
863
+ death_detail=lambda: self._stderr_text() + self._ssh_log_section(),
864
+ death_cause=self._exit_cause,
829
865
  poll_fd=process.stdout.fileno(),
830
866
  )
831
867
  self._send_worker()
@@ -835,6 +871,48 @@ class PersistentChannel(FramedChannel):
835
871
  if self._process is not None:
836
872
  self._process.kill()
837
873
 
874
+ def _with_ssh_log(self) -> list[str]:
875
+ """The command, with ``-E <file>`` after the program when that program is ssh.
876
+
877
+ Spec "Modal": ControlPersist forks a master whose standard error is /dev/null, so
878
+ the transport's own errors reach only this file.
879
+ """
880
+ self._drop_ssh_log()
881
+ if os.path.basename(self.command[0]) not in ("ssh", "ssh.exe"):
882
+ return self.command
883
+ handle, self._ssh_log = tempfile.mkstemp(prefix="letify-ssh-", suffix=".log")
884
+ os.close(handle)
885
+ return [self.command[0], "-E", self._ssh_log, *self.command[1:]]
886
+
887
+ def _drop_ssh_log(self) -> None:
888
+ log, self._ssh_log = self._ssh_log, None
889
+ if log is not None:
890
+ with contextlib.suppress(OSError):
891
+ os.unlink(log)
892
+
893
+ def _ssh_log_section(self) -> str:
894
+ """The last 2000 characters of the ssh log under a heading, or empty when it is empty."""
895
+ if self._ssh_log is None:
896
+ return ""
897
+ try:
898
+ with open(self._ssh_log, "rb") as handle:
899
+ text = handle.read().decode("utf-8", "replace").strip()
900
+ except OSError:
901
+ return ""
902
+ return f"\n--- ssh log ---\n{text[-2000:]}" if text else ""
903
+
904
+ def _exit_cause(self) -> str | None:
905
+ """What the exit code of the worker process says, once the process has exited."""
906
+ process = self._process
907
+ if process is None:
908
+ return None
909
+ try:
910
+ process.wait(timeout=2)
911
+ except subprocess.TimeoutExpired:
912
+ return None
913
+ code = exit_text(process.returncode)
914
+ return f"the worker exited with {code}" if code else None
915
+
838
916
  def _stderr_text(self) -> str:
839
917
  """The process's own standard error, complete once the process has exited."""
840
918
  process = self._process
@@ -850,10 +928,15 @@ class PersistentChannel(FramedChannel):
850
928
  def _startup_failure(self, expired: bool, cause: Exception) -> Exception:
851
929
  if expired:
852
930
  return super()._startup_failure(expired, cause)
931
+ stderr = self._stderr_text().strip()
932
+ code = exit_text(self._process.returncode) if self._process else None
933
+ reason = f", with {code}" if code else ""
853
934
  return RuntimeFailure(
854
- f"{self.name}: the worker exited before it was ready",
935
+ f"{self.name}: the worker exited before it was ready{reason}\n"
936
+ "--- last remote output ---\n"
937
+ f"{stderr[-2000:]}{self._ssh_log_section()}",
855
938
  command=" ".join(self.command),
856
- stderr=self._stderr_text().strip(),
939
+ stderr=stderr,
857
940
  )
858
941
 
859
942
  def close(self) -> None:
@@ -876,6 +959,7 @@ class PersistentChannel(FramedChannel):
876
959
  finally:
877
960
  if self._stderr_reader is not None:
878
961
  self._stderr_reader.join(1)
962
+ self._drop_ssh_log()
879
963
 
880
964
  @property
881
965
  def alive(self) -> bool:
@@ -890,7 +974,8 @@ class PersistentChannel(FramedChannel):
890
974
  if process is None or connection is None:
891
975
  raise RuntimeLost(f"{self.name}: the channel is closed")
892
976
  if process.poll() is not None:
893
- raise RuntimeLost(f"{self.name}: the worker exited with code {process.returncode}")
977
+ code = exit_text(process.returncode)
978
+ raise RuntimeLost(f"{self.name}: the worker exited with {code}")
894
979
  return connection.request(payload, timeout=timeout, kill=self._kill)
895
980
 
896
981
 
@@ -17,6 +17,7 @@ than one request per digest.
17
17
 
18
18
  from __future__ import annotations
19
19
 
20
+ import hashlib
20
21
  import json
21
22
  import os
22
23
  import shutil
@@ -105,6 +106,24 @@ def download_to(url: str, headers: dict[str, str], destination: str | os.PathLik
105
106
  return os.path.getsize(destination)
106
107
 
107
108
 
109
+ def open_stream(url: str, headers: dict[str, str]) -> Any:
110
+ """Open one Drive file for sequential reads and return the response.
111
+
112
+ The caller reads it and closes it. A failure to open raises ``RuntimeFailure`` as
113
+ ``download_to`` does.
114
+ """
115
+ request = urllib.request.Request(url, headers=headers)
116
+ try:
117
+ return urllib.request.urlopen(request, timeout=3600)
118
+ except urllib.error.HTTPError as exc:
119
+ detail = exc.read()[:300].decode(errors="replace")
120
+ raise RuntimeFailure(f"Drive download returned {exc.code}: {detail}") from exc
121
+ except (urllib.error.URLError, OSError) as exc:
122
+ raise RuntimeFailure(f"Drive download failed: {exc}") from exc
123
+ finally:
124
+ request = None # type: ignore[assignment]
125
+
126
+
108
127
  class AccountTokenSource(TokenSource):
109
128
  """Access tokens minted from the refresh token in one account's ``token.json``."""
110
129
 
@@ -354,22 +373,31 @@ class DriveBackend(Backend):
354
373
  self._call("DELETE", url, what=f"deleting {digest}")
355
374
  self._ids.pop(digest, None)
356
375
 
357
- def refs_with_prefix(self, prefix: str) -> dict[str, str]:
358
- """Every ref whose name starts with ``prefix``, as name to digest, in one listing."""
359
- found: dict[str, str] = {}
376
+ def ref_holds(self, prefix: str, digest: str) -> bool:
377
+ """Whether a ref whose name starts with ``prefix`` holds ``digest``, from one listing.
378
+
379
+ A ref holds the digest and nothing else, so the listing's ``md5Checksum`` of each ref
380
+ names its content and no ref is downloaded. An entry listed without a checksum is read.
381
+ """
360
382
  wanted = REF_PREFIX + prefix
383
+ checksum = hashlib.md5(digest.encode()).hexdigest()
361
384
  for entry in self._list(
362
- f"{_quoted(self.folder())} in parents and trashed = false", "id,name"
385
+ f"{_quoted(self.folder())} in parents and trashed = false", "id,name,md5Checksum"
363
386
  ):
364
387
  name = str(entry["name"])
365
388
  if not name.startswith(wanted):
366
389
  continue
390
+ listed = entry.get("md5Checksum")
391
+ if listed is not None:
392
+ if listed == checksum:
393
+ return True
394
+ continue
367
395
  payload = self._call(
368
396
  "GET", self._media_url(str(entry["id"])), what=f"reading ref {name[4:]}"
369
397
  )
370
- if payload is not None:
371
- found[name[len(REF_PREFIX) :]] = payload.decode().strip()
372
- return found
398
+ if payload is not None and payload.decode().strip() == digest:
399
+ return True
400
+ return False
373
401
 
374
402
  # -- the backend -----------------------------------------------------------
375
403