letify 2.9.0__tar.gz → 2.10.0__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 (213) hide show
  1. {letify-2.9.0 → letify-2.10.0}/PKG-INFO +3 -1
  2. {letify-2.9.0 → letify-2.10.0}/README.md +2 -0
  3. {letify-2.9.0 → letify-2.10.0}/docs/SPEC.md +57 -9
  4. {letify-2.9.0 → letify-2.10.0}/docs/guide/03-execution-modes.md +2 -0
  5. {letify-2.9.0 → letify-2.10.0}/docs/guide/05-concurrency.md +1 -1
  6. {letify-2.9.0 → letify-2.10.0}/docs/guide/07-troubleshooting.md +10 -0
  7. {letify-2.9.0 → letify-2.10.0}/docs/guide/ko/03-/354/213/244/355/226/211-/353/260/251/354/213/235.md +2 -0
  8. {letify-2.9.0 → letify-2.10.0}/docs/guide/ko/05-/353/217/231/354/213/234-/354/213/244/355/226/211.md +1 -1
  9. {letify-2.9.0 → letify-2.10.0}/docs/guide/ko/07-/353/254/270/354/240/234-/355/225/264/352/262/260.md +10 -0
  10. {letify-2.9.0 → letify-2.10.0}/docs/locales/README_ko.md +2 -0
  11. {letify-2.9.0 → letify-2.10.0}/letify/__init__.py +1 -1
  12. {letify-2.9.0 → letify-2.10.0}/letify/cli.py +20 -0
  13. {letify-2.9.0 → letify-2.10.0}/letify/launcher.py +12 -0
  14. {letify-2.9.0 → letify-2.10.0}/letify/protocol/worker.py +173 -3
  15. {letify-2.9.0 → letify-2.10.0}/letify/providers/base.py +11 -0
  16. {letify-2.9.0 → letify-2.10.0}/letify/providers/colab.py +3 -0
  17. {letify-2.9.0 → letify-2.10.0}/letify/providers/elice.py +2 -0
  18. {letify-2.9.0 → letify-2.10.0}/letify/providers/shell.py +78 -0
  19. {letify-2.9.0 → letify-2.10.0}/letify/providers/tunnel.py +2 -0
  20. {letify-2.9.0 → letify-2.10.0}/letify/remoting/device/client.py +9 -1
  21. {letify-2.9.0 → letify-2.10.0}/letify/remoting/device/cuda.py +28 -12
  22. {letify-2.9.0 → letify-2.10.0}/letify/remoting/device/executor.py +397 -1
  23. {letify-2.9.0 → letify-2.10.0}/letify/remoting/device/value.py +22 -1
  24. {letify-2.9.0 → letify-2.10.0}/letify/runtime/session.py +29 -0
  25. letify-2.10.0/letify/runtime/workers.py +245 -0
  26. {letify-2.9.0 → letify-2.10.0}/letify/transport/link.py +14 -5
  27. {letify-2.9.0 → letify-2.10.0}/letify/transport/nat.py +121 -10
  28. {letify-2.9.0 → letify-2.10.0}/letify/transport/pipeline.py +2 -0
  29. {letify-2.9.0 → letify-2.10.0}/letify/transport/strategies.py +46 -36
  30. {letify-2.9.0 → letify-2.10.0}/letify-core/Cargo.lock +2 -2
  31. {letify-2.9.0 → letify-2.10.0}/letify-core/Cargo.toml +1 -1
  32. {letify-2.9.0 → letify-2.10.0}/letify-ext/package-lock.json +2 -2
  33. {letify-2.9.0 → letify-2.10.0}/letify-ext/package.json +1 -1
  34. {letify-2.9.0 → letify-2.10.0}/pyproject.toml +1 -1
  35. {letify-2.9.0 → letify-2.10.0}/tests/test_device.py +67 -4
  36. letify-2.10.0/tests/test_device_compile.py +135 -0
  37. letify-2.10.0/tests/test_punch_sockets.py +174 -0
  38. {letify-2.9.0 → letify-2.10.0}/tests/test_run_takeover.py +123 -0
  39. letify-2.10.0/tests/test_shell_workers.py +433 -0
  40. {letify-2.9.0 → letify-2.10.0}/.github/workflows/ci.yml +0 -0
  41. {letify-2.9.0 → letify-2.10.0}/.github/workflows/docs.yml +0 -0
  42. {letify-2.9.0 → letify-2.10.0}/.github/workflows/publish-crates.yml +0 -0
  43. {letify-2.9.0 → letify-2.10.0}/.github/workflows/publish.yml +0 -0
  44. {letify-2.9.0 → letify-2.10.0}/.github/workflows/reserve-crates.yml +0 -0
  45. {letify-2.9.0 → letify-2.10.0}/.gitignore +0 -0
  46. {letify-2.9.0 → letify-2.10.0}/.researchtree +0 -0
  47. {letify-2.9.0 → letify-2.10.0}/CLAUDE.md +0 -0
  48. {letify-2.9.0 → letify-2.10.0}/LICENSE +0 -0
  49. {letify-2.9.0 → letify-2.10.0}/PROJECT.md +0 -0
  50. {letify-2.9.0 → letify-2.10.0}/docs/COMPONENT.md +0 -0
  51. {letify-2.9.0 → letify-2.10.0}/docs/INTENT.md +0 -0
  52. {letify-2.9.0 → letify-2.10.0}/docs/NETWORK.md +0 -0
  53. {letify-2.9.0 → letify-2.10.0}/docs/guide/01-getting-started.md +0 -0
  54. {letify-2.9.0 → letify-2.10.0}/docs/guide/02-providers.md +0 -0
  55. {letify-2.9.0 → letify-2.10.0}/docs/guide/04-environments-and-data.md +0 -0
  56. {letify-2.9.0 → letify-2.10.0}/docs/guide/06-cost.md +0 -0
  57. {letify-2.9.0 → letify-2.10.0}/docs/guide/08-releasing.md +0 -0
  58. {letify-2.9.0 → letify-2.10.0}/docs/guide/README.md +0 -0
  59. {letify-2.9.0 → letify-2.10.0}/docs/guide/ko/01-/354/213/234/354/236/221/355/225/230/352/270/260.md" +0 -0
  60. {letify-2.9.0 → letify-2.10.0}/docs/guide/ko/02-/355/224/204/353/241/234/353/260/224/354/235/264/353/215/224.md" +0 -0
  61. {letify-2.9.0 → letify-2.10.0}/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
  62. {letify-2.9.0 → letify-2.10.0}/docs/guide/ko/06-/353/271/204/354/232/251.md" +0 -0
  63. {letify-2.9.0 → letify-2.10.0}/docs/guide/ko/08-/353/246/264/353/246/254/354/212/244.md" +0 -0
  64. {letify-2.9.0 → letify-2.10.0}/docs/guide/ko/README.md +0 -0
  65. {letify-2.9.0 → letify-2.10.0}/docs/mkdocs.yml +0 -0
  66. {letify-2.9.0 → letify-2.10.0}/examples/README.md +0 -0
  67. {letify-2.9.0 → letify-2.10.0}/examples/nvfp4-lora/.letify.example +0 -0
  68. {letify-2.9.0 → letify-2.10.0}/examples/nvfp4-lora/00_smoke.py +0 -0
  69. {letify-2.9.0 → letify-2.10.0}/examples/nvfp4-lora/01_sweep.py +0 -0
  70. {letify-2.9.0 → letify-2.10.0}/examples/nvfp4-lora/02_resume.py +0 -0
  71. {letify-2.9.0 → letify-2.10.0}/examples/nvfp4-lora/03_watch.py +0 -0
  72. {letify-2.9.0 → letify-2.10.0}/examples/nvfp4-lora/README.md +0 -0
  73. {letify-2.9.0 → letify-2.10.0}/examples/nvfp4-lora/README_ko.md +0 -0
  74. {letify-2.9.0 → letify-2.10.0}/examples/nvfp4-lora/common.py +0 -0
  75. {letify-2.9.0 → letify-2.10.0}/examples/nvfp4-lora/recipe.py +0 -0
  76. {letify-2.9.0 → letify-2.10.0}/letify/config/__init__.py +0 -0
  77. {letify-2.9.0 → letify-2.10.0}/letify/config/inventory.py +0 -0
  78. {letify-2.9.0 → letify-2.10.0}/letify/config/login.py +0 -0
  79. {letify-2.9.0 → letify-2.10.0}/letify/config/schema.py +0 -0
  80. {letify-2.9.0 → letify-2.10.0}/letify/config/secrets.py +0 -0
  81. {letify-2.9.0 → letify-2.10.0}/letify/config/writer.py +0 -0
  82. {letify-2.9.0 → letify-2.10.0}/letify/declare/__init__.py +0 -0
  83. {letify-2.9.0 → letify-2.10.0}/letify/declare/cache.py +0 -0
  84. {letify-2.9.0 → letify-2.10.0}/letify/declare/env.py +0 -0
  85. {letify-2.9.0 → letify-2.10.0}/letify/declare/exit.py +0 -0
  86. {letify-2.9.0 → letify-2.10.0}/letify/declare/function.py +0 -0
  87. {letify-2.9.0 → letify-2.10.0}/letify/declare/instance.py +0 -0
  88. {letify-2.9.0 → letify-2.10.0}/letify/declare/reduce.py +0 -0
  89. {letify-2.9.0 → letify-2.10.0}/letify/declare/replicas.py +0 -0
  90. {letify-2.9.0 → letify-2.10.0}/letify/errors.py +0 -0
  91. {letify-2.9.0 → letify-2.10.0}/letify/install.py +0 -0
  92. {letify-2.9.0 → letify-2.10.0}/letify/paths.py +0 -0
  93. {letify-2.9.0 → letify-2.10.0}/letify/protocol/__init__.py +0 -0
  94. {letify-2.9.0 → letify-2.10.0}/letify/protocol/codec.py +0 -0
  95. {letify-2.9.0 → letify-2.10.0}/letify/protocol/driver.py +0 -0
  96. {letify-2.9.0 → letify-2.10.0}/letify/protocol/handle.py +0 -0
  97. {letify-2.9.0 → letify-2.10.0}/letify/protocol/wire.py +0 -0
  98. {letify-2.9.0 → letify-2.10.0}/letify/providers/__init__.py +0 -0
  99. {letify-2.9.0 → letify-2.10.0}/letify/providers/colab_files.py +0 -0
  100. {letify-2.9.0 → letify-2.10.0}/letify/providers/colab_keepalive.py +0 -0
  101. {letify-2.9.0 → letify-2.10.0}/letify/providers/kaggle.py +0 -0
  102. {letify-2.9.0 → letify-2.10.0}/letify/providers/kaggle_adapter.py +0 -0
  103. {letify-2.9.0 → letify-2.10.0}/letify/providers/local.py +0 -0
  104. {letify-2.9.0 → letify-2.10.0}/letify/providers/modal.py +0 -0
  105. {letify-2.9.0 → letify-2.10.0}/letify/providers/modal_adapter.py +0 -0
  106. {letify-2.9.0 → letify-2.10.0}/letify/providers/naming.py +0 -0
  107. {letify-2.9.0 → letify-2.10.0}/letify/providers/usage.py +0 -0
  108. {letify-2.9.0 → letify-2.10.0}/letify/remoting/__init__.py +0 -0
  109. {letify-2.9.0 → letify-2.10.0}/letify/remoting/device/__init__.py +0 -0
  110. {letify-2.9.0 → letify-2.10.0}/letify/remoting/device/frames.py +0 -0
  111. {letify-2.9.0 → letify-2.10.0}/letify/remoting/device/guard.py +0 -0
  112. {letify-2.9.0 → letify-2.10.0}/letify/remoting/device/tensor.py +0 -0
  113. {letify-2.9.0 → letify-2.10.0}/letify/remoting/device/trace.py +0 -0
  114. {letify-2.9.0 → letify-2.10.0}/letify/render.py +0 -0
  115. {letify-2.9.0 → letify-2.10.0}/letify/runtime/__init__.py +0 -0
  116. {letify-2.9.0 → letify-2.10.0}/letify/runtime/bootstrap.py +0 -0
  117. {letify-2.9.0 → letify-2.10.0}/letify/runtime/broker.py +0 -0
  118. {letify-2.9.0 → letify-2.10.0}/letify/runtime/channel.py +0 -0
  119. {letify-2.9.0 → letify-2.10.0}/letify/runtime/control.py +0 -0
  120. {letify-2.9.0 → letify-2.10.0}/letify/runtime/lease.py +0 -0
  121. {letify-2.9.0 → letify-2.10.0}/letify/runtime/pool.py +0 -0
  122. {letify-2.9.0 → letify-2.10.0}/letify/runtime/takeover.py +0 -0
  123. {letify-2.9.0 → letify-2.10.0}/letify/runtime/telemetry.py +0 -0
  124. {letify-2.9.0 → letify-2.10.0}/letify/store/__init__.py +0 -0
  125. {letify-2.9.0 → letify-2.10.0}/letify/store/backends/__init__.py +0 -0
  126. {letify-2.9.0 → letify-2.10.0}/letify/store/backends/drive.py +0 -0
  127. {letify-2.9.0 → letify-2.10.0}/letify/store/backends/filesystem.py +0 -0
  128. {letify-2.9.0 → letify-2.10.0}/letify/store/backends/google_auth.py +0 -0
  129. {letify-2.9.0 → letify-2.10.0}/letify/store/backends/layout.py +0 -0
  130. {letify-2.9.0 → letify-2.10.0}/letify/store/backends/objects.py +0 -0
  131. {letify-2.9.0 → letify-2.10.0}/letify/store/cas.py +0 -0
  132. {letify-2.9.0 → letify-2.10.0}/letify/store/handoff.py +0 -0
  133. {letify-2.9.0 → letify-2.10.0}/letify/store/pathdata.py +0 -0
  134. {letify-2.9.0 → letify-2.10.0}/letify/store/sendorder.py +0 -0
  135. {letify-2.9.0 → letify-2.10.0}/letify/store/volume.py +0 -0
  136. {letify-2.9.0 → letify-2.10.0}/letify/stubs.py +0 -0
  137. {letify-2.9.0 → letify-2.10.0}/letify/tools.py +0 -0
  138. {letify-2.9.0 → letify-2.10.0}/letify/transport/__init__.py +0 -0
  139. {letify-2.9.0 → letify-2.10.0}/letify/transport/agent.py +0 -0
  140. {letify-2.9.0 → letify-2.10.0}/letify/transport/announce.py +0 -0
  141. {letify-2.9.0 → letify-2.10.0}/letify/transport/portmap.py +0 -0
  142. {letify-2.9.0 → letify-2.10.0}/letify/transport/probe.py +0 -0
  143. {letify-2.9.0 → letify-2.10.0}/letify/transport/quic.py +0 -0
  144. {letify-2.9.0 → letify-2.10.0}/letify/transport/rendezvous.py +0 -0
  145. {letify-2.9.0 → letify-2.10.0}/letify/transport/setup.py +0 -0
  146. {letify-2.9.0 → letify-2.10.0}/letify/transport/sshopts.py +0 -0
  147. {letify-2.9.0 → letify-2.10.0}/letify-core/.gitignore +0 -0
  148. {letify-2.9.0 → letify-2.10.0}/letify-core/build.py +0 -0
  149. {letify-2.9.0 → letify-2.10.0}/letify-core/letify/Cargo.toml +0 -0
  150. {letify-2.9.0 → letify-2.10.0}/letify-core/letify/LICENSE +0 -0
  151. {letify-2.9.0 → letify-2.10.0}/letify-core/letify/README.md +0 -0
  152. {letify-2.9.0 → letify-2.10.0}/letify-core/letify/src/lib.rs +0 -0
  153. {letify-2.9.0 → letify-2.10.0}/letify-core/letify-quic/Cargo.toml +0 -0
  154. {letify-2.9.0 → letify-2.10.0}/letify-core/letify-quic/src/main.rs +0 -0
  155. {letify-2.9.0 → letify-2.10.0}/letify-core/rust-toolchain.toml +0 -0
  156. {letify-2.9.0 → letify-2.10.0}/letify-ext/.vscodeignore +0 -0
  157. {letify-2.9.0 → letify-2.10.0}/letify-ext/LICENSE +0 -0
  158. {letify-2.9.0 → letify-2.10.0}/letify-ext/README.md +0 -0
  159. {letify-2.9.0 → letify-2.10.0}/letify-ext/media/icon.svg +0 -0
  160. {letify-2.9.0 → letify-2.10.0}/letify-ext/src/cli.ts +0 -0
  161. {letify-2.9.0 → letify-2.10.0}/letify-ext/src/extension.ts +0 -0
  162. {letify-2.9.0 → letify-2.10.0}/letify-ext/src/history.ts +0 -0
  163. {letify-2.9.0 → letify-2.10.0}/letify-ext/src/model.ts +0 -0
  164. {letify-2.9.0 → letify-2.10.0}/letify-ext/src/poll.ts +0 -0
  165. {letify-2.9.0 → letify-2.10.0}/letify-ext/src/render.ts +0 -0
  166. {letify-2.9.0 → letify-2.10.0}/letify-ext/test/cli.test.ts +0 -0
  167. {letify-2.9.0 → letify-2.10.0}/letify-ext/test/manifest.test.ts +0 -0
  168. {letify-2.9.0 → letify-2.10.0}/letify-ext/test/model.test.ts +0 -0
  169. {letify-2.9.0 → letify-2.10.0}/letify-ext/test/regressions.test.ts +0 -0
  170. {letify-2.9.0 → letify-2.10.0}/letify-ext/tsconfig.json +0 -0
  171. {letify-2.9.0 → letify-2.10.0}/letify_providers.py +0 -0
  172. {letify-2.9.0 → letify-2.10.0}/scripts/sync-main.sh +0 -0
  173. {letify-2.9.0 → letify-2.10.0}/scripts/version.py +0 -0
  174. {letify-2.9.0 → letify-2.10.0}/tests/conftest.py +0 -0
  175. {letify-2.9.0 → letify-2.10.0}/tests/fake_kaggle_adapter.py +0 -0
  176. {letify-2.9.0 → letify-2.10.0}/tests/fake_modal_adapter.py +0 -0
  177. {letify-2.9.0 → letify-2.10.0}/tests/test_cli.py +0 -0
  178. {letify-2.9.0 → letify-2.10.0}/tests/test_cli_output.py +0 -0
  179. {letify-2.9.0 → letify-2.10.0}/tests/test_colab_keepalive.py +0 -0
  180. {letify-2.9.0 → letify-2.10.0}/tests/test_config.py +0 -0
  181. {letify-2.9.0 → letify-2.10.0}/tests/test_core.py +0 -0
  182. {letify-2.9.0 → letify-2.10.0}/tests/test_data.py +0 -0
  183. {letify-2.9.0 → letify-2.10.0}/tests/test_data_streaming.py +0 -0
  184. {letify-2.9.0 → letify-2.10.0}/tests/test_declare.py +0 -0
  185. {letify-2.9.0 → letify-2.10.0}/tests/test_distributed.py +0 -0
  186. {letify-2.9.0 → letify-2.10.0}/tests/test_drive_handoff.py +0 -0
  187. {letify-2.9.0 → letify-2.10.0}/tests/test_drive_store.py +0 -0
  188. {letify-2.9.0 → letify-2.10.0}/tests/test_editor_sessions.py +0 -0
  189. {letify-2.9.0 → letify-2.10.0}/tests/test_elice.py +0 -0
  190. {letify-2.9.0 → letify-2.10.0}/tests/test_frames.py +0 -0
  191. {letify-2.9.0 → letify-2.10.0}/tests/test_install.py +0 -0
  192. {letify-2.9.0 → letify-2.10.0}/tests/test_kaggle.py +0 -0
  193. {letify-2.9.0 → letify-2.10.0}/tests/test_kaggle_cookie.py +0 -0
  194. {letify-2.9.0 → letify-2.10.0}/tests/test_kaggle_link.py +0 -0
  195. {letify-2.9.0 → letify-2.10.0}/tests/test_link_log.py +0 -0
  196. {letify-2.9.0 → letify-2.10.0}/tests/test_notebook.py +0 -0
  197. {letify-2.9.0 → letify-2.10.0}/tests/test_parallel_placement.py +0 -0
  198. {letify-2.9.0 → letify-2.10.0}/tests/test_paths.py +0 -0
  199. {letify-2.9.0 → letify-2.10.0}/tests/test_portmap.py +0 -0
  200. {letify-2.9.0 → letify-2.10.0}/tests/test_protocol.py +0 -0
  201. {letify-2.9.0 → letify-2.10.0}/tests/test_providers.py +0 -0
  202. {letify-2.9.0 → letify-2.10.0}/tests/test_quic.py +0 -0
  203. {letify-2.9.0 → letify-2.10.0}/tests/test_remoting.py +0 -0
  204. {letify-2.9.0 → letify-2.10.0}/tests/test_render.py +0 -0
  205. {letify-2.9.0 → letify-2.10.0}/tests/test_run_control.py +0 -0
  206. {letify-2.9.0 → letify-2.10.0}/tests/test_runtime.py +0 -0
  207. {letify-2.9.0 → letify-2.10.0}/tests/test_ship.py +0 -0
  208. {letify-2.9.0 → letify-2.10.0}/tests/test_store.py +0 -0
  209. {letify-2.9.0 → letify-2.10.0}/tests/test_strategies.py +0 -0
  210. {letify-2.9.0 → letify-2.10.0}/tests/test_stubs.py +0 -0
  211. {letify-2.9.0 → letify-2.10.0}/tests/test_transport.py +0 -0
  212. {letify-2.9.0 → letify-2.10.0}/tests/test_tunnel_setup.py +0 -0
  213. {letify-2.9.0 → letify-2.10.0}/tests/test_version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: letify
3
- Version: 2.9.0
3
+ Version: 2.10.0
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
@@ -492,6 +492,8 @@ letify setup tailcat # install tailcat or eci from its publisher ahead
492
492
  letify providers # who is declared, storage, channel kind
493
493
  letify devices # what each one offers
494
494
  letify status # what is running right now
495
+ letify sessions # what each account holds, workers on a shell host
496
+ letify stop --orphans # end what a dead process left behind
495
497
  letify usage # what is left on each account
496
498
  letify utilization # how busy each instance's GPU is
497
499
  letify check lab # does this machine answer?
@@ -467,6 +467,8 @@ letify setup tailcat # install tailcat or eci from its publisher ahead
467
467
  letify providers # who is declared, storage, channel kind
468
468
  letify devices # what each one offers
469
469
  letify status # what is running right now
470
+ letify sessions # what each account holds, workers on a shell host
471
+ letify stop --orphans # end what a dead process left behind
470
472
  letify usage # what is left on each account
471
473
  letify utilization # how busy each instance's GPU is
472
474
  letify check lab # does this machine answer?
@@ -320,7 +320,7 @@ letify creates one notebook per run, with `CreateKernelWithSettings`, and never
320
320
 
321
321
  `Kaggle.sessions()` returns the recorded refs that the account still has, checked against the official CLI, `kaggle kernels list --mine --csv`, authenticated by the account's API token in `KAGGLE_API_TOKEN` and never by the cookie, which keeps the boundary this section states. A ref the record names and the account no longer has was deleted elsewhere, and is dropped from the record rather than reported. This matters because a process that dies before `stop` leaves its notebook and its run behind, and that notebook holds weekly accelerator quota with nothing pointing at it: `letify sessions` is how it is found and `kaggle kernels delete` how it is removed. Discovery needs the API token and uv, and the listing is empty rather than failing when either is missing, when the command exits non-zero, or when its output does not parse, because reading what exists must not fail a command that is asking what exists. A notebook created before letify kept this record is not in it and so is not reported.
322
322
 
323
- Whether an account can be asked at all is the provider's own answer, `discovers_sessions`, rather than the launcher naming a provider kind. `colab` and `kaggle` answer yes; every other kind reports that discovery is not supported, which is what `letify sessions` prints for it.
323
+ Whether an account can be asked at all is the provider's own answer, `discovers_sessions`, rather than the launcher naming a provider kind. `colab`, `kaggle` and `shell` answer yes, the last as [Workers on a shell host](#shell-workers) describes. `tunnel` and `elice` answer no, because reaching their machine runs the connection pipeline or allocates a machine, and listing must do neither; every other kind reports that discovery is not supported, which is what `letify sessions` prints for it.
324
324
 
325
325
  **Not making sessions faster than Kaggle allows.** Creating and ending interactive sessions in quick succession makes Kaggle refuse the next run, and nothing letify does recovers it except waiting. The refusal has no message: `CommitAndRun` answers without a `kernelRunId`, which letify reports as `Kaggle did not start a session run`.
326
326
 
@@ -364,6 +364,24 @@ The accelerators letify offers are the fixed set `discover` returns, `CPU` and `
364
364
 
365
365
  `Shell` and its subclasses default to ephemeral because a machine's disk policy is not knowable in advance. Assuming ephemeral costs time, since letify rebuilds the environment each runtime and the work still succeeds; assuming persistent fails outright when the disk turns out to be wiped. A configuration entry overrides it with `persistent = true`.
366
366
 
367
+ ### Provider session discovery <!-- id: provider-session-discovery -->
368
+
369
+ > `letify sessions` lists what an account already holds, and `letify stop` ends what a dead process left behind, for every provider that can be asked.
370
+
371
+ Whether a provider can be asked is its own answer, `discovers_sessions`. `sessions()` returns one readable line per session, `orphans()` returns the refs of those whose owner is gone, and `stop_orphan(ref)` ends one and reports whether it is gone. Kaggle and Colab answer through their accounts, as their sections describe. A provider that cannot be asked prints `session discovery is not supported`.
372
+
373
+ #### Workers on a shell host <!-- id: shell-workers -->
374
+
375
+ > A `Shell` machine answers by listing the letify workers running on it, because the machine has no account to ask.
376
+
377
+ 1. **The worker records itself.** After the environment is built and the worker has moved to the project interpreter, the client sends the op `register` with the workspace root, the runtime name, the pool key and its own host name and pid. The worker writes `<workspace root>/.letify/workers/<worker pid>.json` and removes it when it ends, including when it ends through the lease or `SIGTERM`. The directory has mode 0700 and the file mode 0600, and the file is replaced whole so a reader never sees half of it.
378
+ 2. **The record.** `pid`, `proc_start` (the process start time in clock ticks since boot, field 22 of `/proc/<pid>/stat`, or null where `/proc` is missing), `started` (UTC, ISO 8601), `runtime`, `key`, `devices` (the worker's `CUDA_VISIBLE_DEVICES`, or null when unset), `owner` (`host` and `pid` of the local process that opened the session), `run` (the run id of the call being served, or null) and `lease_deadline` (Unix seconds on the host, rewritten at each lease renewal, or null when no lease is armed). `run` is set when the second door opens and cleared when the call ends. A provider whose class sets `records_workers` to false writes no record. That is every provider but `Shell` and its subclasses reached over a persistent channel.
379
+ 3. **Listing.** `Shell.sessions()` runs one program on the host over the account's link. It reads the records, drops each whose pid is not a live worker and deletes that file, and prints the rest. A pid is a live worker when a process with that pid runs and its start time equals `proc_start`, which keeps a reused pid from reading as a worker; with no `proc_start` in the record, when the pid answers signal 0. Each line reads `pid <pid> gpu <indices or none> started <time> runtime <name> key <key> run <id or none> owner <host>:<pid> <alive|gone|remote>`. An unreachable host raises, as every other command on the account does, rather than listing nothing.
380
+ 4. **Owner state.** `alive` and `gone` are judged only for an owner whose host is this machine's host name: `gone` when that pid does not run. An owner on another host is `remote` and is never judged, because its liveness cannot be read from here.
381
+ 5. **Orphans.** A worker is an orphan when its `lease_deadline` has passed on the host, or when its owner is `gone` and no call is running (`run` is null). A worker whose owner is `gone` while a call runs and whose lease is in the future is not an orphan: it can be taken over, see [Taking over a running call](#run-takeover), and stopping it would end a training run that can still be saved. `letify stop --orphans` never touches a worker whose owner is `alive` or `remote` unless its lease has passed.
382
+ 6. **Stopping one.** `Shell.stop_orphan(pid)` and `letify stop --alias <alias> --worker <pid>` run one program on the host that reads the record for that pid, refuses a pid with no record or no live worker, sends `SIGTERM`, waits up to 30 s (`STOP_GRACE_S`) for the worker to end, then sends `SIGKILL`, and answers whether the pid is gone. A pid with no record is refused, so the command cannot end a process that is not a letify worker. `--worker` stops what is named whatever its owner state, so it asks for confirmation unless `-y` is given.
383
+ 7. **Colab keeps its own answers.** `Colab` inherits `Shell` for its link, but sets `records_workers` to false and lists, orphans and stops through its CLI as before.
384
+
367
385
  ### Remaining usage
368
386
 
369
387
  > Every provider is asked what is left on its account, reads the answer from the service itself, and says why when it cannot.
@@ -1070,7 +1088,7 @@ A command against a run on a provider whose worker has no persistent channel, th
1070
1088
  A call has exactly one owner at a time, and the session still ends with the call that needed it. Taking over moves that ownership to another process; it does not let a call run with no owner. The new owner renews the lease, receives the body's output and the call's result, writes the call's outputs back, and ends the session when the call ends, as the first owner would have.
1071
1089
 
1072
1090
  1. **The registry keeps an orphan.** The record also holds `project`, the project root the call was sent from, and `lease_deadline`, the Unix time at which the runtime's lease ends unless renewed, rewritten at each renewal. It also holds what a second process needs to reach the call and finish it: `door`, the path of the worker's socket below; `config`, the project `config.toml` that names the account; and `call_dir`, `blobs` and `outputs`, the runtime paths and the output locations to write back, each location stored relative to `project` when it lies inside it. No secret is among them. A record whose owner pid is gone is no longer removed when read while its `lease_deadline` is in the future: `letify runs` lists it with `owner` `gone` and the seconds left, and it can be taken over. After the deadline it is removed as stale.
1073
- 2. **The worker keeps a second door.** While a call runs, the worker listens on a Unix socket on the runtime, `<workspace root>/.letify/run-<run id>.sock`, mode 0600. When that path is longer than 100 bytes, the limit of a Unix socket address, the worker and the relay reach the socket through `/proc/self/fd/<n>`, a descriptor of its directory, so it stays at that path; where `/proc` is missing the worker reports that the run has no second door. A channel closing while a call runs does not end the worker: the body keeps running, as it does today, until the lease deadline passes. Output the body makes while no channel is open is kept, the last 256 KiB per stream, and is sent to the next owner first. A body that returns while no channel is open keeps its result for a new owner until the lease deadline; on a provider with no lease the worker ends then, as before.
1091
+ 2. **The worker keeps a second door.** While a call runs, the worker listens on a Unix socket on the runtime, `<workspace root>/.letify/run-<run id>.sock`, mode 0600. When that path is longer than 100 bytes, the limit of a Unix socket address, the worker and the relay reach the socket through `/proc/self/fd/<n>`, a descriptor of its directory, so it stays at that path; where `/proc` is missing the worker reports that the run has no second door. A channel that closes while a call runs, with no shutdown frame before it, does not end the worker: the body keeps running until the lease deadline passes. A shutdown frame is different: it is the owner ending the run on purpose, as [A deliberate shutdown](#run-shutdown) describes. Output the body makes while no channel is open is kept, the last 256 KiB per stream, and is sent to the next owner first. A body that returns while no channel is open keeps its result for a new owner until the lease deadline; on a provider with no lease the worker ends then, as before.
1074
1092
  3. **The new owner connects.** `letify takeover <run>` reads the record, opens a channel to the same runtime through the record's provider and runtime name, and starts a relay to that socket instead of a new worker. It then sends `adopt` with the run id. The worker answers once with the call's state, which is `running` or `finished`, and from then on sends the call's output and its reply on the new channel, the reply as the last piece of the same answer. The old channel, if still open, is told `moved` on the call's stream and answers every later request on it with `moved`. A door takes one connection at a time and no request but `adopt` before it, and a second owner can adopt after the first one's connection has closed.
1075
1093
  4. **The old owner lets go.** When the old owner is alive, takeover first asks it through its control socket to release the run. Its waiting call raises `OwnershipMoved` naming the run and the new owner's pid, it stops renewing that runtime's lease, and it drops the runtime from its pool without stopping it. A `keep_alive` block in the old owner no longer holds that session. An old owner that does not answer within 5 s is treated as gone.
1076
1094
  5. **The call ends at the new owner.** `letify takeover` prints the body's output as it arrives. When the call returns it prints the `repr` of the value, or with `--result PATH` writes the value with pickle to `PATH`, and exits 0. When the body raises it prints the traceback and exits 1. With `--json` the last line is one object with `run`, `ok`, `value_repr` or `error`, and `result_path`. The call's outputs are written back under the record's `project`, not the current directory. The session is then ended.
@@ -1078,13 +1096,24 @@ A call has exactly one owner at a time, and the session still ends with the call
1078
1096
 
1079
1097
  A provider whose runtime cannot be reached by a second process refuses with `UnsupportedMode` naming it. On the `Local` provider the runtime is this machine, so the new owner connects to the socket directly, but the worker is a child of the owning process and dies with it: a `Local` run can be taken over only from an owner that is still alive.
1080
1098
 
1099
+ ### A deliberate shutdown <!-- id: run-shutdown -->
1100
+
1101
+ > A shutdown frame from the channel that owns a running call ends the body and the worker, because a lost owner can be replaced and an owner that left on purpose cannot.
1102
+
1103
+ `Pool.shutdown`, a session ending and a process exiting normally close a persistent channel by sending a shutdown frame before closing its input. The worker tells that from a lost owner:
1104
+
1105
+ 1. **A shutdown frame on the owning channel while a body runs** runs the exit hooks with the reason "the owner shut down", then ends the process with status 128 + `SIGTERM`, as a received `SIGTERM` does. No lease wait and no takeover follow. Before it exits, the worker ends the processes the body started (data loader workers, subprocesses). It builds the tree of descendants of its own pid from `/proc/<pid>/stat`, sends each `SIGTERM`, waits up to 5 s (`CHILD_GRACE_S`) for them to end, and sends `SIGKILL` to the survivors. The tree is walked rather than the process group, because a worker started over SSH is usually not a group leader (the remote shell leads the session). Where `/proc` is missing, the worker sends `SIGTERM` to its process group only when it leads that group, and signals no one else.
1106
+ 2. **End of input without a shutdown frame** keeps the behaviour of [Taking over a running call](#run-takeover): the body runs on until the lease deadline.
1107
+ 3. **A shutdown frame while no body runs** ends the worker, as before.
1108
+ 4. **A shutdown frame on a channel the call moved away from** does not end the call. The channel that gave the call up closes with `detach`, which sends no shutdown frame, and a shutdown that does arrive on it is ignored.
1109
+
1081
1110
  ### Ctrl-C in the owning process
1082
1111
 
1083
1112
  The first `KeyboardInterrupt` that reaches a thread waiting for a call's reply sends the same interrupt as `letify interrupt` and keeps waiting, so the body's handlers run and the outputs come back before the call raises. The waiting thread prints `letify: interrupting <run>; press Ctrl-C again to stop waiting`. A second `KeyboardInterrupt` during that wait stops waiting at once and propagates, as before this section existed. No signal handler is installed for this, so a notebook kernel's interrupt behaves the same way.
1084
1113
 
1085
1114
  ### Exit hooks
1086
1115
 
1087
- `letify.on_exit(fn)`, called inside a body, registers `fn` to run when the worker is about to end while that body is still running, for a reason other than the body returning or raising. The reasons are the worker receiving `SIGTERM` (a preempted session, a provider stopping it), the lease deadline passing, and the channel closing while a call runs. Hooks run in the reverse order of registration, each at most once, on the body's process, with 120 s in total; a hook that raises is reported on stderr and the next one runs. Hooks registered by a call are dropped when that call ends. `SIGKILL`, the out of memory killer and a power cut run no code at all, so no hook runs for them; checkpoints are what covers those.
1116
+ `letify.on_exit(fn)`, called inside a body, registers `fn` to run when the worker is about to end while that body is still running, for a reason other than the body returning or raising. The reasons are the worker receiving `SIGTERM` (a preempted session, a provider stopping it), the lease deadline passing, the channel closing while a call runs, and the owner shutting down. Hooks run in the reverse order of registration, each at most once, on the body's process, with 120 s in total; a hook that raises is reported on stderr and the next one runs. Hooks registered by a call are dropped when that call ends. `SIGKILL`, the out of memory killer and a power cut run no code at all, so no hook runs for them; checkpoints are what covers those.
1088
1117
 
1089
1118
  Outside a body, `letify.on_exit` raises `UnsupportedMode`, because a local process has `atexit` already.
1090
1119
 
@@ -1304,7 +1333,7 @@ connection, but a call's second large blob is split whenever splitting is faster
1304
1333
 
1305
1334
  A connection that costs something to open has to pay for itself, so a provider reports `transfer_connection_cost_s`, the seconds one more connection takes. It is 0 for a provider that dials an address, and about 10 for one reached by a hole punch, because that is what a punch measures. The streams are opened only when the transfer is expected to finish sooner with them: the estimate uses the probe's upload rate, assumes the streams scale and that their connections are opened at the same time, and keeps one stream when `size / (rate x streams) + cost` is not less than `size / rate`. A measured case: a Kaggle runtime at a 44.7 ms round trip carried 128 MiB at 21.0 MiB/s on one stream and 9.5 MiB/s on four, 2.2 times worse, because three more punches cost more than the transfer saved. The same account at a 193 ms round trip carried 1.57 MiB/s on one raw SSH connection and 6.10 MiB/s on four, so the gain is real where the link is slow enough and the blob large enough for it to be worth 10 s.
1306
1335
 
1307
- The connections have to be real ones. Every SSH command letify builds carries `ControlMaster=auto`, which multiplexes further sessions onto the first connection, so opening more channels that way would add streams inside one TCP connection and gain nothing. A transfer channel therefore sets `ControlPath=none` and `ControlMaster=no`, and a punched link redials for each, so each transfer channel is its own kernel connection.
1336
+ The connections have to be real ones. Every SSH command letify builds carries `ControlMaster=auto`, which multiplexes further sessions onto the first connection, so opening more channels that way would add streams inside one TCP connection and gain nothing. A transfer channel therefore sets `ControlPath=none` and `ControlMaster=no`, and a punched link gives each its own socket, so each transfer channel is its own kernel connection. A punched link holds the spare sockets of its punch, `PUNCH_SOCKETS` of them in all, [Rendezvous](#rendezvous), and hands one to each SSH command before it redials. The worker channel and the first transfer channels therefore cost no rendezvous, and `transfer_connection_cost_s` applies only to the streams beyond the spares.
1308
1337
 
1309
1338
  The worker's `data_put` appends to one handle per digest and hashes as it goes, which assumes the pieces arrive in order on one stream. Parallel senders cannot use it, so they use two other requests:
1310
1339
 
@@ -1883,7 +1912,9 @@ The remote side's punch window is 150 s, or the request's `window` in seconds wh
1883
1912
 
1884
1913
  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.
1885
1914
 
1886
- A punched connection first answers the probe, then on request is spliced to the remote machine's SSH server, and SSH runs over a local forwarding port. The splice copies with `os.splice` through a pipe where the platform has it, which is Linux, so the bytes move between the two sockets inside the kernel. Every byte of a punched session crosses it, and the machine on the other end is often a two core VM, so a read into Python and a write back out costs real throughput there. A platform without `os.splice`, or a call it refuses, falls back to the read and write loop. A second SSH connection over the same link punches again.
1915
+ A punched connection first answers the probe, then on request is spliced to the remote machine's SSH server, and SSH runs over a local forwarding port. The splice copies with `os.splice` through a pipe where the platform has it, which is Linux, so the bytes move between the two sockets inside the kernel. Every byte of a punched session crosses it, and the machine on the other end is often a two core VM, so a read into Python and a write back out costs real throughput there. A platform without `os.splice`, or a call it refuses, falls back to the read and write loop. A second SSH connection over the same link takes a spare socket from the same punch, and punches again only when the spares are used up. Next section.
1916
+
1917
+ A punch opens several sockets in one rendezvous, because each extra rendezvous costs about 10 s of lead time and one TCP flow over a long path carries a fraction of the path. The request names `count`, 1 when it is absent and at most 8, `PUNCH_SOCKETS` (4) when the strategy asks. Each side binds `count` local ports, learns a STUN mapping for each, and the request and the answer carry them as `mappings`, in order, with `mapping` still holding the first. Pair `i` is the user's port `i` and the remote's port `i`. It uses the token `token + bytes([i])`, so a hello written for one pair is refused by every other, and all pairs punch in the same window, each on its own thread. A pair that does not connect is absent, and the punch fails only when no pair connected. The remote side serves every connected socket as it serves one, each on its own thread, and its detached process stays until the last of them has ended, because the first one, which carries the probe, often ends long before a spare in use does. Every connected socket has `SO_KEEPALIVE` with `TCP_KEEPIDLE` 20 s and `TCP_KEEPINTVL` 10 s where the platform has them, so the NAT mapping of a socket waiting for its turn is not dropped. The strategy logs `tcp_punch connected <k> of <n> sockets in <seconds> s`. [PR #75](https://github.com/DarkPyonix/letify/pull/75) records the measurements.
1887
1918
 
1888
1919
  A local port that letify binds for forwarding is chosen by the operating system, never fixed, because Windows reserves port ranges that vary by machine. The remote end of a reverse forward is chosen the same way, with `ssh -R 0:`.
1889
1920
 
@@ -2453,11 +2484,28 @@ The two `Tensor` methods are replaced on the class and the `torch.accelerator` f
2453
2484
 
2454
2485
  ### Compilation <!-- id: forwarding-compile -->
2455
2486
 
2456
- > Under `host="local"`, `torch.compile` returns the function or module it is given, unchanged, and warns once that it runs eagerly, so a compiled training loop runs and computes what eager code computes.
2487
+ > Under `host="local"`, `torch.compile` returns the function or module it is given, unchanged. When step compilation is enabled it also asks the runtime to compile each captured step with Inductor, so the same Python code gets fused kernels without a CUDA driver in this process. Step compilation is off by default.
2488
+
2489
+ **Switch.** Step compilation is enabled by the environment variable `LETIFY_COMPILE_STEPS` when it is set, else by `compile_steps = true` at the top level of `~/.letify/config.toml`, else it is off. For the variable, `1`, `true`, `yes` and `on` enable it and `0`, `false`, `no` and `off` disable it, in any case; any other value disables it. The client reads the switch once, when it is created, as it does `auto_fetch`. The default is off because it measured no gain: on a Colab T4, 15.0 ms per step eager against 16.0 ms compiled, because under `host="local"` the step time is bound by the client's dispatch, not by the runtime GPU.
2490
+
2491
+ **Client.** Inductor cannot run here: before tracing it creates a CUDA tensor in this process to set up a device context, which needs a driver this process does not have. Dynamo with any backend also traces into `RemoteTensor` dispatch, which is letify's own Python. So while forwarding is active, `torch.compile(model, ...)` with any arguments returns `model` itself, and `torch.compile(...)` used as a decorator factory returns a decorator that returns the function itself. `Module.compile(...)` does nothing else. `torch.compile` and `Module.compile` are restored when forwarding ends and undone in a forked process, as the rest of the mapping is.
2492
+
2493
+ While step compilation is off, `torch.compile` and `Module.compile` send nothing, the executor replays every step as [Step capture](#forwarding-step-capture) describes, and the first call in a process emits a `UserWarning` naming `host="local"`, saying that the model runs eagerly, and naming `LETIFY_COMPILE_STEPS=1` and `compile_steps = true` as the way to compile captured steps on the runtime. While it is on, the first call in a session that does not pass `disable=True` and has the default `backend` sends the request `letify.compile` with its `mode` and `options`, `None` and `{}` when absent. A later call in the session sends nothing. The first such call in a process also emits a `UserWarning` naming `host="local"` and saying that compilation happens on the runtime per captured step. Until the request arrives, and when nobody calls `torch.compile`, the runtime replays every step as [Step capture](#forwarding-step-capture) describes, unchanged.
2494
+
2495
+ **Compiled segments.** Once compilation is enabled, the worker compiles a segment of a step: the operators `start` to `stop - 1` of one step entry. A segment is identified by `(step, start, stop)`, so a partial entry is its own segment. Its compiled function returns every tensor that some entry of the segment kept, and an entry stores only the ones in its own `keep`; an entry whose `keep` has an offset not yet returned is another variant. A segment that has any of these stays eager for the rest of the session: an operator whose name contains a random-number token (`rand`, `dropout`, `bernoulli`, `normal`, `multinomial`, `uniform`, `exponential`, `random`), because Inductor draws from its own generator; a compilation failure; or a fifth variant of its step.
2496
+
2497
+ - **Sightings.** The first entry of a segment runs eagerly and records its scalars. On the second, a scalar slot whose value differs from the first and whose type is `int` or `float` is varying, and every other slot is constant. The second entry also starts the compilation, and runs eagerly.
2498
+ - **Function.** The compiled function takes, in this order, the distinct tensors the segment reads that it does not create (the entry's `externals` and the handles created before `start`, each tensor once however often it is read), the CPU tensors the segment reads as blobs, and one 0-d CPU tensor for each varying scalar, `float64` for a float and `int64` for an int. It replays the operators exactly as the **Worker** paragraph of [Step capture](#forwarding-step-capture) does, over a table of its own, reading a varying scalar with `aten._local_scalar_dense` so that an optimizer's learning rate or bias correction is a runtime value, not a constant. It returns the survivors: the tensors the segment creates that are in `keep` or not released inside the segment, in creation order. An in-place update of an input is written back to that input.
2499
+ - **Tracing.** The function is traced with `make_fx` in its symbolic mode over fake inputs of the observed shapes, and compiled with `torch._inductor.compile_fx.compile_fx` against fake inputs of static shapes. Inductor's `keep_output_stride` keeps its default.
2500
+ - **Background.** Tracing and compiling run in a thread of the worker. Until the compiled function is ready, entries of the segment run eagerly.
2501
+ - **Guard.** An entry runs compiled when its constant scalars equal those compiled in, the pattern of repeated tensors among the inputs is the same, every input has the shape, strides, dtype and device compiled in, and no two inputs share storage. Otherwise it runs eagerly. A mismatch of the constants, the pattern or the inputs starts a new compilation of the segment with the changed scalar slots varying, which is another variant. A step has at most 4 variants in the session.
2502
+ - **Run.** A compiled entry stores each survivor that is in its `keep` or not released inside the segment under its handle, `first handle` plus its offset, drops the handles created before `start` that the eager replay would release, and creates no other handle.
2503
+
2504
+ **Mode and options.** `options` is applied as `torch._inductor.config.patch(options)` around the compilation, so a key Inductor does not know is a compilation failure. `mode="max-autotune"` and `mode="max-autotune-no-cudagraphs"` set `max_autotune`. Every other mode, `"reduce-overhead"` included, compiles as the default mode does, because the worker does not use CUDA graphs.
2457
2505
 
2458
- Inductor, the default backend, cannot compile here: before tracing it creates a CUDA tensor in this process to set up a device context, which needs a driver this process does not have. Dynamo with any backend also traces into `RemoteTensor` dispatch, which is letify's own Python, and recompiles it for every operator. A replayed step already sends the whole step as one entry, as [Step capture](#forwarding-step-capture) describes, so compiling on the client has nothing left to batch.
2506
+ **Failure.** A compilation that fails marks the segment eager and queues a note `letify: step <step> could not be compiled: <reason>; it runs eagerly`. The note travels with the next reply and the client prints it on standard error once. A compiled entry that raises while running is reported as [Failure semantics](#forwarding-failure) describes, naming `compiled step <step>`, the segment is marked eager, and the entry is not run again.
2459
2507
 
2460
- While forwarding is active, `torch.compile(model, ...)` with any arguments returns `model` itself, and `torch.compile(...)` used as a decorator factory returns a decorator that returns the function itself. `Module.compile(...)` does nothing. The first such call in a process emits a `UserWarning` naming `host="local"` and eager execution. `torch.compile` and `Module.compile` are restored when forwarding ends and undone in a forked process, as the rest of the mapping is.
2508
+ **Request.** `letify.compile_stats` with `wait` true joins the compilations in progress, then returns counters: `compiled`, the successful compilations; `failed`, the failed ones; `runs`, the entries that ran compiled; `eager`, the segments marked eager.
2461
2509
 
2462
2510
  `torch.cuda.get_rng_state` and `torch.cuda.set_rng_state` stay refused, so code that saves and restores the generator state names the gap instead of silently restoring a state that is not the runtime's.
2463
2511
 
@@ -2582,7 +2630,7 @@ A step is what a training loop repeats: forward, backward and the optimizer upda
2582
2630
 
2583
2631
  **Early release.** A tensor a repetition creates is released on the runtime right after the last operator of the step that reads it, when nothing else can read it, rather than with the release list after the whole step. When a step is registered, the client and the worker both compute each created handle's release position from the wiring: the last position whose operator takes that offset as an argument, or the position that created it when no later operator of the step reads it. A step entry carries `keep`, the offsets whose release position lies in `start` to `stop - 1` and that must not be released early. The client computes `keep` when it takes the entry into a batch: an offset is kept when a `RemoteTensor` still names its handle, which is when its handle has not been released locally, or when an entry after it in that batch, an entry left in the queue, or the unfinished repetition reads the handle. A saved tensor autograd holds for backward is named by a `RemoteTensor`, so it is released only after the backward operator that reads it. A tensor the loop keeps across steps, such as the loss or a gradient, is kept, and released by the release list as before.
2584
2632
 
2585
- **Worker.** The worker executes operators `start` to `stop - 1` of the step in order, reading an argument's handle as `first handle + offset` or from `externals`, and stores each new output under the next handle counted from `first handle`. After each operator it drops the handles whose release position is that operator, other than those in `keep`, from its table. Each operator is the same call an eager entry makes, so a failure is recorded and reported as [Failure semantics](#forwarding-failure) describes, naming the operator. The worker does not use CUDA graphs, because the handles bound to a step, such as the batch, change between repetitions and a graph needs fixed input memory.
2633
+ **Worker.** The worker executes operators `start` to `stop - 1` of the step in order, reading an argument's handle as `first handle + offset` or from `externals`, and stores each new output under the next handle counted from `first handle`. After each operator it drops the handles whose release position is that operator, other than those in `keep`, from its table. Each operator is the same call an eager entry makes, so a failure is recorded and reported as [Failure semantics](#forwarding-failure) describes, naming the operator. When compilation is enabled, a segment may run as one compiled function, as [Compilation](#forwarding-compile) describes. The worker does not use CUDA graphs, because the handles bound to a step, such as the batch, change between repetitions and a graph needs fixed input memory.
2586
2634
 
2587
2635
  ### Handles <!-- id: forwarding-handles -->
2588
2636
 
@@ -72,6 +72,8 @@ A read does not have to be a synchronization. `loss.detach().to("cpu", non_block
72
72
 
73
73
  You do not have to write either of those for a logged number. Under `host="local"`, `loss.item()` already returns a deferred value: the read is queued and the loop keeps going, and the value is waited for where it is used, such as in the `print` a step later. `tolist()`, `float(tensor)` and `int(tensor)` work the same way. `bool(tensor)` still waits, because control flow needs the value at once. The value a call returns is always a real `float`, `int` or `list`. Put `auto_fetch = false` in the account entry, or `LETIFY_AUTO_FETCH=0` in the environment, to turn this off. At the end of a call letify prints how many reads it deferred and how many had to wait, so a loop that gains nothing from it is visible.
74
74
 
75
+ `torch.compile` and `Module.compile` return the model unchanged under `host="local"` and warn once that it runs eagerly. To have the runtime compile each captured step with Inductor instead, set `LETIFY_COMPILE_STEPS=1` in the environment or `compile_steps = true` at the top level of `~/.letify/config.toml`. It is off by default because it measured no gain on a Colab T4: 15.0 ms per step eager against 16.0 ms compiled, since the step time is bound by the client's dispatch.
76
+
75
77
  Other things that force a synchronization: `nonzero()`, boolean mask indexing, `unique()`, and mixture of experts routing, which reads per-expert token counts back to the host. Any operation whose output shape the host has to know is a synchronization.
76
78
 
77
79
  ### Measure it instead of guessing
@@ -157,7 +157,7 @@ def train():
157
157
  ...
158
158
  ```
159
159
 
160
- The hook runs when the worker receives `SIGTERM`, when its lease runs out, or when the channel closes during the call. It does not run for `SIGKILL` or the out of memory killer, so keep writing checkpoints as well. Hooks run newest first and share 120 seconds.
160
+ The hook runs when the worker receives `SIGTERM`, when its lease runs out, when the channel closes during the call, or when the owner shuts down. A shutdown by the owner (a pool shutdown, or a client that received `SIGTERM`) runs the hooks and then ends the body and the worker. A channel that closes with no shutdown leaves the body running until the lease deadline, so another process can take it over. It does not run for `SIGKILL` or the out of memory killer, so keep writing checkpoints as well. Hooks run newest first and share 120 seconds.
161
161
 
162
162
  None of this works on a provider whose worker has only a one-shot channel, and the commands say which provider. There is no control socket on Windows. See the spec section "Controlling a running call".
163
163
 
@@ -138,6 +138,16 @@ and letify cannot know when that process ends.
138
138
 
139
139
  On a shared machine this usually means a colleague is computing on those cards. Check with `letify utilization`, then retry later or narrow `indices` in the devices table.
140
140
 
141
+ **Cards held by a worker you cannot see.** A worker left behind when the local process died keeps its cards until its lease ends, 300 s after the last renewal. On a `Shell` machine, list the workers and stop one:
142
+
143
+ ```bash
144
+ letify sessions
145
+ letify stop --orphans --alias lab
146
+ letify stop --alias lab --worker 4242
147
+ ```
148
+
149
+ `letify sessions` prints one line per worker: pid, GPU indices, start time, runtime name, pool key, run id and the local process that owns it, marked `alive`, `gone` or `remote` (owned from another machine, so not judged). `--orphans` stops the workers whose lease has passed, and those whose owner is gone while no call runs. It never stops a worker whose owner is alive. A worker whose owner is gone but whose call still runs is listed by `letify runs` and can be taken over, so `--orphans` leaves it alone. `--worker` stops the one pid you name, whatever its owner, and refuses a pid that is not a letify worker. A stop sends `SIGTERM`, waits 30 s, then sends `SIGKILL`.
150
+
141
151
  ---
142
152
 
143
153
  ## `RemoteError`
@@ -72,6 +72,8 @@ Hugging Face 학습 스텝은 기본 설정에서 `k ≈ 3`입니다.
72
72
 
73
73
  로깅하는 숫자 하나 때문에 위의 두 가지를 직접 쓸 필요는 없습니다. `host="local"`에서 `loss.item()`은 이미 지연 값을 돌려줍니다. 읽기는 큐에 들어가고 루프는 계속 진행되며, 값은 실제로 쓰이는 곳, 예를 들어 한 스텝 뒤의 `print`에서 기다립니다. `tolist()`, `float(tensor)`, `int(tensor)`도 같습니다. `bool(tensor)`는 제어 흐름이 값을 바로 필요로 하므로 그대로 기다립니다. 호출이 돌려주는 값은 언제나 실제 `float`, `int`, `list`입니다. 이 동작을 끄려면 계정 항목에 `auto_fetch = false`를 넣거나 환경 변수 `LETIFY_AUTO_FETCH=0`을 설정하세요. 호출이 끝나면 letify가 몇 개의 읽기를 지연했고 몇 개가 기다려야 했는지 한 줄로 출력하므로, 이 동작에서 얻는 것이 없는 루프는 바로 드러납니다.
74
74
 
75
+ `host="local"`에서 `torch.compile`과 `Module.compile`은 모델을 그대로 돌려주고, 모델이 eager로 실행된다는 경고를 한 번 출력합니다. 런타임이 캡처된 각 스텝을 Inductor로 컴파일하게 하려면 환경 변수 `LETIFY_COMPILE_STEPS=1`을 설정하거나 `~/.letify/config.toml` 최상위에 `compile_steps = true`를 넣으세요. Colab T4에서 이득이 측정되지 않아 기본값은 꺼짐입니다. 스텝당 eager 15.0 ms, 컴파일 16.0 ms였고, 스텝 시간이 클라이언트의 디스패치에 묶여 있기 때문입니다.
76
+
75
77
  동기화를 강제하는 다른 것들도 있습니다. `nonzero()`, 불리언 마스크 인덱싱, `unique()`, 그리고 전문가별 토큰 수를 호스트로 읽어오는 MoE 라우팅입니다. 출력 크기를 호스트가 알아야 하는 연산은 모두 동기화입니다.
76
78
 
77
79
  ### 추측하지 말고 측정하세요
@@ -153,7 +153,7 @@ def train():
153
153
  ...
154
154
  ```
155
155
 
156
- 훅은 워커가 `SIGTERM`을 받을 때, 리스 기한이 지날 때, 호출 중에 채널이 닫힐 때 실행됩니다. `SIGKILL`이나 메모리 부족 종료에는 실행되지 않으므로 체크포인트도 계속 쓰세요. 훅은 나중에 등록한 것부터 실행되며 합쳐서 120초를 씁니다.
156
+ 훅은 워커가 `SIGTERM`을 받을 때, 리스 기한이 지날 때, 호출 중에 채널이 닫힐 때, 소유자가 종료를 요청할 때 실행됩니다. 소유자가 종료를 요청하면(풀 종료, `SIGTERM`을 받은 클라이언트) 훅을 실행한 뒤 본문과 워커가 함께 끝납니다. 종료 요청 없이 채널만 끊기면 본문은 리스 기한까지 계속 실행되어 다른 프로세스가 이어받을 수 있습니다. `SIGKILL`이나 메모리 부족 종료에는 실행되지 않으므로 체크포인트도 계속 쓰세요. 훅은 나중에 등록한 것부터 실행되며 합쳐서 120초를 씁니다.
157
157
 
158
158
  워커에 일회성 채널만 있는 프로바이더에서는 이 기능을 쓸 수 없고, 명령이 그 프로바이더 이름을 알려 줍니다. Windows에는 제어 소켓이 없습니다. 스펙의 "Controlling a running call" 절을 참고하세요.
159
159
 
@@ -138,6 +138,16 @@ and letify cannot know when that process ends.
138
138
 
139
139
  공유 머신이라면 보통 동료가 그 카드로 계산 중이라는 뜻입니다. `letify utilization`으로 확인한 뒤 나중에 다시 시도하거나, devices 테이블의 `indices`를 좁히세요.
140
140
 
141
+ **보이지 않는 워커가 잡고 있는 카드.** 로컬 프로세스가 죽으면서 남은 워커는 리스가 끝날 때까지, 마지막 갱신 후 300초 동안 카드를 잡고 있습니다. `Shell` 머신에서는 워커를 나열하고 하나를 멈출 수 있습니다.
142
+
143
+ ```bash
144
+ letify sessions
145
+ letify stop --orphans --alias lab
146
+ letify stop --alias lab --worker 4242
147
+ ```
148
+
149
+ `letify sessions`는 워커마다 한 줄을 출력합니다. pid, GPU 번호, 시작 시각, 런타임 이름, 풀 키, 실행 id, 그리고 소유한 로컬 프로세스와 그 상태(`alive`, `gone`, `remote`)입니다. `remote`는 다른 머신이 소유한 경우라 판정하지 않습니다. `--orphans`는 리스가 지난 워커와, 소유자가 사라졌고 실행 중인 호출이 없는 워커를 멈춥니다. 소유자가 살아 있는 워커는 멈추지 않습니다. 소유자는 사라졌지만 호출이 아직 도는 워커는 `letify runs`에 나오고 인수할 수 있으므로 `--orphans`가 건드리지 않습니다. `--worker`는 지정한 pid 하나를 소유자와 관계없이 멈추고, letify 워커가 아닌 pid는 거부합니다. 멈출 때는 `SIGTERM`을 보내고 30초 기다린 뒤 `SIGKILL`을 보냅니다.
150
+
141
151
  ---
142
152
 
143
153
  ## `RemoteError`
@@ -467,6 +467,8 @@ letify setup tailcat # 배포처에서 tailcat 또는 eci를 미리 설치
467
467
  letify providers # 선언된 프로바이더, 저장소 수명, 기본 배치
468
468
  letify devices # 각자 제공하는 GPU
469
469
  letify status # 지금 돌고 있는 것
470
+ letify sessions # 계정이 잡고 있는 것, shell 호스트의 워커
471
+ letify stop --orphans # 죽은 프로세스가 남긴 것 정리
470
472
  letify usage # 계정마다 남은 사용량
471
473
  letify utilization # 인스턴스별 GPU가 얼마나 바쁜가
472
474
  letify check lab # 이 머신이 응답하나?
@@ -70,7 +70,7 @@ distributed: Final = Host.distributed
70
70
  # The enum class stays importable from letify.declare.instance for letify's own use.
71
71
  del Host
72
72
 
73
- __version__ = "2.9.0"
73
+ __version__ = "2.10.0"
74
74
 
75
75
 
76
76
  def fetch(tensor): # type: ignore[no-untyped-def]
@@ -49,6 +49,11 @@ def build_parser() -> argparse.ArgumentParser:
49
49
  help="delete the sessions whose creating process is gone",
50
50
  )
