letify 2.8.4__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.4 → letify-2.8.5}/PKG-INFO +1 -1
  2. {letify-2.8.4 → letify-2.8.5}/docs/SPEC.md +5 -1
  3. {letify-2.8.4 → letify-2.8.5}/letify/__init__.py +1 -1
  4. {letify-2.8.4 → letify-2.8.5}/letify/providers/modal.py +75 -11
  5. {letify-2.8.4 → letify-2.8.5}/letify/providers/modal_adapter.py +1 -0
  6. {letify-2.8.4 → letify-2.8.5}/letify/runtime/channel.py +11 -2
  7. {letify-2.8.4 → letify-2.8.5}/letify-core/Cargo.lock +3 -3
  8. {letify-2.8.4 → letify-2.8.5}/letify-core/Cargo.toml +1 -1
  9. {letify-2.8.4 → letify-2.8.5}/letify-ext/package-lock.json +2 -2
  10. {letify-2.8.4 → letify-2.8.5}/letify-ext/package.json +1 -1
  11. {letify-2.8.4 → letify-2.8.5}/pyproject.toml +1 -1
  12. {letify-2.8.4 → letify-2.8.5}/tests/test_providers.py +103 -0
  13. {letify-2.8.4 → letify-2.8.5}/.github/workflows/ci.yml +0 -0
  14. {letify-2.8.4 → letify-2.8.5}/.github/workflows/docs.yml +0 -0
  15. {letify-2.8.4 → letify-2.8.5}/.github/workflows/publish.yml +0 -0
  16. {letify-2.8.4 → letify-2.8.5}/.gitignore +0 -0
  17. {letify-2.8.4 → letify-2.8.5}/.researchtree +0 -0
  18. {letify-2.8.4 → letify-2.8.5}/CLAUDE.md +0 -0
  19. {letify-2.8.4 → letify-2.8.5}/LICENSE +0 -0
  20. {letify-2.8.4 → letify-2.8.5}/PROJECT.md +0 -0
  21. {letify-2.8.4 → letify-2.8.5}/README.md +0 -0
  22. {letify-2.8.4 → letify-2.8.5}/docs/COMPONENT.md +0 -0
  23. {letify-2.8.4 → letify-2.8.5}/docs/INTENT.md +0 -0
  24. {letify-2.8.4 → letify-2.8.5}/docs/NETWORK.md +0 -0
  25. {letify-2.8.4 → letify-2.8.5}/docs/guide/01-getting-started.md +0 -0
  26. {letify-2.8.4 → letify-2.8.5}/docs/guide/02-providers.md +0 -0
  27. {letify-2.8.4 → letify-2.8.5}/docs/guide/03-execution-modes.md +0 -0
  28. {letify-2.8.4 → letify-2.8.5}/docs/guide/04-environments-and-data.md +0 -0
  29. {letify-2.8.4 → letify-2.8.5}/docs/guide/05-concurrency.md +0 -0
  30. {letify-2.8.4 → letify-2.8.5}/docs/guide/06-cost.md +0 -0
  31. {letify-2.8.4 → letify-2.8.5}/docs/guide/07-troubleshooting.md +0 -0
  32. {letify-2.8.4 → letify-2.8.5}/docs/guide/08-releasing.md +0 -0
  33. {letify-2.8.4 → letify-2.8.5}/docs/guide/README.md +0 -0
  34. {letify-2.8.4 → letify-2.8.5}/docs/guide/ko/01-/354/213/234/354/236/221/355/225/230/352/270/260.md" +0 -0
  35. {letify-2.8.4 → 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
  36. {letify-2.8.4 → letify-2.8.5}/docs/guide/ko/03-/354/213/244/355/226/211-/353/260/251/354/213/235.md" +0 -0
  37. {letify-2.8.4 → 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
  38. {letify-2.8.4 → letify-2.8.5}/docs/guide/ko/05-/353/217/231/354/213/234-/354/213/244/355/226/211.md" +0 -0
  39. {letify-2.8.4 → letify-2.8.5}/docs/guide/ko/06-/353/271/204/354/232/251.md" +0 -0
  40. {letify-2.8.4 → letify-2.8.5}/docs/guide/ko/07-/353/254/270/354/240/234-/355/225/264/352/262/260.md" +0 -0
  41. {letify-2.8.4 → letify-2.8.5}/docs/guide/ko/08-/353/246/264/353/246/254/354/212/244.md" +0 -0
  42. {letify-2.8.4 → letify-2.8.5}/docs/guide/ko/README.md +0 -0
  43. {letify-2.8.4 → letify-2.8.5}/docs/locales/README_ko.md +0 -0
  44. {letify-2.8.4 → letify-2.8.5}/docs/mkdocs.yml +0 -0
  45. {letify-2.8.4 → letify-2.8.5}/examples/README.md +0 -0
  46. {letify-2.8.4 → letify-2.8.5}/examples/nvfp4-lora/.letify.example +0 -0
  47. {letify-2.8.4 → letify-2.8.5}/examples/nvfp4-lora/00_smoke.py +0 -0
  48. {letify-2.8.4 → letify-2.8.5}/examples/nvfp4-lora/01_sweep.py +0 -0
  49. {letify-2.8.4 → letify-2.8.5}/examples/nvfp4-lora/02_resume.py +0 -0
  50. {letify-2.8.4 → letify-2.8.5}/examples/nvfp4-lora/03_watch.py +0 -0
  51. {letify-2.8.4 → letify-2.8.5}/examples/nvfp4-lora/README.md +0 -0
  52. {letify-2.8.4 → letify-2.8.5}/examples/nvfp4-lora/README_ko.md +0 -0
  53. {letify-2.8.4 → letify-2.8.5}/examples/nvfp4-lora/common.py +0 -0
  54. {letify-2.8.4 → letify-2.8.5}/examples/nvfp4-lora/recipe.py +0 -0
  55. {letify-2.8.4 → letify-2.8.5}/letify/cli.py +0 -0
  56. {letify-2.8.4 → letify-2.8.5}/letify/config/__init__.py +0 -0
  57. {letify-2.8.4 → letify-2.8.5}/letify/config/inventory.py +0 -0
  58. {letify-2.8.4 → letify-2.8.5}/letify/config/login.py +0 -0
  59. {letify-2.8.4 → letify-2.8.5}/letify/config/schema.py +0 -0
  60. {letify-2.8.4 → letify-2.8.5}/letify/config/secrets.py +0 -0
  61. {letify-2.8.4 → letify-2.8.5}/letify/config/writer.py +0 -0
  62. {letify-2.8.4 → letify-2.8.5}/letify/declare/__init__.py +0 -0
  63. {letify-2.8.4 → letify-2.8.5}/letify/declare/cache.py +0 -0
  64. {letify-2.8.4 → letify-2.8.5}/letify/declare/env.py +0 -0
  65. {letify-2.8.4 → letify-2.8.5}/letify/declare/function.py +0 -0
  66. {letify-2.8.4 → letify-2.8.5}/letify/declare/instance.py +0 -0
  67. {letify-2.8.4 → letify-2.8.5}/letify/declare/reduce.py +0 -0
  68. {letify-2.8.4 → letify-2.8.5}/letify/declare/replicas.py +0 -0
  69. {letify-2.8.4 → letify-2.8.5}/letify/errors.py +0 -0
  70. {letify-2.8.4 → letify-2.8.5}/letify/install.py +0 -0
  71. {letify-2.8.4 → letify-2.8.5}/letify/launcher.py +0 -0
  72. {letify-2.8.4 → letify-2.8.5}/letify/paths.py +0 -0
  73. {letify-2.8.4 → letify-2.8.5}/letify/protocol/__init__.py +0 -0
  74. {letify-2.8.4 → letify-2.8.5}/letify/protocol/codec.py +0 -0
  75. {letify-2.8.4 → letify-2.8.5}/letify/protocol/driver.py +0 -0
  76. {letify-2.8.4 → letify-2.8.5}/letify/protocol/handle.py +0 -0
  77. {letify-2.8.4 → letify-2.8.5}/letify/protocol/wire.py +0 -0
  78. {letify-2.8.4 → letify-2.8.5}/letify/protocol/worker.py +0 -0
  79. {letify-2.8.4 → letify-2.8.5}/letify/providers/__init__.py +0 -0
  80. {letify-2.8.4 → letify-2.8.5}/letify/providers/base.py +0 -0
  81. {letify-2.8.4 → letify-2.8.5}/letify/providers/colab.py +0 -0
  82. {letify-2.8.4 → letify-2.8.5}/letify/providers/colab_files.py +0 -0
  83. {letify-2.8.4 → letify-2.8.5}/letify/providers/colab_keepalive.py +0 -0
  84. {letify-2.8.4 → letify-2.8.5}/letify/providers/elice.py +0 -0
  85. {letify-2.8.4 → letify-2.8.5}/letify/providers/kaggle.py +0 -0
  86. {letify-2.8.4 → letify-2.8.5}/letify/providers/kaggle_adapter.py +0 -0
  87. {letify-2.8.4 → letify-2.8.5}/letify/providers/local.py +0 -0
  88. {letify-2.8.4 → letify-2.8.5}/letify/providers/naming.py +0 -0
  89. {letify-2.8.4 → letify-2.8.5}/letify/providers/shell.py +0 -0
  90. {letify-2.8.4 → letify-2.8.5}/letify/providers/tunnel.py +0 -0
  91. {letify-2.8.4 → letify-2.8.5}/letify/providers/usage.py +0 -0
  92. {letify-2.8.4 → letify-2.8.5}/letify/remoting/__init__.py +0 -0
  93. {letify-2.8.4 → letify-2.8.5}/letify/remoting/capability.py +0 -0
  94. {letify-2.8.4 → letify-2.8.5}/letify/remoting/device/__init__.py +0 -0
  95. {letify-2.8.4 → letify-2.8.5}/letify/remoting/device/client.py +0 -0
  96. {letify-2.8.4 → letify-2.8.5}/letify/remoting/device/cuda.py +0 -0
  97. {letify-2.8.4 → letify-2.8.5}/letify/remoting/device/executor.py +0 -0
  98. {letify-2.8.4 → letify-2.8.5}/letify/remoting/device/frames.py +0 -0
  99. {letify-2.8.4 → letify-2.8.5}/letify/remoting/device/guard.py +0 -0
  100. {letify-2.8.4 → letify-2.8.5}/letify/remoting/device/tensor.py +0 -0
  101. {letify-2.8.4 → letify-2.8.5}/letify/remoting/device/trace.py +0 -0
  102. {letify-2.8.4 → letify-2.8.5}/letify/remoting/device/value.py +0 -0
  103. {letify-2.8.4 → letify-2.8.5}/letify/remoting/loader.py +0 -0
  104. {letify-2.8.4 → letify-2.8.5}/letify/remoting/probe.py +0 -0
  105. {letify-2.8.4 → letify-2.8.5}/letify/render.py +0 -0
  106. {letify-2.8.4 → letify-2.8.5}/letify/runtime/__init__.py +0 -0
  107. {letify-2.8.4 → letify-2.8.5}/letify/runtime/bootstrap.py +0 -0
  108. {letify-2.8.4 → letify-2.8.5}/letify/runtime/broker.py +0 -0
  109. {letify-2.8.4 → letify-2.8.5}/letify/runtime/lease.py +0 -0
  110. {letify-2.8.4 → letify-2.8.5}/letify/runtime/pool.py +0 -0
  111. {letify-2.8.4 → letify-2.8.5}/letify/runtime/session.py +0 -0
  112. {letify-2.8.4 → letify-2.8.5}/letify/runtime/telemetry.py +0 -0
  113. {letify-2.8.4 → letify-2.8.5}/letify/store/__init__.py +0 -0
  114. {letify-2.8.4 → letify-2.8.5}/letify/store/backends/__init__.py +0 -0
  115. {letify-2.8.4 → letify-2.8.5}/letify/store/backends/drive.py +0 -0
  116. {letify-2.8.4 → letify-2.8.5}/letify/store/backends/filesystem.py +0 -0
  117. {letify-2.8.4 → letify-2.8.5}/letify/store/backends/google_auth.py +0 -0
  118. {letify-2.8.4 → letify-2.8.5}/letify/store/backends/layout.py +0 -0
  119. {letify-2.8.4 → letify-2.8.5}/letify/store/backends/objects.py +0 -0
  120. {letify-2.8.4 → letify-2.8.5}/letify/store/cas.py +0 -0
  121. {letify-2.8.4 → letify-2.8.5}/letify/store/handoff.py +0 -0
  122. {letify-2.8.4 → letify-2.8.5}/letify/store/pathdata.py +0 -0
  123. {letify-2.8.4 → letify-2.8.5}/letify/store/sendorder.py +0 -0
  124. {letify-2.8.4 → letify-2.8.5}/letify/store/volume.py +0 -0
  125. {letify-2.8.4 → letify-2.8.5}/letify/stubs.py +0 -0
  126. {letify-2.8.4 → letify-2.8.5}/letify/tools.py +0 -0
  127. {letify-2.8.4 → letify-2.8.5}/letify/transport/__init__.py +0 -0
  128. {letify-2.8.4 → letify-2.8.5}/letify/transport/agent.py +0 -0
  129. {letify-2.8.4 → letify-2.8.5}/letify/transport/announce.py +0 -0
  130. {letify-2.8.4 → letify-2.8.5}/letify/transport/link.py +0 -0
  131. {letify-2.8.4 → letify-2.8.5}/letify/transport/nat.py +0 -0
  132. {letify-2.8.4 → letify-2.8.5}/letify/transport/pipeline.py +0 -0
  133. {letify-2.8.4 → letify-2.8.5}/letify/transport/portmap.py +0 -0
  134. {letify-2.8.4 → letify-2.8.5}/letify/transport/probe.py +0 -0
  135. {letify-2.8.4 → letify-2.8.5}/letify/transport/quic.py +0 -0
  136. {letify-2.8.4 → letify-2.8.5}/letify/transport/rendezvous.py +0 -0
  137. {letify-2.8.4 → letify-2.8.5}/letify/transport/setup.py +0 -0
  138. {letify-2.8.4 → letify-2.8.5}/letify/transport/sshopts.py +0 -0
  139. {letify-2.8.4 → letify-2.8.5}/letify/transport/strategies.py +0 -0
  140. {letify-2.8.4 → letify-2.8.5}/letify-core/.gitignore +0 -0
  141. {letify-2.8.4 → letify-2.8.5}/letify-core/build.py +0 -0
  142. {letify-2.8.4 → letify-2.8.5}/letify-core/letify-agent/Cargo.toml +0 -0
  143. {letify-2.8.4 → letify-2.8.5}/letify-core/letify-agent/src/driver.rs +0 -0
  144. {letify-2.8.4 → letify-2.8.5}/letify-core/letify-agent/src/main.rs +0 -0
  145. {letify-2.8.4 → letify-2.8.5}/letify-core/letify-driver/Cargo.toml +0 -0
  146. {letify-2.8.4 → letify-2.8.5}/letify-core/letify-driver/src/client.rs +0 -0
  147. {letify-2.8.4 → letify-2.8.5}/letify-core/letify-driver/src/exports.rs +0 -0
  148. {letify-2.8.4 → letify-2.8.5}/letify-core/letify-driver/src/image.rs +0 -0
  149. {letify-2.8.4 → letify-2.8.5}/letify-core/letify-driver/src/lib.rs +0 -0
  150. {letify-2.8.4 → letify-2.8.5}/letify-core/letify-driver/src/status.rs +0 -0
  151. {letify-2.8.4 → letify-2.8.5}/letify-core/letify-driver/src/table.rs +0 -0
  152. {letify-2.8.4 → letify-2.8.5}/letify-core/letify-quic/Cargo.toml +0 -0
  153. {letify-2.8.4 → letify-2.8.5}/letify-core/letify-quic/src/main.rs +0 -0
  154. {letify-2.8.4 → letify-2.8.5}/letify-core/letify-wire/Cargo.toml +0 -0
  155. {letify-2.8.4 → letify-2.8.5}/letify-core/letify-wire/src/lib.rs +0 -0
  156. {letify-2.8.4 → letify-2.8.5}/letify-core/letify-wire/tests/throughput.rs +0 -0
  157. {letify-2.8.4 → letify-2.8.5}/letify-core/rust-toolchain.toml +0 -0
  158. {letify-2.8.4 → letify-2.8.5}/letify-ext/.vscodeignore +0 -0
  159. {letify-2.8.4 → letify-2.8.5}/letify-ext/LICENSE +0 -0
  160. {letify-2.8.4 → letify-2.8.5}/letify-ext/README.md +0 -0
  161. {letify-2.8.4 → letify-2.8.5}/letify-ext/media/icon.svg +0 -0
  162. {letify-2.8.4 → letify-2.8.5}/letify-ext/src/cli.ts +0 -0
  163. {letify-2.8.4 → letify-2.8.5}/letify-ext/src/extension.ts +0 -0
  164. {letify-2.8.4 → letify-2.8.5}/letify-ext/src/history.ts +0 -0
  165. {letify-2.8.4 → letify-2.8.5}/letify-ext/src/model.ts +0 -0
  166. {letify-2.8.4 → letify-2.8.5}/letify-ext/src/poll.ts +0 -0
  167. {letify-2.8.4 → letify-2.8.5}/letify-ext/src/render.ts +0 -0
  168. {letify-2.8.4 → letify-2.8.5}/letify-ext/test/cli.test.ts +0 -0
  169. {letify-2.8.4 → letify-2.8.5}/letify-ext/test/manifest.test.ts +0 -0
  170. {letify-2.8.4 → letify-2.8.5}/letify-ext/test/model.test.ts +0 -0
  171. {letify-2.8.4 → letify-2.8.5}/letify-ext/test/regressions.test.ts +0 -0
  172. {letify-2.8.4 → letify-2.8.5}/letify-ext/tsconfig.json +0 -0
  173. {letify-2.8.4 → letify-2.8.5}/letify_providers.py +0 -0
  174. {letify-2.8.4 → letify-2.8.5}/scripts/sync-main.sh +0 -0
  175. {letify-2.8.4 → letify-2.8.5}/scripts/version.py +0 -0
  176. {letify-2.8.4 → letify-2.8.5}/tests/conftest.py +0 -0
  177. {letify-2.8.4 → letify-2.8.5}/tests/fake_kaggle_adapter.py +0 -0
  178. {letify-2.8.4 → letify-2.8.5}/tests/fake_modal_adapter.py +0 -0
  179. {letify-2.8.4 → letify-2.8.5}/tests/test_cli.py +0 -0
  180. {letify-2.8.4 → letify-2.8.5}/tests/test_cli_output.py +0 -0
  181. {letify-2.8.4 → letify-2.8.5}/tests/test_colab_keepalive.py +0 -0
  182. {letify-2.8.4 → letify-2.8.5}/tests/test_config.py +0 -0
  183. {letify-2.8.4 → letify-2.8.5}/tests/test_core.py +0 -0
  184. {letify-2.8.4 → letify-2.8.5}/tests/test_data.py +0 -0
  185. {letify-2.8.4 → letify-2.8.5}/tests/test_data_streaming.py +0 -0
  186. {letify-2.8.4 → letify-2.8.5}/tests/test_declare.py +0 -0
  187. {letify-2.8.4 → letify-2.8.5}/tests/test_device.py +0 -0
  188. {letify-2.8.4 → letify-2.8.5}/tests/test_distributed.py +0 -0
  189. {letify-2.8.4 → letify-2.8.5}/tests/test_drive_handoff.py +0 -0
  190. {letify-2.8.4 → letify-2.8.5}/tests/test_drive_store.py +0 -0
  191. {letify-2.8.4 → letify-2.8.5}/tests/test_editor_sessions.py +0 -0
  192. {letify-2.8.4 → letify-2.8.5}/tests/test_elice.py +0 -0
  193. {letify-2.8.4 → letify-2.8.5}/tests/test_frames.py +0 -0
  194. {letify-2.8.4 → letify-2.8.5}/tests/test_install.py +0 -0
  195. {letify-2.8.4 → letify-2.8.5}/tests/test_kaggle.py +0 -0
  196. {letify-2.8.4 → letify-2.8.5}/tests/test_kaggle_cookie.py +0 -0
  197. {letify-2.8.4 → letify-2.8.5}/tests/test_kaggle_link.py +0 -0
  198. {letify-2.8.4 → letify-2.8.5}/tests/test_link_log.py +0 -0
  199. {letify-2.8.4 → letify-2.8.5}/tests/test_notebook.py +0 -0
  200. {letify-2.8.4 → letify-2.8.5}/tests/test_parallel_placement.py +0 -0
  201. {letify-2.8.4 → letify-2.8.5}/tests/test_paths.py +0 -0
  202. {letify-2.8.4 → letify-2.8.5}/tests/test_portmap.py +0 -0
  203. {letify-2.8.4 → letify-2.8.5}/tests/test_protocol.py +0 -0
  204. {letify-2.8.4 → letify-2.8.5}/tests/test_quic.py +0 -0
  205. {letify-2.8.4 → letify-2.8.5}/tests/test_remoting.py +0 -0
  206. {letify-2.8.4 → letify-2.8.5}/tests/test_render.py +0 -0
  207. {letify-2.8.4 → letify-2.8.5}/tests/test_runtime.py +0 -0
  208. {letify-2.8.4 → letify-2.8.5}/tests/test_ship.py +0 -0
  209. {letify-2.8.4 → letify-2.8.5}/tests/test_store.py +0 -0
  210. {letify-2.8.4 → letify-2.8.5}/tests/test_strategies.py +0 -0
  211. {letify-2.8.4 → letify-2.8.5}/tests/test_stubs.py +0 -0
  212. {letify-2.8.4 → letify-2.8.5}/tests/test_transport.py +0 -0
  213. {letify-2.8.4 → letify-2.8.5}/tests/test_tunnel_setup.py +0 -0
  214. {letify-2.8.4 → 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.4
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.
@@ -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.5"
72
72
 
73
73
 
74
74
  def fetch(tensor): # type: ignore[no-untyped-def]
@@ -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:
@@ -138,7 +138,7 @@ dependencies = [
138
138
 
139
139
  [[package]]
140
140
  name = "letify-agent"
141
- version = "2.8.4"
141
+ version = "2.8.5"
142
142
  dependencies = [
143
143
  "letify-wire",
144
144
  "libloading",
@@ -146,7 +146,7 @@ dependencies = [
146
146
 
147
147
  [[package]]
148
148
  name = "letify-driver"
149
- version = "2.8.4"
149
+ version = "2.8.5"
150
150
  dependencies = [
151
151
  "letify-wire",
152
152
  ]
@@ -164,7 +164,7 @@ dependencies = [
164
164
 
165
165
  [[package]]
166
166
  name = "letify-wire"
167
- version = "2.8.4"
167
+ version = "2.8.5"
168
168
 
169
169
  [[package]]
170
170
  name = "libc"
@@ -8,7 +8,7 @@ resolver = "3"
8
8
  members = ["letify-wire", "letify-driver", "letify-agent", "letify-quic"]
9
9
 
10
10
  [workspace.package]
11
- version = "2.8.4"
11
+ version = "2.8.5"
12
12
  edition = "2024"
13
13
  license = "MIT"
14
14
  repository = "https://github.com/DarkPyonix/letify"
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "letify-status",
3
- "version": "2.8.4",
3
+ "version": "2.8.5",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "letify-status",
9
- "version": "2.8.4",
9
+ "version": "2.8.5",
10
10
  "license": "SEE LICENSE IN LICENSE",
11
11
  "devDependencies": {
12
12
  "@types/node": "^18.19.0",
@@ -2,7 +2,7 @@
2
2
  "name": "letify-status",
3
3
  "displayName": "letify Status",
4
4
  "description": "letify quota and GPU activity in the status bar, read from the letify CLI.",
5
- "version": "2.8.4",
5
+ "version": "2.8.5",
6
6
  "publisher": "darkpyonix",
7
7
  "license": "SEE LICENSE IN LICENSE",
8
8
  "repository": {
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "letify"
3
- version = "2.8.4"
3
+ version = "2.8.5"
4
4
  description = "Declarations that become infrastructure."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -2368,6 +2368,39 @@ def test_a_modal_sandbox_is_created_with_a_lifetime_and_an_idle_limit(
2368
2368
  provider.stop(runtime)
2369
2369
 
2370
2370
 
2371
+ def test_a_modal_sandbox_requests_the_memory_the_account_sets(isolated_home, fake_modal) -> None:
2372
+ # Spec "Modal": memory_gib becomes the sandbox's memory request in MiB.
2373
+ provider = provider_of(Modal, "m", memory_gib=64)
2374
+ runtime = modal_runtime(provider)
2375
+ provider.open_channel(runtime)
2376
+ try:
2377
+ [created] = fake_modal.requests("create")
2378
+ assert created["memory"] == 64 * 1024
2379
+ finally:
2380
+ provider.stop(runtime)
2381
+
2382
+
2383
+ def test_a_modal_sandbox_requests_no_memory_when_the_account_sets_none(
2384
+ isolated_home, fake_modal
2385
+ ) -> None:
2386
+ provider = provider_of(Modal, "m")
2387
+ runtime = modal_runtime(provider)
2388
+ provider.open_channel(runtime)
2389
+ try:
2390
+ [created] = fake_modal.requests("create")
2391
+ assert created.get("memory") is None
2392
+ finally:
2393
+ provider.stop(runtime)
2394
+
2395
+
2396
+ @pytest.mark.parametrize("value", [0, -2, "lots", True])
2397
+ def test_a_memory_gib_that_is_not_a_positive_number_is_refused(config_file, value) -> None:
2398
+ from letify.errors import ConfigError
2399
+
2400
+ with pytest.raises(ConfigError, match="memory_gib"):
2401
+ _ = provider_of(Modal, "lab", memory_gib=value).sandbox_memory_mib
2402
+
2403
+
2371
2404
  # -- Modal data channel ----------------------------------------------------------
2372
2405
 
2373
2406
 
@@ -3205,3 +3238,73 @@ def test_stopping_a_colab_orphan_unassigns_its_endpoint(
3205
3238
  assert provider.stop_orphan("gpu-a") is True
3206
3239
  assert cli.unassigned == ["gpu-a"]
3207
3240
  assert provider.orphans() == []
3241
+
3242
+
3243
+ # -- Modal: a worker that stopped names what the sandbox did (spec "Modal") -----------
3244
+
3245
+
3246
+ def _died_modal_call(fake_modal, provider, *, ended: int | None) -> str:
3247
+ """Kill the worker behind a Modal channel, make a call, and return the failure text."""
3248
+ import os
3249
+ import signal
3250
+ import time
3251
+
3252
+ from letify.errors import ProtocolError
3253
+
3254
+ runtime = modal_runtime(provider)
3255
+ channel = provider.open_channel(runtime)
3256
+ channel.start()
3257
+ [pid] = fake_modal.sandbox_pids()
3258
+ if ended is not None:
3259
+ fake_modal.end_sandbox("sb-1", ended)
3260
+ os.kill(pid, signal.SIGKILL)
3261
+ try:
3262
+ with pytest.raises(ProtocolError) as caught:
3263
+ channel.call(time.sleep, (600,), {}, timeout=20)
3264
+ finally:
3265
+ channel.close()
3266
+ provider.stop(runtime)
3267
+ return str(caught.value)
3268
+
3269
+
3270
+ def test_a_worker_killed_with_exit_137_names_the_out_of_memory_kill(
3271
+ isolated_home, fake_modal
3272
+ ) -> None:
3273
+ text = _died_modal_call(fake_modal, provider_of(Modal, "m"), ended=137)
3274
+ assert "the Modal sandbox exited with code 137, which is SIGKILL" in text
3275
+ assert "most often the container running out of memory" in text
3276
+ assert "The usual causes" not in text
3277
+
3278
+
3279
+ def test_a_worker_whose_sandbox_exited_with_1_names_the_code_only(
3280
+ isolated_home, fake_modal
3281
+ ) -> None:
3282
+ text = _died_modal_call(fake_modal, provider_of(Modal, "m"), ended=1)
3283
+ assert "the Modal sandbox exited with code 1" in text
3284
+ assert "SIGKILL" not in text
3285
+ assert "The usual causes" not in text
3286
+
3287
+
3288
+ def test_a_worker_that_died_inside_a_running_sandbox_says_so(isolated_home, fake_modal) -> None:
3289
+ text = _died_modal_call(fake_modal, provider_of(Modal, "m"), ended=None)
3290
+ assert "the Modal sandbox is still running, so the worker process died inside it" in text
3291
+ assert "The usual causes" not in text
3292
+
3293
+
3294
+ def test_a_failed_poll_leaves_the_worker_death_message_as_it_was(
3295
+ isolated_home, fake_modal
3296
+ ) -> None:
3297
+ fake_modal.fail("poll")
3298
+ text = _died_modal_call(fake_modal, provider_of(Modal, "m"), ended=137)
3299
+ assert "The usual causes are an out of memory kill" in text
3300
+ assert "the Modal sandbox exited" not in text
3301
+
3302
+
3303
+ def test_a_sandbox_at_its_lifetime_keeps_the_lifetime_message_without_a_poll(
3304
+ isolated_home, fake_modal
3305
+ ) -> None:
3306
+ provider = provider_of(Modal, "m", timeout=1)
3307
+ text = _died_modal_call(fake_modal, provider, ended=137)
3308
+ assert "lifetime letify asked for" in text
3309
+ assert "exited with code" not in text
3310
+ assert fake_modal.requests("poll") == []
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes