letify 2.8.3__tar.gz → 2.8.5__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.5}/PKG-INFO +1 -1
  2. {letify-2.8.3 → letify-2.8.5}/docs/SPEC.md +11 -3
  3. {letify-2.8.3 → letify-2.8.5}/letify/__init__.py +1 -1
  4. {letify-2.8.3 → letify-2.8.5}/letify/protocol/codec.py +28 -6
  5. {letify-2.8.3 → letify-2.8.5}/letify/protocol/worker.py +42 -24
  6. {letify-2.8.3 → letify-2.8.5}/letify/providers/modal.py +75 -11
  7. {letify-2.8.3 → letify-2.8.5}/letify/providers/modal_adapter.py +1 -0
  8. {letify-2.8.3 → letify-2.8.5}/letify/runtime/channel.py +11 -2
  9. {letify-2.8.3 → letify-2.8.5}/letify/runtime/session.py +16 -8
  10. {letify-2.8.3 → letify-2.8.5}/letify/store/handoff.py +7 -1
  11. {letify-2.8.3 → letify-2.8.5}/letify/store/pathdata.py +9 -7
  12. {letify-2.8.3 → letify-2.8.5}/letify/transport/nat.py +30 -1
  13. {letify-2.8.3 → letify-2.8.5}/letify-core/Cargo.lock +3 -3
  14. {letify-2.8.3 → letify-2.8.5}/letify-core/Cargo.toml +1 -1
  15. {letify-2.8.3 → letify-2.8.5}/letify-ext/package-lock.json +2 -2
  16. {letify-2.8.3 → letify-2.8.5}/letify-ext/package.json +1 -1
  17. {letify-2.8.3 → letify-2.8.5}/pyproject.toml +1 -1
  18. {letify-2.8.3 → letify-2.8.5}/tests/conftest.py +17 -0
  19. {letify-2.8.3 → letify-2.8.5}/tests/test_protocol.py +3 -1
  20. {letify-2.8.3 → letify-2.8.5}/tests/test_providers.py +103 -0
  21. {letify-2.8.3 → letify-2.8.5}/tests/test_runtime.py +34 -0
  22. {letify-2.8.3 → letify-2.8.5}/tests/test_store.py +54 -0
  23. {letify-2.8.3 → letify-2.8.5}/tests/test_strategies.py +32 -2
  24. {letify-2.8.3 → letify-2.8.5}/.github/workflows/ci.yml +0 -0
  25. {letify-2.8.3 → letify-2.8.5}/.github/workflows/docs.yml +0 -0
  26. {letify-2.8.3 → letify-2.8.5}/.github/workflows/publish.yml +0 -0
  27. {letify-2.8.3 → letify-2.8.5}/.gitignore +0 -0
  28. {letify-2.8.3 → letify-2.8.5}/.researchtree +0 -0
  29. {letify-2.8.3 → letify-2.8.5}/CLAUDE.md +0 -0
  30. {letify-2.8.3 → letify-2.8.5}/LICENSE +0 -0
  31. {letify-2.8.3 → letify-2.8.5}/PROJECT.md +0 -0
  32. {letify-2.8.3 → letify-2.8.5}/README.md +0 -0
  33. {letify-2.8.3 → letify-2.8.5}/docs/COMPONENT.md +0 -0
  34. {letify-2.8.3 → letify-2.8.5}/docs/INTENT.md +0 -0
  35. {letify-2.8.3 → letify-2.8.5}/docs/NETWORK.md +0 -0
  36. {letify-2.8.3 → letify-2.8.5}/docs/guide/01-getting-started.md +0 -0
  37. {letify-2.8.3 → letify-2.8.5}/docs/guide/02-providers.md +0 -0
  38. {letify-2.8.3 → letify-2.8.5}/docs/guide/03-execution-modes.md +0 -0
  39. {letify-2.8.3 → letify-2.8.5}/docs/guide/04-environments-and-data.md +0 -0
  40. {letify-2.8.3 → letify-2.8.5}/docs/guide/05-concurrency.md +0 -0
  41. {letify-2.8.3 → letify-2.8.5}/docs/guide/06-cost.md +0 -0
  42. {letify-2.8.3 → letify-2.8.5}/docs/guide/07-troubleshooting.md +0 -0
  43. {letify-2.8.3 → letify-2.8.5}/docs/guide/08-releasing.md +0 -0
  44. {letify-2.8.3 → letify-2.8.5}/docs/guide/README.md +0 -0
  45. {letify-2.8.3 → letify-2.8.5}/docs/guide/ko/01-/354/213/234/354/236/221/355/225/230/352/270/260.md" +0 -0
  46. {letify-2.8.3 → letify-2.8.5}/docs/guide/ko/02-/355/224/204/353/241/234/353/260/224/354/235/264/353/215/224.md" +0 -0
  47. {letify-2.8.3 → letify-2.8.5}/docs/guide/ko/03-/354/213/244/355/226/211-/353/260/251/354/213/235.md" +0 -0
  48. {letify-2.8.3 → letify-2.8.5}/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
  49. {letify-2.8.3 → letify-2.8.5}/docs/guide/ko/05-/353/217/231/354/213/234-/354/213/244/355/226/211.md" +0 -0
  50. {letify-2.8.3 → letify-2.8.5}/docs/guide/ko/06-/353/271/204/354/232/251.md" +0 -0
  51. {letify-2.8.3 → letify-2.8.5}/docs/guide/ko/07-/353/254/270/354/240/234-/355/225/264/352/262/260.md" +0 -0
  52. {letify-2.8.3 → letify-2.8.5}/docs/guide/ko/08-/353/246/264/353/246/254/354/212/244.md" +0 -0
  53. {letify-2.8.3 → letify-2.8.5}/docs/guide/ko/README.md +0 -0
  54. {letify-2.8.3 → letify-2.8.5}/docs/locales/README_ko.md +0 -0
  55. {letify-2.8.3 → letify-2.8.5}/docs/mkdocs.yml +0 -0
  56. {letify-2.8.3 → letify-2.8.5}/examples/README.md +0 -0
  57. {letify-2.8.3 → letify-2.8.5}/examples/nvfp4-lora/.letify.example +0 -0
  58. {letify-2.8.3 → letify-2.8.5}/examples/nvfp4-lora/00_smoke.py +0 -0
  59. {letify-2.8.3 → letify-2.8.5}/examples/nvfp4-lora/01_sweep.py +0 -0
  60. {letify-2.8.3 → letify-2.8.5}/examples/nvfp4-lora/02_resume.py +0 -0
  61. {letify-2.8.3 → letify-2.8.5}/examples/nvfp4-lora/03_watch.py +0 -0
  62. {letify-2.8.3 → letify-2.8.5}/examples/nvfp4-lora/README.md +0 -0
  63. {letify-2.8.3 → letify-2.8.5}/examples/nvfp4-lora/README_ko.md +0 -0
  64. {letify-2.8.3 → letify-2.8.5}/examples/nvfp4-lora/common.py +0 -0
  65. {letify-2.8.3 → letify-2.8.5}/examples/nvfp4-lora/recipe.py +0 -0
  66. {letify-2.8.3 → letify-2.8.5}/letify/cli.py +0 -0
  67. {letify-2.8.3 → letify-2.8.5}/letify/config/__init__.py +0 -0
  68. {letify-2.8.3 → letify-2.8.5}/letify/config/inventory.py +0 -0
  69. {letify-2.8.3 → letify-2.8.5}/letify/config/login.py +0 -0
  70. {letify-2.8.3 → letify-2.8.5}/letify/config/schema.py +0 -0
  71. {letify-2.8.3 → letify-2.8.5}/letify/config/secrets.py +0 -0
  72. {letify-2.8.3 → letify-2.8.5}/letify/config/writer.py +0 -0
  73. {letify-2.8.3 → letify-2.8.5}/letify/declare/__init__.py +0 -0
  74. {letify-2.8.3 → letify-2.8.5}/letify/declare/cache.py +0 -0
  75. {letify-2.8.3 → letify-2.8.5}/letify/declare/env.py +0 -0
  76. {letify-2.8.3 → letify-2.8.5}/letify/declare/function.py +0 -0
  77. {letify-2.8.3 → letify-2.8.5}/letify/declare/instance.py +0 -0
  78. {letify-2.8.3 → letify-2.8.5}/letify/declare/reduce.py +0 -0
  79. {letify-2.8.3 → letify-2.8.5}/letify/declare/replicas.py +0 -0
  80. {letify-2.8.3 → letify-2.8.5}/letify/errors.py +0 -0
  81. {letify-2.8.3 → letify-2.8.5}/letify/install.py +0 -0
  82. {letify-2.8.3 → letify-2.8.5}/letify/launcher.py +0 -0
  83. {letify-2.8.3 → letify-2.8.5}/letify/paths.py +0 -0
  84. {letify-2.8.3 → letify-2.8.5}/letify/protocol/__init__.py +0 -0
  85. {letify-2.8.3 → letify-2.8.5}/letify/protocol/driver.py +0 -0
  86. {letify-2.8.3 → letify-2.8.5}/letify/protocol/handle.py +0 -0
  87. {letify-2.8.3 → letify-2.8.5}/letify/protocol/wire.py +0 -0
  88. {letify-2.8.3 → letify-2.8.5}/letify/providers/__init__.py +0 -0
  89. {letify-2.8.3 → letify-2.8.5}/letify/providers/base.py +0 -0
  90. {letify-2.8.3 → letify-2.8.5}/letify/providers/colab.py +0 -0
  91. {letify-2.8.3 → letify-2.8.5}/letify/providers/colab_files.py +0 -0
  92. {letify-2.8.3 → letify-2.8.5}/letify/providers/colab_keepalive.py +0 -0
  93. {letify-2.8.3 → letify-2.8.5}/letify/providers/elice.py +0 -0
  94. {letify-2.8.3 → letify-2.8.5}/letify/providers/kaggle.py +0 -0
  95. {letify-2.8.3 → letify-2.8.5}/letify/providers/kaggle_adapter.py +0 -0
  96. {letify-2.8.3 → letify-2.8.5}/letify/providers/local.py +0 -0
  97. {letify-2.8.3 → letify-2.8.5}/letify/providers/naming.py +0 -0
  98. {letify-2.8.3 → letify-2.8.5}/letify/providers/shell.py +0 -0
  99. {letify-2.8.3 → letify-2.8.5}/letify/providers/tunnel.py +0 -0
  100. {letify-2.8.3 → letify-2.8.5}/letify/providers/usage.py +0 -0
  101. {letify-2.8.3 → letify-2.8.5}/letify/remoting/__init__.py +0 -0
  102. {letify-2.8.3 → letify-2.8.5}/letify/remoting/capability.py +0 -0
  103. {letify-2.8.3 → letify-2.8.5}/letify/remoting/device/__init__.py +0 -0
  104. {letify-2.8.3 → letify-2.8.5}/letify/remoting/device/client.py +0 -0
  105. {letify-2.8.3 → letify-2.8.5}/letify/remoting/device/cuda.py +0 -0
  106. {letify-2.8.3 → letify-2.8.5}/letify/remoting/device/executor.py +0 -0
  107. {letify-2.8.3 → letify-2.8.5}/letify/remoting/device/frames.py +0 -0
  108. {letify-2.8.3 → letify-2.8.5}/letify/remoting/device/guard.py +0 -0
  109. {letify-2.8.3 → letify-2.8.5}/letify/remoting/device/tensor.py +0 -0
  110. {letify-2.8.3 → letify-2.8.5}/letify/remoting/device/trace.py +0 -0
  111. {letify-2.8.3 → letify-2.8.5}/letify/remoting/device/value.py +0 -0
  112. {letify-2.8.3 → letify-2.8.5}/letify/remoting/loader.py +0 -0
  113. {letify-2.8.3 → letify-2.8.5}/letify/remoting/probe.py +0 -0
  114. {letify-2.8.3 → letify-2.8.5}/letify/render.py +0 -0
  115. {letify-2.8.3 → letify-2.8.5}/letify/runtime/__init__.py +0 -0
  116. {letify-2.8.3 → letify-2.8.5}/letify/runtime/bootstrap.py +0 -0
  117. {letify-2.8.3 → letify-2.8.5}/letify/runtime/broker.py +0 -0
  118. {letify-2.8.3 → letify-2.8.5}/letify/runtime/lease.py +0 -0
  119. {letify-2.8.3 → letify-2.8.5}/letify/runtime/pool.py +0 -0
  120. {letify-2.8.3 → letify-2.8.5}/letify/runtime/telemetry.py +0 -0
  121. {letify-2.8.3 → letify-2.8.5}/letify/store/__init__.py +0 -0
  122. {letify-2.8.3 → letify-2.8.5}/letify/store/backends/__init__.py +0 -0
  123. {letify-2.8.3 → letify-2.8.5}/letify/store/backends/drive.py +0 -0
  124. {letify-2.8.3 → letify-2.8.5}/letify/store/backends/filesystem.py +0 -0
  125. {letify-2.8.3 → letify-2.8.5}/letify/store/backends/google_auth.py +0 -0
  126. {letify-2.8.3 → letify-2.8.5}/letify/store/backends/layout.py +0 -0
  127. {letify-2.8.3 → letify-2.8.5}/letify/store/backends/objects.py +0 -0
  128. {letify-2.8.3 → letify-2.8.5}/letify/store/cas.py +0 -0
  129. {letify-2.8.3 → letify-2.8.5}/letify/store/sendorder.py +0 -0
  130. {letify-2.8.3 → letify-2.8.5}/letify/store/volume.py +0 -0
  131. {letify-2.8.3 → letify-2.8.5}/letify/stubs.py +0 -0
  132. {letify-2.8.3 → letify-2.8.5}/letify/tools.py +0 -0
  133. {letify-2.8.3 → letify-2.8.5}/letify/transport/__init__.py +0 -0
  134. {letify-2.8.3 → letify-2.8.5}/letify/transport/agent.py +0 -0
  135. {letify-2.8.3 → letify-2.8.5}/letify/transport/announce.py +0 -0
  136. {letify-2.8.3 → letify-2.8.5}/letify/transport/link.py +0 -0
  137. {letify-2.8.3 → letify-2.8.5}/letify/transport/pipeline.py +0 -0
  138. {letify-2.8.3 → letify-2.8.5}/letify/transport/portmap.py +0 -0
  139. {letify-2.8.3 → letify-2.8.5}/letify/transport/probe.py +0 -0
  140. {letify-2.8.3 → letify-2.8.5}/letify/transport/quic.py +0 -0
  141. {letify-2.8.3 → letify-2.8.5}/letify/transport/rendezvous.py +0 -0
  142. {letify-2.8.3 → letify-2.8.5}/letify/transport/setup.py +0 -0
  143. {letify-2.8.3 → letify-2.8.5}/letify/transport/sshopts.py +0 -0
  144. {letify-2.8.3 → letify-2.8.5}/letify/transport/strategies.py +0 -0
  145. {letify-2.8.3 → letify-2.8.5}/letify-core/.gitignore +0 -0
  146. {letify-2.8.3 → letify-2.8.5}/letify-core/build.py +0 -0
  147. {letify-2.8.3 → letify-2.8.5}/letify-core/letify-agent/Cargo.toml +0 -0
  148. {letify-2.8.3 → letify-2.8.5}/letify-core/letify-agent/src/driver.rs +0 -0
  149. {letify-2.8.3 → letify-2.8.5}/letify-core/letify-agent/src/main.rs +0 -0
  150. {letify-2.8.3 → letify-2.8.5}/letify-core/letify-driver/Cargo.toml +0 -0
  151. {letify-2.8.3 → letify-2.8.5}/letify-core/letify-driver/src/client.rs +0 -0
  152. {letify-2.8.3 → letify-2.8.5}/letify-core/letify-driver/src/exports.rs +0 -0
  153. {letify-2.8.3 → letify-2.8.5}/letify-core/letify-driver/src/image.rs +0 -0
  154. {letify-2.8.3 → letify-2.8.5}/letify-core/letify-driver/src/lib.rs +0 -0
  155. {letify-2.8.3 → letify-2.8.5}/letify-core/letify-driver/src/status.rs +0 -0
  156. {letify-2.8.3 → letify-2.8.5}/letify-core/letify-driver/src/table.rs +0 -0
  157. {letify-2.8.3 → letify-2.8.5}/letify-core/letify-quic/Cargo.toml +0 -0
  158. {letify-2.8.3 → letify-2.8.5}/letify-core/letify-quic/src/main.rs +0 -0
  159. {letify-2.8.3 → letify-2.8.5}/letify-core/letify-wire/Cargo.toml +0 -0
  160. {letify-2.8.3 → letify-2.8.5}/letify-core/letify-wire/src/lib.rs +0 -0
  161. {letify-2.8.3 → letify-2.8.5}/letify-core/letify-wire/tests/throughput.rs +0 -0
  162. {letify-2.8.3 → letify-2.8.5}/letify-core/rust-toolchain.toml +0 -0
  163. {letify-2.8.3 → letify-2.8.5}/letify-ext/.vscodeignore +0 -0
  164. {letify-2.8.3 → letify-2.8.5}/letify-ext/LICENSE +0 -0
  165. {letify-2.8.3 → letify-2.8.5}/letify-ext/README.md +0 -0
  166. {letify-2.8.3 → letify-2.8.5}/letify-ext/media/icon.svg +0 -0
  167. {letify-2.8.3 → letify-2.8.5}/letify-ext/src/cli.ts +0 -0
  168. {letify-2.8.3 → letify-2.8.5}/letify-ext/src/extension.ts +0 -0
  169. {letify-2.8.3 → letify-2.8.5}/letify-ext/src/history.ts +0 -0
  170. {letify-2.8.3 → letify-2.8.5}/letify-ext/src/model.ts +0 -0
  171. {letify-2.8.3 → letify-2.8.5}/letify-ext/src/poll.ts +0 -0
  172. {letify-2.8.3 → letify-2.8.5}/letify-ext/src/render.ts +0 -0
  173. {letify-2.8.3 → letify-2.8.5}/letify-ext/test/cli.test.ts +0 -0
  174. {letify-2.8.3 → letify-2.8.5}/letify-ext/test/manifest.test.ts +0 -0
  175. {letify-2.8.3 → letify-2.8.5}/letify-ext/test/model.test.ts +0 -0
  176. {letify-2.8.3 → letify-2.8.5}/letify-ext/test/regressions.test.ts +0 -0
  177. {letify-2.8.3 → letify-2.8.5}/letify-ext/tsconfig.json +0 -0
  178. {letify-2.8.3 → letify-2.8.5}/letify_providers.py +0 -0
  179. {letify-2.8.3 → letify-2.8.5}/scripts/sync-main.sh +0 -0
  180. {letify-2.8.3 → letify-2.8.5}/scripts/version.py +0 -0
  181. {letify-2.8.3 → letify-2.8.5}/tests/fake_kaggle_adapter.py +0 -0
  182. {letify-2.8.3 → letify-2.8.5}/tests/fake_modal_adapter.py +0 -0
  183. {letify-2.8.3 → letify-2.8.5}/tests/test_cli.py +0 -0
  184. {letify-2.8.3 → letify-2.8.5}/tests/test_cli_output.py +0 -0
  185. {letify-2.8.3 → letify-2.8.5}/tests/test_colab_keepalive.py +0 -0
  186. {letify-2.8.3 → letify-2.8.5}/tests/test_config.py +0 -0
  187. {letify-2.8.3 → letify-2.8.5}/tests/test_core.py +0 -0
  188. {letify-2.8.3 → letify-2.8.5}/tests/test_data.py +0 -0
  189. {letify-2.8.3 → letify-2.8.5}/tests/test_data_streaming.py +0 -0
  190. {letify-2.8.3 → letify-2.8.5}/tests/test_declare.py +0 -0
  191. {letify-2.8.3 → letify-2.8.5}/tests/test_device.py +0 -0
  192. {letify-2.8.3 → letify-2.8.5}/tests/test_distributed.py +0 -0
  193. {letify-2.8.3 → letify-2.8.5}/tests/test_drive_handoff.py +0 -0
  194. {letify-2.8.3 → letify-2.8.5}/tests/test_drive_store.py +0 -0
  195. {letify-2.8.3 → letify-2.8.5}/tests/test_editor_sessions.py +0 -0
  196. {letify-2.8.3 → letify-2.8.5}/tests/test_elice.py +0 -0
  197. {letify-2.8.3 → letify-2.8.5}/tests/test_frames.py +0 -0
  198. {letify-2.8.3 → letify-2.8.5}/tests/test_install.py +0 -0
  199. {letify-2.8.3 → letify-2.8.5}/tests/test_kaggle.py +0 -0
  200. {letify-2.8.3 → letify-2.8.5}/tests/test_kaggle_cookie.py +0 -0
  201. {letify-2.8.3 → letify-2.8.5}/tests/test_kaggle_link.py +0 -0
  202. {letify-2.8.3 → letify-2.8.5}/tests/test_link_log.py +0 -0
  203. {letify-2.8.3 → letify-2.8.5}/tests/test_notebook.py +0 -0
  204. {letify-2.8.3 → letify-2.8.5}/tests/test_parallel_placement.py +0 -0
  205. {letify-2.8.3 → letify-2.8.5}/tests/test_paths.py +0 -0
  206. {letify-2.8.3 → letify-2.8.5}/tests/test_portmap.py +0 -0
  207. {letify-2.8.3 → letify-2.8.5}/tests/test_quic.py +0 -0
  208. {letify-2.8.3 → letify-2.8.5}/tests/test_remoting.py +0 -0
  209. {letify-2.8.3 → letify-2.8.5}/tests/test_render.py +0 -0
  210. {letify-2.8.3 → letify-2.8.5}/tests/test_ship.py +0 -0
  211. {letify-2.8.3 → letify-2.8.5}/tests/test_stubs.py +0 -0
  212. {letify-2.8.3 → letify-2.8.5}/tests/test_transport.py +0 -0
  213. {letify-2.8.3 → letify-2.8.5}/tests/test_tunnel_setup.py +0 -0
  214. {letify-2.8.3 → letify-2.8.5}/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.5
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
@@ -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,12 @@ 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
+
817
821
  ### Modal data channel <!-- id: modal-data-channel -->