51
51
  stop.add_argument("--alias", help="only this account")
52
+ stop.add_argument(
53
+ "--worker",
54
+ metavar="PID",
55
+ help="stop the letify worker with this pid on the machine --alias names",
56
+ )
52
57
  stop.add_argument("-y", "--yes", action="store_true", help="do not ask")
53
58
  stop.add_argument("--json", action="store_true", help="print the records unformatted")
54
59
 
@@ -947,6 +952,21 @@ def _dispatch(args: argparse.Namespace) -> int:
947
952
  return 0
948
953
 
949
954
  if args.command == "stop":
955
+ if args.worker is not None:
956
+ if not args.alias:
957
+ print("letify: --worker needs --alias, the machine it runs on", file=sys.stderr)
958
+ return 2
959
+ print(f"worker {args.worker} on {args.alias} gets SIGTERM, then SIGKILL after 30 s")
960
+ if not args.yes and not _confirmed("Stop it?"):
961
+ print("nothing was stopped")
962
+ return 0
963
+ row = let.stop_worker(args.alias, args.worker)
964
+ if args.json:
965
+ _json(row)
966
+ return 0 if row.get("stopped") else 1
967
+ mark = "stopped" if row.get("stopped") else f"failed{_detail(row)}"
968
+ print(f"{row['alias']} {row['ref']} {mark}")
969
+ return 0 if row.get("stopped") else 1
950
970
  if not args.orphans:
