letify 2.8.5__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.5 → letify-2.8.6}/PKG-INFO +1 -1
  2. {letify-2.8.5 → letify-2.8.6}/PROJECT.md +1 -0
  3. {letify-2.8.5 → letify-2.8.6}/docs/SPEC.md +10 -6
  4. {letify-2.8.5 → letify-2.8.6}/letify/__init__.py +19 -3
  5. {letify-2.8.5 → letify-2.8.6}/letify/protocol/worker.py +8 -2
  6. {letify-2.8.5 → letify-2.8.6}/letify/runtime/channel.py +81 -5
  7. {letify-2.8.5 → letify-2.8.6}/letify/store/backends/drive.py +35 -7
  8. {letify-2.8.5 → letify-2.8.6}/letify/store/handoff.py +243 -18
  9. {letify-2.8.5 → letify-2.8.6}/letify/transport/nat.py +96 -19
  10. {letify-2.8.5 → letify-2.8.6}/letify/transport/pipeline.py +9 -2
  11. {letify-2.8.5 → letify-2.8.6}/letify/transport/rendezvous.py +31 -0
  12. {letify-2.8.5 → letify-2.8.6}/letify/transport/strategies.py +29 -19
  13. {letify-2.8.5 → letify-2.8.6}/letify-core/Cargo.lock +3 -3
  14. {letify-2.8.5 → letify-2.8.6}/letify-core/Cargo.toml +1 -1
  15. {letify-2.8.5 → letify-2.8.6}/letify-ext/package-lock.json +2 -2
  16. {letify-2.8.5 → letify-2.8.6}/letify-ext/package.json +1 -1
  17. {letify-2.8.5 → letify-2.8.6}/pyproject.toml +1 -1
  18. {letify-2.8.5 → letify-2.8.6}/tests/conftest.py +12 -1
  19. {letify-2.8.5 → letify-2.8.6}/tests/test_drive_handoff.py +335 -0
  20. letify-2.8.6/tests/test_quic.py +370 -0
  21. {letify-2.8.5 → letify-2.8.6}/tests/test_runtime.py +92 -0
  22. {letify-2.8.5 → letify-2.8.6}/tests/test_strategies.py +64 -0
  23. {letify-2.8.5 → letify-2.8.6}/tests/test_transport.py +63 -0
  24. letify-2.8.5/tests/test_quic.py +0 -187
  25. {letify-2.8.5 → letify-2.8.6}/.github/workflows/ci.yml +0 -0
  26. {letify-2.8.5 → letify-2.8.6}/.github/workflows/docs.yml +0 -0
  27. {letify-2.8.5 → letify-2.8.6}/.github/workflows/publish.yml +0 -0
  28. {letify-2.8.5 → letify-2.8.6}/.gitignore +0 -0
  29. {letify-2.8.5 → letify-2.8.6}/.researchtree +0 -0
  30. {letify-2.8.5 → letify-2.8.6}/CLAUDE.md +0 -0
  31. {letify-2.8.5 → letify-2.8.6}/LICENSE +0 -0
  32. {letify-2.8.5 → letify-2.8.6}/README.md +0 -0
  33. {letify-2.8.5 → letify-2.8.6}/docs/COMPONENT.md +0 -0
  34. {letify-2.8.5 → letify-2.8.6}/docs/INTENT.md +0 -0
  35. {letify-2.8.5 → letify-2.8.6}/docs/NETWORK.md +0 -0
  36. {letify-2.8.5 → letify-2.8.6}/docs/guide/01-getting-started.md +0 -0
  37. {letify-2.8.5 → letify-2.8.6}/docs/guide/02-providers.md +0 -0
  38. {letify-2.8.5 → letify-2.8.6}/docs/guide/03-execution-modes.md +0 -0
  39. {letify-2.8.5 → letify-2.8.6}/docs/guide/04-environments-and-data.md +0 -0
  40. {letify-2.8.5 → letify-2.8.6}/docs/guide/05-concurrency.md +0 -0
  41. {letify-2.8.5 → letify-2.8.6}/docs/guide/06-cost.md +0 -0
  42. {letify-2.8.5 → letify-2.8.6}/docs/guide/07-troubleshooting.md +0 -0
  43. {letify-2.8.5 → letify-2.8.6}/docs/guide/08-releasing.md +0 -0
  44. {letify-2.8.5 → letify-2.8.6}/docs/guide/README.md +0 -0
  45. {letify-2.8.5 → letify-2.8.6}/docs/guide/ko/01-/354/213/234/354/236/221/355/225/230/352/270/260.md" +0 -0
  46. {letify-2.8.5 → 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
  47. {letify-2.8.5 → letify-2.8.6}/docs/guide/ko/03-/354/213/244/355/226/211-/353/260/251/354/213/235.md" +0 -0
  48. {letify-2.8.5 → 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
  49. {letify-2.8.5 → letify-2.8.6}/docs/guide/ko/05-/353/217/231/354/213/234-/354/213/244/355/226/211.md" +0 -0
  50. {letify-2.8.5 → letify-2.8.6}/docs/guide/ko/06-/353/271/204/354/232/251.md" +0 -0
  51. {letify-2.8.5 → letify-2.8.6}/docs/guide/ko/07-/353/254/270/354/240/234-/355/225/264/352/262/260.md" +0 -0
  52. {letify-2.8.5 → letify-2.8.6}/docs/guide/ko/08-/353/246/264/353/246/254/354/212/244.md" +0 -0
  53. {letify-2.8.5 → letify-2.8.6}/docs/guide/ko/README.md +0 -0
  54. {letify-2.8.5 → letify-2.8.6}/docs/locales/README_ko.md +0 -0
  55. {letify-2.8.5 → letify-2.8.6}/docs/mkdocs.yml +0 -0
  56. {letify-2.8.5 → letify-2.8.6}/examples/README.md +0 -0
  57. {letify-2.8.5 → letify-2.8.6}/examples/nvfp4-lora/.letify.example +0 -0
  58. {letify-2.8.5 → letify-2.8.6}/examples/nvfp4-lora/00_smoke.py +0 -0
  59. {letify-2.8.5 → letify-2.8.6}/examples/nvfp4-lora/01_sweep.py +0 -0
  60. {letify-2.8.5 → letify-2.8.6}/examples/nvfp4-lora/02_resume.py +0 -0
  61. {letify-2.8.5 → letify-2.8.6}/examples/nvfp4-lora/03_watch.py +0 -0
  62. {letify-2.8.5 → letify-2.8.6}/examples/nvfp4-lora/README.md +0 -0
  63. {letify-2.8.5 → letify-2.8.6}/examples/nvfp4-lora/README_ko.md +0 -0
  64. {letify-2.8.5 → letify-2.8.6}/examples/nvfp4-lora/common.py +0 -0
  65. {letify-2.8.5 → letify-2.8.6}/examples/nvfp4-lora/recipe.py +0 -0
  66. {letify-2.8.5 → letify-2.8.6}/letify/cli.py +0 -0
  67. {letify-2.8.5 → letify-2.8.6}/letify/config/__init__.py +0 -0
  68. {letify-2.8.5 → letify-2.8.6}/letify/config/inventory.py +0 -0
  69. {letify-2.8.5 → letify-2.8.6}/letify/config/login.py +0 -0
  70. {letify-2.8.5 → letify-2.8.6}/letify/config/schema.py +0 -0
  71. {letify-2.8.5 → letify-2.8.6}/letify/config/secrets.py +0 -0
  72. {letify-2.8.5 → letify-2.8.6}/letify/config/writer.py +0 -0
  73. {letify-2.8.5 → letify-2.8.6}/letify/declare/__init__.py +0 -0
  74. {letify-2.8.5 → letify-2.8.6}/letify/declare/cache.py +0 -0
  75. {letify-2.8.5 → letify-2.8.6}/letify/declare/env.py +0 -0
  76. {letify-2.8.5 → letify-2.8.6}/letify/declare/function.py +0 -0
  77. {letify-2.8.5 → letify-2.8.6}/letify/declare/instance.py +0 -0
  78. {letify-2.8.5 → letify-2.8.6}/letify/declare/reduce.py +0 -0
  79. {letify-2.8.5 → letify-2.8.6}/letify/declare/replicas.py +0 -0
  80. {letify-2.8.5 → letify-2.8.6}/letify/errors.py +0 -0
  81. {letify-2.8.5 → letify-2.8.6}/letify/install.py +0 -0
  82. {letify-2.8.5 → letify-2.8.6}/letify/launcher.py +0 -0
  83. {letify-2.8.5 → letify-2.8.6}/letify/paths.py +0 -0
  84. {letify-2.8.5 → letify-2.8.6}/letify/protocol/__init__.py +0 -0
  85. {letify-2.8.5 → letify-2.8.6}/letify/protocol/codec.py +0 -0
  86. {letify-2.8.5 → letify-2.8.6}/letify/protocol/driver.py +0 -0
  87. {letify-2.8.5 → letify-2.8.6}/letify/protocol/handle.py +0 -0
  88. {letify-2.8.5 → letify-2.8.6}/letify/protocol/wire.py +0 -0
  89. {letify-2.8.5 → letify-2.8.6}/letify/providers/__init__.py +0 -0
  90. {letify-2.8.5 → letify-2.8.6}/letify/providers/base.py +0 -0
  91. {letify-2.8.5 → letify-2.8.6}/letify/providers/colab.py +0 -0
  92. {letify-2.8.5 → letify-2.8.6}/letify/providers/colab_files.py +0 -0
  93. {letify-2.8.5 → letify-2.8.6}/letify/providers/colab_keepalive.py +0 -0
  94. {letify-2.8.5 → letify-2.8.6}/letify/providers/elice.py +0 -0
  95. {letify-2.8.5 → letify-2.8.6}/letify/providers/kaggle.py +0 -0
  96. {letify-2.8.5 → letify-2.8.6}/letify/providers/kaggle_adapter.py +0 -0
  97. {letify-2.8.5 → letify-2.8.6}/letify/providers/local.py +0 -0
  98. {letify-2.8.5 → letify-2.8.6}/letify/providers/modal.py +0 -0
  99. {letify-2.8.5 → letify-2.8.6}/letify/providers/modal_adapter.py +0 -0
  100. {letify-2.8.5 → letify-2.8.6}/letify/providers/naming.py +0 -0
  101. {letify-2.8.5 → letify-2.8.6}/letify/providers/shell.py +0 -0
  102. {letify-2.8.5 → letify-2.8.6}/letify/providers/tunnel.py +0 -0
  103. {letify-2.8.5 → letify-2.8.6}/letify/providers/usage.py +0 -0
  104. {letify-2.8.5 → letify-2.8.6}/letify/remoting/__init__.py +0 -0
  105. {letify-2.8.5 → letify-2.8.6}/letify/remoting/capability.py +0 -0
  106. {letify-2.8.5 → letify-2.8.6}/letify/remoting/device/__init__.py +0 -0
  107. {letify-2.8.5 → letify-2.8.6}/letify/remoting/device/client.py +0 -0
  108. {letify-2.8.5 → letify-2.8.6}/letify/remoting/device/cuda.py +0 -0
  109. {letify-2.8.5 → letify-2.8.6}/letify/remoting/device/executor.py +0 -0
  110. {letify-2.8.5 → letify-2.8.6}/letify/remoting/device/frames.py +0 -0
  111. {letify-2.8.5 → letify-2.8.6}/letify/remoting/device/guard.py +0 -0
  112. {letify-2.8.5 → letify-2.8.6}/letify/remoting/device/tensor.py +0 -0
  113. {letify-2.8.5 → letify-2.8.6}/letify/remoting/device/trace.py +0 -0
  114. {letify-2.8.5 → letify-2.8.6}/letify/remoting/device/value.py +0 -0
  115. {letify-2.8.5 → letify-2.8.6}/letify/remoting/loader.py +0 -0
  116. {letify-2.8.5 → letify-2.8.6}/letify/remoting/probe.py +0 -0
  117. {letify-2.8.5 → letify-2.8.6}/letify/render.py +0 -0
  118. {letify-2.8.5 → letify-2.8.6}/letify/runtime/__init__.py +0 -0
  119. {letify-2.8.5 → letify-2.8.6}/letify/runtime/bootstrap.py +0 -0
  120. {letify-2.8.5 → letify-2.8.6}/letify/runtime/broker.py +0 -0
  121. {letify-2.8.5 → letify-2.8.6}/letify/runtime/lease.py +0 -0
  122. {letify-2.8.5 → letify-2.8.6}/letify/runtime/pool.py +0 -0
  123. {letify-2.8.5 → letify-2.8.6}/letify/runtime/session.py +0 -0
  124. {letify-2.8.5 → letify-2.8.6}/letify/runtime/telemetry.py +0 -0
  125. {letify-2.8.5 → letify-2.8.6}/letify/store/__init__.py +0 -0
  126. {letify-2.8.5 → letify-2.8.6}/letify/store/backends/__init__.py +0 -0
  127. {letify-2.8.5 → letify-2.8.6}/letify/store/backends/filesystem.py +0 -0
  128. {letify-2.8.5 → letify-2.8.6}/letify/store/backends/google_auth.py +0 -0
  129. {letify-2.8.5 → letify-2.8.6}/letify/store/backends/layout.py +0 -0
  130. {letify-2.8.5 → letify-2.8.6}/letify/store/backends/objects.py +0 -0
  131. {letify-2.8.5 → letify-2.8.6}/letify/store/cas.py +0 -0
  132. {letify-2.8.5 → letify-2.8.6}/letify/store/pathdata.py +0 -0
  133. {letify-2.8.5 → letify-2.8.6}/letify/store/sendorder.py +0 -0
  134. {letify-2.8.5 → letify-2.8.6}/letify/store/volume.py +0 -0
  135. {letify-2.8.5 → letify-2.8.6}/letify/stubs.py +0 -0
  136. {letify-2.8.5 → letify-2.8.6}/letify/tools.py +0 -0
  137. {letify-2.8.5 → letify-2.8.6}/letify/transport/__init__.py +0 -0
  138. {letify-2.8.5 → letify-2.8.6}/letify/transport/agent.py +0 -0
  139. {letify-2.8.5 → letify-2.8.6}/letify/transport/announce.py +0 -0
  140. {letify-2.8.5 → letify-2.8.6}/letify/transport/link.py +0 -0
  141. {letify-2.8.5 → letify-2.8.6}/letify/transport/portmap.py +0 -0
  142. {letify-2.8.5 → letify-2.8.6}/letify/transport/probe.py +0 -0
  143. {letify-2.8.5 → letify-2.8.6}/letify/transport/quic.py +0 -0
  144. {letify-2.8.5 → letify-2.8.6}/letify/transport/setup.py +0 -0
  145. {letify-2.8.5 → letify-2.8.6}/letify/transport/sshopts.py +0 -0
  146. {letify-2.8.5 → letify-2.8.6}/letify-core/.gitignore +0 -0
  147. {letify-2.8.5 → letify-2.8.6}/letify-core/build.py +0 -0
  148. {letify-2.8.5 → letify-2.8.6}/letify-core/letify-agent/Cargo.toml +0 -0
  149. {letify-2.8.5 → letify-2.8.6}/letify-core/letify-agent/src/driver.rs +0 -0
  150. {letify-2.8.5 → letify-2.8.6}/letify-core/letify-agent/src/main.rs +0 -0
  151. {letify-2.8.5 → letify-2.8.6}/letify-core/letify-driver/Cargo.toml +0 -0
  152. {letify-2.8.5 → letify-2.8.6}/letify-core/letify-driver/src/client.rs +0 -0
  153. {letify-2.8.5 → letify-2.8.6}/letify-core/letify-driver/src/exports.rs +0 -0
  154. {letify-2.8.5 → letify-2.8.6}/letify-core/letify-driver/src/image.rs +0 -0
  155. {letify-2.8.5 → letify-2.8.6}/letify-core/letify-driver/src/lib.rs +0 -0
  156. {letify-2.8.5 → letify-2.8.6}/letify-core/letify-driver/src/status.rs +0 -0
  157. {letify-2.8.5 → letify-2.8.6}/letify-core/letify-driver/src/table.rs +0 -0
  158. {letify-2.8.5 → letify-2.8.6}/letify-core/letify-quic/Cargo.toml +0 -0
  159. {letify-2.8.5 → letify-2.8.6}/letify-core/letify-quic/src/main.rs +0 -0
  160. {letify-2.8.5 → letify-2.8.6}/letify-core/letify-wire/Cargo.toml +0 -0
  161. {letify-2.8.5 → letify-2.8.6}/letify-core/letify-wire/src/lib.rs +0 -0
  162. {letify-2.8.5 → letify-2.8.6}/letify-core/letify-wire/tests/throughput.rs +0 -0
  163. {letify-2.8.5 → letify-2.8.6}/letify-core/rust-toolchain.toml +0 -0
  164. {letify-2.8.5 → letify-2.8.6}/letify-ext/.vscodeignore +0 -0
  165. {letify-2.8.5 → letify-2.8.6}/letify-ext/LICENSE +0 -0
  166. {letify-2.8.5 → letify-2.8.6}/letify-ext/README.md +0 -0
  167. {letify-2.8.5 → letify-2.8.6}/letify-ext/media/icon.svg +0 -0
  168. {letify-2.8.5 → letify-2.8.6}/letify-ext/src/cli.ts +0 -0
  169. {letify-2.8.5 → letify-2.8.6}/letify-ext/src/extension.ts +0 -0
  170. {letify-2.8.5 → letify-2.8.6}/letify-ext/src/history.ts +0 -0
  171. {letify-2.8.5 → letify-2.8.6}/letify-ext/src/model.ts +0 -0
  172. {letify-2.8.5 → letify-2.8.6}/letify-ext/src/poll.ts +0 -0
  173. {letify-2.8.5 → letify-2.8.6}/letify-ext/src/render.ts +0 -0
  174. {letify-2.8.5 → letify-2.8.6}/letify-ext/test/cli.test.ts +0 -0
  175. {letify-2.8.5 → letify-2.8.6}/letify-ext/test/manifest.test.ts +0 -0
  176. {letify-2.8.5 → letify-2.8.6}/letify-ext/test/model.test.ts +0 -0
  177. {letify-2.8.5 → letify-2.8.6}/letify-ext/test/regressions.test.ts +0 -0
  178. {letify-2.8.5 → letify-2.8.6}/letify-ext/tsconfig.json +0 -0
  179. {letify-2.8.5 → letify-2.8.6}/letify_providers.py +0 -0
  180. {letify-2.8.5 → letify-2.8.6}/scripts/sync-main.sh +0 -0
  181. {letify-2.8.5 → letify-2.8.6}/scripts/version.py +0 -0
  182. {letify-2.8.5 → letify-2.8.6}/tests/fake_kaggle_adapter.py +0 -0
  183. {letify-2.8.5 → letify-2.8.6}/tests/fake_modal_adapter.py +0 -0
  184. {letify-2.8.5 → letify-2.8.6}/tests/test_cli.py +0 -0
  185. {letify-2.8.5 → letify-2.8.6}/tests/test_cli_output.py +0 -0
  186. {letify-2.8.5 → letify-2.8.6}/tests/test_colab_keepalive.py +0 -0
  187. {letify-2.8.5 → letify-2.8.6}/tests/test_config.py +0 -0
  188. {letify-2.8.5 → letify-2.8.6}/tests/test_core.py +0 -0
  189. {letify-2.8.5 → letify-2.8.6}/tests/test_data.py +0 -0
  190. {letify-2.8.5 → letify-2.8.6}/tests/test_data_streaming.py +0 -0
  191. {letify-2.8.5 → letify-2.8.6}/tests/test_declare.py +0 -0
  192. {letify-2.8.5 → letify-2.8.6}/tests/test_device.py +0 -0
  193. {letify-2.8.5 → letify-2.8.6}/tests/test_distributed.py +0 -0
  194. {letify-2.8.5 → letify-2.8.6}/tests/test_drive_store.py +0 -0
  195. {letify-2.8.5 → letify-2.8.6}/tests/test_editor_sessions.py +0 -0
  196. {letify-2.8.5 → letify-2.8.6}/tests/test_elice.py +0 -0
  197. {letify-2.8.5 → letify-2.8.6}/tests/test_frames.py +0 -0
  198. {letify-2.8.5 → letify-2.8.6}/tests/test_install.py +0 -0
  199. {letify-2.8.5 → letify-2.8.6}/tests/test_kaggle.py +0 -0
  200. {letify-2.8.5 → letify-2.8.6}/tests/test_kaggle_cookie.py +0 -0
  201. {letify-2.8.5 → letify-2.8.6}/tests/test_kaggle_link.py +0 -0
  202. {letify-2.8.5 → letify-2.8.6}/tests/test_link_log.py +0 -0
  203. {letify-2.8.5 → letify-2.8.6}/tests/test_notebook.py +0 -0
  204. {letify-2.8.5 → letify-2.8.6}/tests/test_parallel_placement.py +0 -0
  205. {letify-2.8.5 → letify-2.8.6}/tests/test_paths.py +0 -0
  206. {letify-2.8.5 → letify-2.8.6}/tests/test_portmap.py +0 -0
  207. {letify-2.8.5 → letify-2.8.6}/tests/test_protocol.py +0 -0
  208. {letify-2.8.5 → letify-2.8.6}/tests/test_providers.py +0 -0
  209. {letify-2.8.5 → letify-2.8.6}/tests/test_remoting.py +0 -0
  210. {letify-2.8.5 → letify-2.8.6}/tests/test_render.py +0 -0
  211. {letify-2.8.5 → letify-2.8.6}/tests/test_ship.py +0 -0
  212. {letify-2.8.5 → letify-2.8.6}/tests/test_store.py +0 -0
  213. {letify-2.8.5 → letify-2.8.6}/tests/test_stubs.py +0 -0
  214. {letify-2.8.5 → letify-2.8.6}/tests/test_tunnel_setup.py +0 -0
  215. {letify-2.8.5 → 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.5
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.
@@ -818,6 +818,8 @@ A sandbox that reaches that lifetime takes its worker with it, so the channel se
818
818
 
819
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
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
+
821
823
  ### Modal data channel <!-- id: modal-data-channel -->
822
824
 
823
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.
@@ -1560,10 +1562,12 @@ A token is reused until 60 s before it expires. A service account key file is re
1560
1562
 
1561
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.
1562
1564
 
1563
- `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.
1564
1566
 
1565
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.
1566
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
+
1567
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.
1568
1572
 
1569
1573
  The name `unstash` is not `fetch`, because `letify.fetch(tensor)` already reads a tensor back in `host=local`.
@@ -1571,7 +1575,7 @@ The name `unstash` is not `fetch`, because `letify.fetch(tensor)` already reads
1571
1575
  Both run in two places with the same result:
1572
1576
 
1573
1577
  - **In the local process**, the client moves the bytes itself with the `DriveBackend` of Google Drive is a store, not a volume.
1574
- - **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.
1575
1579
 
1576
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.
1577
1581
 
@@ -1727,7 +1731,7 @@ A `Shell` subclass differs from its parent only in its `Rendezvous` and its stra
1727
1731
 
1728
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.
1729
1733
 
1730
- 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.
1731
1735
 
1732
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.
1733
1737
 
@@ -1807,7 +1811,7 @@ A connection to the agent is told apart by its first bytes: `SSH-` is spliced to
1807
1811
 
1808
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.
1809
1813
 
1810
- 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.
1811
1815
 
1812
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.
1813
1817
 
@@ -1821,7 +1825,7 @@ A punch that ends with nothing connected names what happened to its dials, becau
1821
1825
 
1822
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.
1823
1827
 
1824
- 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.
1825
1829
 
1826
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.
1827
1831
 
@@ -1957,7 +1961,7 @@ Colab limits outbound UDP to roughly 200 packets per second, so rank 3 is expect
1957
1961
 
1958
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.
1959
1963
 
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.
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.
1961
1965
 
1962
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.
1963
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.5"
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):
@@ -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
@@ -779,6 +780,27 @@ class FramedChannel(Channel):
779
780
  return connection.pipeline(window=window, timeout=timeout, kill=self._kill)
780
781
 
781
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
+
782
804
  class PersistentChannel(FramedChannel):
783
805
  """One worker process, kept alive behind a pipe pair.
784
806
 
@@ -798,6 +820,8 @@ class PersistentChannel(FramedChannel):
798
820
  self._start_lock = threading.Lock()
799
821
  self._stderr = _Tail()
800
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
801
825
 
802
826
  # -- lifecycle -----------------------------------------------------------
803
827
 
@@ -805,9 +829,10 @@ class PersistentChannel(FramedChannel):
805
829
  with self._start_lock:
806
830
  if self._process is not None:
807
831
  return
832
+ command = self._with_ssh_log()
808
833
  try:
809
834
  process = subprocess.Popen(
810
- self.command,
835
+ command,
811
836
  stdin=subprocess.PIPE,
812
837
  stdout=subprocess.PIPE,
813
838
  stderr=subprocess.PIPE,
@@ -815,6 +840,7 @@ class PersistentChannel(FramedChannel):
815
840
  env=self.env,
816
841
  )
817
842
  except OSError as exc:
843
+ self._drop_ssh_log()
818
844
  raise RuntimeFailure(
819
845
  f"{self.name}: could not start the worker: {exc}",
820
846
  command=" ".join(self.command),
@@ -834,7 +860,8 @@ class PersistentChannel(FramedChannel):
834
860
  wire.fd_writer(process.stdin.fileno()),
835
861
  process.stdout.readinto, # type: ignore[attr-defined]
836
862
  self._emit,
837
- death_detail=self._stderr_text,
863
+ death_detail=lambda: self._stderr_text() + self._ssh_log_section(),
864
+ death_cause=self._exit_cause,
838
865
  poll_fd=process.stdout.fileno(),
839
866
  )
840
867
  self._send_worker()
@@ -844,6 +871,48 @@ class PersistentChannel(FramedChannel):
844
871
  if self._process is not None:
845
872
  self._process.kill()
846
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
+
847
916
  def _stderr_text(self) -> str:
848
917
  """The process's own standard error, complete once the process has exited."""
849
918
  process = self._process
@@ -859,10 +928,15 @@ class PersistentChannel(FramedChannel):
859
928
  def _startup_failure(self, expired: bool, cause: Exception) -> Exception:
860
929
  if expired:
861
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 ""
862
934
  return RuntimeFailure(
863
- 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()}",
864
938
  command=" ".join(self.command),
865
- stderr=self._stderr_text().strip(),
939
+ stderr=stderr,
866
940
  )
867
941
 
868
942
  def close(self) -> None:
@@ -885,6 +959,7 @@ class PersistentChannel(FramedChannel):
885
959
  finally:
886
960
  if self._stderr_reader is not None:
887
961
  self._stderr_reader.join(1)
962
+ self._drop_ssh_log()
888
963
 
889
964
  @property
890
965
  def alive(self) -> bool:
@@ -899,7 +974,8 @@ class PersistentChannel(FramedChannel):
899
974
  if process is None or connection is None:
900
975
  raise RuntimeLost(f"{self.name}: the channel is closed")
901
976
  if process.poll() is not None:
902
- 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}")
903
979
  return connection.request(payload, timeout=timeout, kill=self._kill)
904
980
 
905
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