818
822
 
819
823
  > 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.
@@ -924,6 +928,8 @@ A top-level argument whose serialized size is 64 KiB or more is replaced by a `B
924
928
 
925
929
  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
930
 
931
+ 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.
932
+
927
933
  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
934
 
929
935
  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 +1094,7 @@ neither side holds it whole:
1088
1094
  returns `{"digest", "size", "dir"}`. The client receives the file with the stream a write-back
1089
1095
  uses, checks the digest as the pieces arrive into a scratch file beside the store, and renames it
1090
1096
  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).
1097
+ 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
1098
  - Restore: a blob of more than one piece (8 MiB) that the store holds as a file is sent with
1093
1099
  `data_put` pieces into a staging directory beside the destination, then moved into place and
1094
1100
  unpacked by the worker op `place_blob`. Smaller blobs, and any blob from a store that does not
@@ -1114,6 +1120,8 @@ the kind this rule exists to catch, and the session that syncs instead both reco
1114
1120
  decides afresh whether the archive is worth keeping. The cost of that is one sync; the cost of
1115
1121
  trusting it is the transfer this rule was measured to avoid.
1116
1122
 
1123
+ 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.
1124
+
1117
1125
  Packing is said, because it is a step that can take minutes and had nothing reporting it:
1118
1126
  `letify: <runtime>: caching the environment, 2.1 GB`. Skipping it is said too, because it is a
1119
1127
  decision letify made rather than a thing that did not
@@ -1949,7 +1957,7 @@ Colab limits outbound UDP to roughly 200 packets per second, so rank 3 is expect
1949
1957
 
1950
1958
  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
1959
 
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.
1960
+ 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
1961
 
1954
1962
  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
1963
 
@@ -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.5"
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)
@@ -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()
@@ -258,6 +258,7 @@ class Connection:
258
258
  emit: Callable[[str, bytes], None],
259
259
  *,
260
260
  death_detail: Callable[[], str] = lambda: "",
261
+ death_cause: Callable[[], str | None] = lambda: None,
261
262
  poll_fd: int | None = None,
262
263
  ):
263
264
  self.name = name
@@ -276,6 +277,8 @@ class Connection:
276
277
  self._receiver = wire.Receiver(readinto)
277
278
  self._emit = emit
278
279
  self._death_detail = death_detail
280
+ #: Names what ended the worker when the provider can tell, replacing the usual causes.
281
+ self._death_cause = death_cause
279
282
  #: Guards ``_reading`` and wakes waiters when a reply, a hello or a failure arrives.
280
283
  self._turn = threading.Condition()
281
284
  self._reading = False
@@ -383,10 +386,16 @@ class Connection:
383
386
  return
384
387
  if message is None:
385
388
  detail = self._death_detail()
389
+ cause = self._death_cause()
390
+ causes = (
391
+ f": {cause}."
392
+ if cause
393
+ else ". The usual causes are an out of memory kill, a preempted session, "
394
+ "or a crash below Python."
395
+ )
386
396
  message = (
387
397
  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"
398
+ f"before it finished{causes}\n--- last remote output ---\n"
390
399
  f"{self._tail.text()[-2000:]}{detail[-2000:]}"
391
400
  )
392
401
  with self._turn:
@@ -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)