951
971
  # Spec "Kaggle", stopping what a dead process left behind: a bare stop reads as
952
972
  # ending everything, so it ends nothing, and says why rather than only that.
@@ -517,6 +517,18 @@ class Launcher:
517
517
  rows.append({"alias": name, "orphans": [], "unavailable": str(exc)})
518
518
  return rows
519
519
 
520
+ def stop_worker(self, alias: str, pid: str) -> dict[str, Any]:
521
+ """Stop one named worker on a provider that keeps worker records.
522
+
523
+ Spec "Workers on a shell host", stopping one. The provider refuses a pid that no
524
+ worker record names.
525
+ """
526
+ provider = self.provider(alias)
527
+ try:
528
+ return {"alias": alias, "ref": pid, "stopped": provider.stop_worker(pid)}
529
+ except LetifyError as exc:
530
+ return {"alias": alias, "ref": pid, "stopped": False, "error": str(exc)}
531
+
520
532
  def stop_orphans(self, alias: str | None = None) -> list[dict[str, Any]]:
521
533
  """Delete what ``orphans`` reported, one at a time, reporting each outcome."""
522
534
  done: list[dict[str, Any]] = []
@@ -377,6 +377,7 @@ def _reply(stream, outcome):
377
377
  try:
378
378
  _send_outcome(_SENDER, stream, outcome)
379
379
  except OSError:
380
+ _drop_record()
380
381
  os._exit(0)
381
382
 
382
383
 
@@ -2677,6 +2678,7 @@ def _op_lease(request):
2677
2678
  """
2678
2679
  state = globals().setdefault("_LEASE", {})
2679
2680
  state["deadline"] = time.time() + float(request["grace"])
2681
+ _record_update(lease_deadline=state["deadline"])
2680
2682
 
2681
2683
  def watch():
2682
2684
  while True:
@@ -2691,6 +2693,82 @@ def _op_lease(request):
2691
2693
  return {"ok": True, "value": state["deadline"]}
2692
2694
 
2693
2695
 
2696
+ # -- the worker's record on its host ---------------------------------------------------
2697
+ # Spec "Workers on a shell host": a file under the workspace root saying who this worker is,
2698
+ # written when the client registers it and removed when the worker ends.
2699
+
2700
+ _RECORD = {"path": None, "data": None, "lock": threading.Lock()}
2701
+
2702
+
2703
+ def _proc_start():
2704
+ """This process's start time in clock ticks since boot, or None where /proc is missing."""
2705
+ try:
2706
+ with open("/proc/self/stat") as handle:
2707
+ return int(handle.read().rsplit(")", 1)[1].split()[19])
2708
+ except (OSError, ValueError, IndexError):
2709
+ return None
2710
+
2711
+
2712
+ def _write_record():
2713
+ """Replace the record file whole, so a reader never sees half of it."""
2714
+ import json
2715
+ path, data = _RECORD["path"], _RECORD["data"]
2716
+ if path is None:
2717
+ return
2718
+ partial = "%s.%d.part" % (path, os.getpid())
2719
+ try:
2720
+ descriptor = os.open(partial, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
2721
+ with os.fdopen(descriptor, "w") as handle:
2722
+ json.dump(data, handle)
2723
+ os.replace(partial, path)
2724
+ except OSError as exc:
2725
+ print("letify: the worker record was not written: %s" % exc, file=sys.stderr)
2726
+
2727
+
2728
+ def _record_update(**fields):
2729
+ """Change fields of the record and write it again. Nothing when no record is kept."""
2730
+ with _RECORD["lock"]:
2731
+ if _RECORD["path"] is None:
2732
+ return
2733
+ _RECORD["data"].update(fields)
2734
+ _write_record()
2735
+
2736
+
2737
+ def _drop_record():
2738
+ """Remove the record. Called before every way this process ends."""
2739
+ with _RECORD["lock"]:
2740
+ path, _RECORD["path"] = _RECORD["path"], None
2741
+ if path is not None:
2742
+ try:
2743
+ os.unlink(path)
2744
+ except OSError:
2745
+ pass
2746
+
2747
+
2748
+ def _op_register(request):
2749
+ """Write ``<workspace>/.letify/workers/<pid>.json`` and keep it current until exit."""
2750
+ root = os.path.expanduser(request["workspace"])
2751
+ directory = os.path.join(root, ".letify", "workers")
2752
+ os.makedirs(directory, exist_ok=True)
2753
+ os.chmod(directory, 0o700)
2754
+ data = {
2755
+ "pid": os.getpid(),
2756
+ "proc_start": _proc_start(),
2757
+ "started": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
2758
+ "runtime": request["runtime"],
2759
+ "key": request["key"],
2760
+ "devices": os.environ.get("CUDA_VISIBLE_DEVICES"),
2761
+ "owner": request["owner"],
2762
+ "run": _TAKE["run"],
2763
+ "lease_deadline": globals().get("_LEASE", {}).get("deadline"),
2764
+ }
2765
+ with _RECORD["lock"]:
2766
+ _RECORD["path"] = os.path.join(directory, "%d.json" % os.getpid())
2767
+ _RECORD["data"] = data
2768
+ _write_record()
2769
+ return {"ok": True, "value": None}
2770
+
2771
+
2694
2772
  # -- controlling a running call --------------------------------------------------------
2695
2773
  # Spec "Controlling a running call": an interrupt, source run beside the body, and exit hooks.
2696
2774
 
@@ -2761,9 +2839,86 @@ def _run_exit_hooks(reason):
2761
2839
 
2762
2840
  def _exit_with_hooks(code, reason="the lease ended"):
2763
2841
  _run_exit_hooks(reason)
2842
+ _drop_record()
2764
2843
  os._exit(code)
2765
2844
 
2766
2845
 
2846
+ #: Seconds the body's descendants get to end after SIGTERM before they are killed.
2847
+ CHILD_GRACE_S = 5.0
2848
+
2849
+
2850
+ def _descendants():
2851
+ """Pids of every descendant of this process, from /proc, or None where /proc is missing."""
2852
+ try:
2853
+ names = [n for n in os.listdir("/proc") if n.isdigit()]
2854
+ except OSError:
2855
+ return None
2856
+ children = {}
2857
+ for name in names:
2858
+ try:
2859
+ with open("/proc/%s/stat" % name) as handle:
2860
+ fields = handle.read().rsplit(")", 1)[1].split()
2861
+ except (OSError, IndexError):
2862
+ continue
2863
+ children.setdefault(int(fields[1]), []).append(int(name))
2864
+ found, queue = [], [os.getpid()]
2865
+ while queue:
2866
+ for pid in children.get(queue.pop(), ()):
2867
+ found.append(pid)
2868
+ queue.append(pid)
2869
+ return found
2870
+
2871
+
2872
+ def _alive(pid):
2873
+ try:
2874
+ with open("/proc/%d/stat" % pid) as handle:
2875
+ return handle.read().rsplit(")", 1)[1].split()[0] != "Z"
2876
+ except (OSError, IndexError):
2877
+ return False
2878
+
2879
+
2880
+ def _end_descendants():
2881
+ """SIGTERM the processes the body started, wait CHILD_GRACE_S, then SIGKILL survivors."""
2882
+ import signal
2883
+ pids = _descendants()
2884
+ if pids is None:
2885
+ # No /proc: the group is signalled only when this worker leads it.
2886
+ if hasattr(os, "killpg") and os.getpgrp() == os.getpid():
2887
+ try:
2888
+ os.killpg(os.getpgrp(), signal.SIGTERM)
2889
+ time.sleep(2.0)
2890
+ except OSError:
2891
+ pass
2892
+ return
2893
+ for pid in pids:
2894
+ try:
2895
+ os.kill(pid, signal.SIGTERM)
2896
+ except OSError:
2897
+ pass
2898
+ end = time.monotonic() + CHILD_GRACE_S
2899
+ while time.monotonic() < end and any(_alive(pid) for pid in pids):
2900
+ time.sleep(0.05)
2901
+ for pid in pids:
2902
+ if _alive(pid):
2903
+ try:
2904
+ os.kill(pid, signal.SIGKILL)
2905
+ except OSError:
2906
+ pass
2907
+
2908
+
2909
+ def _end_by_owner():
2910
+ """The owner shut down on purpose: run the hooks, then end the body and the worker.
2911
+
2912
+ Spec "A deliberate shutdown". The exit status is the one a received SIGTERM gives. The
2913
+ processes the body started are ended first.
2914
+ """
2915
+ import signal
2916
+ _run_exit_hooks("the owner shut down")
2917
+ _end_descendants()
2918
+ _drop_record()
2919
+ os._exit(128 + signal.SIGTERM)
2920
+
2921
+
2767
2922
  def _on_sigint(signum, frame):
2768
2923
  """Raise KeyboardInterrupt, as the default handler does, except for a stale interrupt.
2769
2924
 
@@ -2800,6 +2955,7 @@ def _watch_sigterm(fd):
2800
2955
  return
2801
2956
  if signal.SIGTERM in data and signal.getsignal(signal.SIGTERM) is _on_sigterm:
2802
2957
  _run_exit_hooks("SIGTERM")
2958
+ _drop_record()
2803
2959
  os._exit(128 + signal.SIGTERM)
2804
2960
 
2805
2961
 
@@ -3089,6 +3245,7 @@ _OPS = {
3089
3245
  "device": _op_device,
3090
3246
  "stat": _op_stat,
3091
3247
  "lease": _op_lease,
3248
+ "register": _op_register,
3092
3249
  "interrupt": _op_interrupt,
3093
3250
  "inspect_exec": _op_inspect_exec,
3094
3251
  "reduce_peers": _op_reduce_peers,
@@ -3310,6 +3467,7 @@ def _read(readinto=None, door=None):
3310
3467
  channel the call's output and reply go to.
3311
3468
  """
3312
3469
  receiver = Receiver(readinto or sys.stdin.buffer.readinto)
3470
+ shutdown = False
3313
3471
  while True:
3314
3472
  try:
3315
3473
  event = receiver.next_event()
@@ -3323,6 +3481,7 @@ def _read(readinto=None, door=None):
3323
3481
  continue
3324
3482
  kind, stream, value = event
3325
3483
  if kind == SHUTDOWN:
3484
+ shutdown = True
3326
3485
  break
3327
3486
  if kind != REQUEST:
3328
3487
  continue
@@ -3381,11 +3540,15 @@ def _read(readinto=None, door=None):
3381
3540
  _JOBS.put((stream, request))
3382
3541
  if op == "reexec":
3383
3542
  return
3384
- _reader_ended(door)
3543
+ _reader_ended(door, shutdown)
3544
+
3385
3545
 
3546
+ def _reader_ended(door, shutdown=False):
3547
+ """A channel's reader stopped: it was closed, or sent a shutdown.
3386
3548
 
3387
- def _reader_ended(door):
3388
- """A channel's reader stopped: it was closed, or sent a shutdown."""
3549
+ Spec "A deliberate shutdown": a shutdown frame on the channel that owns a running call
3550
+ ends the body and the worker. End of input alone leaves the body for a takeover.
3551
+ """
3389
3552
  if door is None and _TAKE["moved"]:
3390
3553
  # The call was adopted away; this channel closing is the old owner leaving.
3391
3554
  return
@@ -3404,6 +3567,9 @@ def _reader_ended(door):
3404
3567
  inbox = _DEVICE.get("inbox")
3405
3568
  if inbox is not None:
3406
3569
  inbox.put(None)
3570
+ if shutdown and during:
3571
+ _end_by_owner()
3572
+ return
3407
3573
  # Spec "Exit hooks": a channel that closes while a body runs is a reason to end, and
3408
3574
  # the hooks run before the main thread is told there is nothing more to serve. Spec
3409
3575
  # "Taking over a running call": the body keeps running, and the worker ends when the
@@ -3456,6 +3622,7 @@ def _orphan_output(kind, data):
3456
3622
 
3457
3623
  def _door_open(take):
3458
3624
  """Start listening on the call's door. A door that cannot open leaves the call as it was."""
3625
+ _record_update(run=take["run"])
3459
3626
  with _TAKE_COND:
3460
3627
  _TAKE.update(
3461
3628
  run=take["run"], live=True, ended_input=False, finished=False, outcome=None,
@@ -3592,7 +3759,9 @@ def _deliver_call(stream, outcome):
3592
3759
  listener, path = _TAKE["listener"], _TAKE["path"]
3593
3760
  _TAKE.update(run=None, listener=None, path=None, finished=False, outcome=None)
3594
3761
  _door_close(listener, path)
3762
+ _record_update(run=None)
3595
3763
  if failed and owner is None:
3764
+ _drop_record()
3596
3765
  os._exit(0)
3597
3766
  if ended:
3598
3767
  _JOBS.put(None)
@@ -3633,6 +3802,7 @@ def _serve():
3633
3802
  _run(stream, request, True)
3634
3803
  request = None
3635
3804
  _release_output()
3805
+ _drop_record()
3636
3806
 
3637
3807
 
3638
3808
  _serve()