modal 1.5.6.dev12__tar.gz → 1.5.6.dev13__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 (241) hide show
  1. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/PKG-INFO +1 -1
  2. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_functions.py +8 -4
  3. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_output/status.py +5 -6
  4. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_utils/auth_token_manager.py +28 -17
  5. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_utils/grpc_utils.py +20 -6
  6. modal-1.5.6.dev13/modal/_utils/idle_countdown.py +64 -0
  7. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_utils/task_command_router_client.py +192 -82
  8. modal-1.5.6.dev13/modal/cli/_logs.py +131 -0
  9. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/cli/app.py +16 -111
  10. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/cli/container.py +1 -1
  11. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/cli/entry_point.py +2 -0
  12. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/cli/function.py +148 -9
  13. modal-1.5.6.dev13/modal/cli/server.py +136 -0
  14. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/cli/utils.py +13 -0
  15. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/client.py +0 -1
  16. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/client.pyi +2 -2
  17. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/config.py +4 -0
  18. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/exception.py +0 -4
  19. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/functions.pyi +6 -6
  20. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/io_streams.py +18 -0
  21. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/io_streams.pyi +18 -0
  22. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/sandbox.py +50 -152
  23. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/sandbox.pyi +15 -188
  24. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal.egg-info/PKG-INFO +1 -1
  25. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal.egg-info/SOURCES.txt +3 -2
  26. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal_docs/gen_reference_docs.py +0 -1
  27. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal_docs/reference_intro.md +0 -1
  28. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal_proto/api_grpc.py +48 -0
  29. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal_proto/api_pb2.py +1285 -1191
  30. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal_proto/api_pb2.pyi +210 -7
  31. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal_proto/api_pb2_grpc.py +99 -0
  32. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal_proto/api_pb2_grpc.pyi +30 -0
  33. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal_proto/modal_api_grpc.py +3 -0
  34. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal_version/__init__.py +1 -1
  35. modal-1.5.6.dev12/modal/file_io.py +0 -564
  36. modal-1.5.6.dev12/modal/file_io.pyi +0 -447
  37. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/LICENSE +0 -0
  38. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/README.md +0 -0
  39. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/__init__.py +0 -0
  40. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/__main__.py +0 -0
  41. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_billing.py +0 -0
  42. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_clustered_functions.py +0 -0
  43. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_clustered_functions.pyi +0 -0
  44. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_container_entrypoint.py +0 -0
  45. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_environments.py +0 -0
  46. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_function_variants.py +0 -0
  47. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_grpc_client.py +0 -0
  48. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_image.py +0 -0
  49. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_ipython.py +0 -0
  50. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_load_context.py +0 -0
  51. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_location.py +0 -0
  52. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_logs.py +0 -0
  53. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_logs_manager.py +0 -0
  54. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_logs_manager.pyi +0 -0
  55. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_object.py +0 -0
  56. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_output/__init__.py +0 -0
  57. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_output/manager.py +0 -0
  58. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_output/pty.py +0 -0
  59. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_output/rich.py +0 -0
  60. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_partial_function.py +0 -0
  61. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_resolver.py +0 -0
  62. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_resources.py +0 -0
  63. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_runtime/__init__.py +0 -0
  64. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_runtime/asgi.py +0 -0
  65. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_runtime/container_io_manager.py +0 -0
  66. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_runtime/container_io_manager.pyi +0 -0
  67. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_runtime/execution_context.py +0 -0
  68. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_runtime/execution_context.pyi +0 -0
  69. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_runtime/gpu_memory_snapshot.py +0 -0
  70. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_runtime/task_lifecycle_manager.py +0 -0
  71. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_runtime/task_lifecycle_manager.pyi +0 -0
  72. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_runtime/telemetry.py +0 -0
  73. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_runtime/user_code_event_loop.py +0 -0
  74. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_runtime/user_code_imports.py +0 -0
  75. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_serialization.py +0 -0
  76. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_server.py +0 -0
  77. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_supports_logs.py +0 -0
  78. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_traceback.py +0 -0
  79. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_tunnel.py +0 -0
  80. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_tunnel.pyi +0 -0
  81. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_type_manager.py +0 -0
  82. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_utils/__init__.py +0 -0
  83. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_utils/app_utils.py +0 -0
  84. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_utils/async_utils.py +0 -0
  85. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_utils/blob_utils.py +0 -0
  86. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_utils/browser_utils.py +0 -0
  87. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_utils/bytes_io_segment_payload.py +0 -0
  88. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_utils/curl_utils.py +0 -0
  89. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_utils/deprecation.py +0 -0
  90. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_utils/display_utils.py +0 -0
  91. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_utils/docker_utils.py +0 -0
  92. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_utils/function_utils.py +0 -0
  93. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_utils/git_utils.py +0 -0
  94. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_utils/grpc_testing.py +0 -0
  95. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_utils/hash_utils.py +0 -0
  96. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_utils/http_utils.py +0 -0
  97. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_utils/jwt_utils.py +0 -0
  98. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_utils/logger.py +0 -0
  99. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_utils/mount_utils.py +0 -0
  100. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_utils/name_utils.py +0 -0
  101. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_utils/package_utils.py +0 -0
  102. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_utils/pattern_utils.py +0 -0
  103. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_utils/proxy_support.py +0 -0
  104. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_utils/rand_pb_testing.py +0 -0
  105. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_utils/sandbox_fs_utils.py +0 -0
  106. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_utils/shell_utils.py +0 -0
  107. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_utils/time_utils.py +0 -0
  108. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_vendor/__init__.py +0 -0
  109. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_vendor/a2wsgi_wsgi.py +0 -0
  110. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_vendor/cloudpickle.py +0 -0
  111. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_vendor/tblib.py +0 -0
  112. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_vendor/version.py +0 -0
  113. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_watcher.py +0 -0
  114. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_workspace.py +0 -0
  115. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/app.py +0 -0
  116. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/app.pyi +0 -0
  117. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/billing.py +0 -0
  118. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/billing.pyi +0 -0
  119. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/builder/2023.12.312.txt +0 -0
  120. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/builder/2023.12.txt +0 -0
  121. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/builder/2024.04.txt +0 -0
  122. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/builder/2024.10.txt +0 -0
  123. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/builder/2025.06.txt +0 -0
  124. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/builder/PREVIEW.txt +0 -0
  125. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/builder/README.md +0 -0
  126. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/builder/base-images.json +0 -0
  127. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/call_graph.py +0 -0
  128. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/cli/__init__.py +0 -0
  129. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/cli/_download.py +0 -0
  130. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/cli/_help.py +0 -0
  131. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/cli/_stats.py +0 -0
  132. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/cli/_traceback.py +0 -0
  133. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/cli/billing.py +0 -0
  134. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/cli/bootstrap.py +0 -0
  135. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/cli/changelog.py +0 -0
  136. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/cli/cluster.py +0 -0
  137. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/cli/config.py +0 -0
  138. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/cli/curl.py +0 -0
  139. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/cli/dashboard.py +0 -0
  140. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/cli/dict.py +0 -0
  141. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/cli/endpoint.py +0 -0
  142. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/cli/environment.py +0 -0
  143. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/cli/image.py +0 -0
  144. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/cli/import_refs.py +0 -0
  145. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/cli/launch.py +0 -0
  146. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/cli/logo.py +0 -0
  147. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/cli/network_file_system.py +0 -0
  148. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/cli/profile.py +0 -0
  149. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/cli/programs/__init__.py +0 -0
  150. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/cli/programs/run_jupyter.py +0 -0
  151. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/cli/programs/vscode.py +0 -0
  152. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/cli/queues.py +0 -0
  153. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/cli/run.py +0 -0
  154. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/cli/secret.py +0 -0
  155. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/cli/selector.py +0 -0
  156. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/cli/shell.py +0 -0
  157. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/cli/skills.py +0 -0
  158. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/cli/token.py +0 -0
  159. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/cli/volume.py +0 -0
  160. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/cli/workspace.py +0 -0
  161. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/cloud_bucket_mount.py +0 -0
  162. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/cloud_bucket_mount.pyi +0 -0
  163. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/cls.py +0 -0
  164. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/cls.pyi +0 -0
  165. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/container_process.py +0 -0
  166. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/container_process.pyi +0 -0
  167. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/dict.py +0 -0
  168. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/dict.pyi +0 -0
  169. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/environments.py +0 -0
  170. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/environments.pyi +0 -0
  171. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/experimental/__init__.py +0 -0
  172. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/experimental/flash.py +0 -0
  173. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/experimental/flash.pyi +0 -0
  174. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/experimental/ipython.py +0 -0
  175. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/file_pattern_matcher.py +0 -0
  176. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/functions.py +0 -0
  177. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/image.py +0 -0
  178. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/image.pyi +0 -0
  179. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/mount.py +0 -0
  180. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/mount.pyi +0 -0
  181. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/network_file_system.py +0 -0
  182. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/network_file_system.pyi +0 -0
  183. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/object.py +0 -0
  184. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/object.pyi +0 -0
  185. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/output.py +0 -0
  186. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/parallel_map.py +0 -0
  187. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/parallel_map.pyi +0 -0
  188. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/partial_function.py +0 -0
  189. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/partial_function.pyi +0 -0
  190. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/proxy.py +0 -0
  191. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/proxy.pyi +0 -0
  192. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/py.typed +0 -0
  193. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/queue.py +0 -0
  194. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/queue.pyi +0 -0
  195. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/retries.py +0 -0
  196. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/runner.py +0 -0
  197. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/runner.pyi +0 -0
  198. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/running_app.py +0 -0
  199. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/sandbox_fs.py +0 -0
  200. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/sandbox_fs.pyi +0 -0
  201. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/schedule.py +0 -0
  202. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/scheduler_placement.py +0 -0
  203. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/secret.py +0 -0
  204. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/secret.pyi +0 -0
  205. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/server.py +0 -0
  206. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/server.pyi +0 -0
  207. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/serving.py +0 -0
  208. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/serving.pyi +0 -0
  209. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/skills/modal/SKILL.md +0 -0
  210. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/snapshot.py +0 -0
  211. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/snapshot.pyi +0 -0
  212. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/stream_type.py +0 -0
  213. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/token_flow.py +0 -0
  214. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/token_flow.pyi +0 -0
  215. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/types.py +0 -0
  216. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/volume.py +0 -0
  217. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/volume.pyi +0 -0
  218. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/workspace.py +0 -0
  219. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/workspace.pyi +0 -0
  220. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal.egg-info/dependency_links.txt +0 -0
  221. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal.egg-info/entry_points.txt +0 -0
  222. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal.egg-info/requires.txt +0 -0
  223. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal.egg-info/top_level.txt +0 -0
  224. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal_docs/__init__.py +0 -0
  225. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal_docs/gen_cli_docs.py +0 -0
  226. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal_docs/gen_cli_docs_main.py +0 -0
  227. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal_docs/gen_reference_docs_main.py +0 -0
  228. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal_docs/mdmd/__init__.py +0 -0
  229. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal_docs/mdmd/mdmd.py +0 -0
  230. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal_docs/mdmd/signatures.py +0 -0
  231. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal_docs/mdmd/types.py +0 -0
  232. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal_proto/__init__.py +0 -0
  233. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal_proto/py.typed +0 -0
  234. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal_proto/task_command_router_grpc.py +0 -0
  235. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal_proto/task_command_router_pb2.py +0 -0
  236. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal_proto/task_command_router_pb2.pyi +0 -0
  237. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal_proto/task_command_router_pb2_grpc.py +0 -0
  238. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal_proto/task_command_router_pb2_grpc.pyi +0 -0
  239. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal_version/__main__.py +0 -0
  240. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/pyproject.toml +0 -0
  241. {modal-1.5.6.dev12 → modal-1.5.6.dev13}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: modal
3
- Version: 1.5.6.dev12
3
+ Version: 1.5.6.dev13
4
4
  Summary: Python client library for Modal
5
5
  Author-email: Modal Labs <support@modal.com>
6
6
  License-Expression: Apache-2.0
@@ -64,7 +64,7 @@ from ._utils.function_utils import (
64
64
  parse_gpu_config,
65
65
  validate_target_concurrency,
66
66
  )
67
- from ._utils.grpc_utils import Retry, RetryWarningMessage
67
+ from ._utils.grpc_utils import Retry, RetryWarningMessage, is_class_function_lookup_error
68
68
  from ._utils.mount_utils import validate_network_file_systems, validate_volumes, validate_volumes_by_object_id
69
69
  from .call_graph import InputInfo, _reconstruct_call_graph
70
70
  from .client import _Client
@@ -1361,9 +1361,13 @@ class _Function(typing.Generic[P, ReturnType, OriginalReturnType], _Object, type
1361
1361
  env_context = (
1362
1362
  f" (in the '{load_context.environment_name}' environment)" if load_context.environment_name else ""
1363
1363
  )
1364
- raise NotFoundError(
1365
- f"Lookup failed for Function '{name}' from the '{app_name}' app{env_context}: {exc}."
1366
- ) from None
1364
+ msg = f"Lookup failed for Function '{name}' from the '{app_name}' app{env_context}: {exc}."
1365
+ if is_class_function_lookup_error(exc):
1366
+ msg = (
1367
+ f"Function '{name}' from the '{app_name}' app{env_context} is a modal.Cls. "
1368
+ f"Use `modal.Cls.from_name` instead."
1369
+ )
1370
+ raise NotFoundError(msg) from None
1367
1371
 
1368
1372
  print_server_warnings(response.server_warnings)
1369
1373
 
@@ -46,16 +46,17 @@ class FunctionCreationStatus:
46
46
 
47
47
  if not self.response:
48
48
  self.status_row.finish(f"Unknown error when creating function {self.tag}")
49
+ return
49
50
 
50
- elif web_url := self.response.handle_metadata.web_url:
51
+ for warning in self.response.server_warnings:
52
+ self.status_row.warn(warning)
53
+
54
+ if web_url := self.response.handle_metadata.web_url:
51
55
  url_info = self.response.function.web_url_info
52
56
  requires_proxy_auth = self.response.function.webhook_config.requires_proxy_auth
53
57
  proxy_auth_suffix = " 🔑" if requires_proxy_auth else ""
54
58
  # Ensure terms used here match terms used in modal.com/docs/guide/webhook-urls doc.
55
59
  suffix = _get_suffix_from_web_url_info(url_info)
56
- # TODO: this is only printed when we're showing progress. Maybe move this somewhere else.
57
- for warning in self.response.server_warnings:
58
- self.status_row.warn(warning)
59
60
  self.status_row.finish(
60
61
  f"Created web function {self.tag} => [magenta underline]{web_url}[/magenta underline]"
61
62
  f"{proxy_auth_suffix}{suffix}"
@@ -78,8 +79,6 @@ class FunctionCreationStatus:
78
79
  )
79
80
 
80
81
  else:
81
- for warning in self.response.server_warnings:
82
- self.status_row.warn(warning)
83
82
  self.status_row.finish(f"Created function {self.tag}.")
84
83
  if self.response.function.method_definitions_set:
85
84
  for method_definition in self.response.function.method_definitions.values():
@@ -4,6 +4,7 @@ import base64
4
4
  import json
5
5
  import random
6
6
  import time
7
+ from collections.abc import Awaitable, Callable
7
8
  from typing import Any
8
9
 
9
10
  from modal._utils.grpc_utils import DEFAULT_MAX_RETRIES, Retry
@@ -15,8 +16,15 @@ from .logger import logger
15
16
  AUTH_DENIED_EXCEPTIONS = (AuthError, PermissionDeniedError)
16
17
 
17
18
 
19
+ TokenFetcher = Callable[[Retry], Awaitable[str]]
20
+
21
+
18
22
  class _AuthTokenManager:
19
- """Handles fetching and refreshing of the input plane auth token."""
23
+ """Handles fetching and refreshing of a short-lived JWT.
24
+
25
+ Defaults to the input plane auth token (`AuthTokenGet`); pass `fetch` to manage a different token with the
26
+ same caching and refresh behavior.
27
+ """
20
28
 
21
29
  # Fraction of the token's lifetime to use before refreshing.
22
30
  REFRESH_FRACTION = 0.5
@@ -26,7 +34,7 @@ class _AuthTokenManager:
26
34
  # only ever delays a refresh, so the margin before expiry stays at no less than
27
35
  # 1 - REFRESH_FRACTION - REFRESH_JITTER of the lifetime.
28
36
  REFRESH_JITTER = 0.1
29
- # Bound each AuthTokenGet attempt so a refresh can't hang.
37
+ # Bound each fetch attempt so a refresh can't hang.
30
38
  AUTH_TOKEN_TIMEOUT = 5.0
31
39
  AUTH_TOKEN_RETRY_TOTAL_TIMEOUT = 3 * AUTH_TOKEN_TIMEOUT
32
40
  # After a failed refresh, wait before hitting the server again, growing exponentially with
@@ -36,8 +44,9 @@ class _AuthTokenManager:
36
44
  # If the token doesn't have an expiry field, default to current time plus this value (not expected).
37
45
  DEFAULT_EXPIRY_OFFSET = 20 * 60
38
46
 
39
- def __init__(self, stub: "modal_api_grpc.ModalClientModal"):
47
+ def __init__(self, stub: "modal_api_grpc.ModalClientModal", fetch: TokenFetcher | None = None):
40
48
  self._stub = stub
49
+ self._fetch = fetch or self._fetch_auth_token
41
50
  self._token = ""
42
51
  self._expiry = 0.0
43
52
  self._refresh_at = 0.0
@@ -105,31 +114,33 @@ class _AuthTokenManager:
105
114
  # Retrieve the exception, if any, so a failure isn't logged as unhandled when no caller is waiting.
106
115
  task.exception()
107
116
 
117
+ async def _fetch_auth_token(self, retry: Retry) -> str:
118
+ resp: api_pb2.AuthTokenGetResponse = await self._stub.AuthTokenGet(api_pb2.AuthTokenGetRequest(), retry=retry)
119
+ return resp.token
120
+
108
121
  async def _fetch_token(self):
109
- """Make the AuthTokenGet request and cache its token, or arm the failure backoff."""
122
+ """Fetch a new token and cache it, or arm the failure backoff."""
110
123
  try:
111
- resp: api_pb2.AuthTokenGetResponse = await self._stub.AuthTokenGet(
112
- api_pb2.AuthTokenGetRequest(),
113
- # No cached token to fall back on, so a failure is user-visible: retry transient errors.
114
- # Otherwise one attempt, and _retry_after handles the cooldown.
115
- retry=Retry(
116
- attempt_timeout=self.AUTH_TOKEN_TIMEOUT,
117
- total_timeout=self.AUTH_TOKEN_RETRY_TOTAL_TIMEOUT,
118
- max_retries=0 if self._token else DEFAULT_MAX_RETRIES,
119
- ),
124
+ # No cached token to fall back on, so a failure is user-visible: retry transient errors.
125
+ # Otherwise one attempt, and _retry_after handles the cooldown.
126
+ retry = Retry(
127
+ attempt_timeout=self.AUTH_TOKEN_TIMEOUT,
128
+ total_timeout=self.AUTH_TOKEN_RETRY_TOTAL_TIMEOUT,
129
+ max_retries=0 if self._token else DEFAULT_MAX_RETRIES,
120
130
  )
121
- if not resp.token:
131
+ token = await self._fetch(retry)
132
+ if not token:
122
133
  # Not expected
123
134
  raise ExecutionError(
124
135
  "Internal error: Did not receive auth token from server. Please contact Modal support."
125
136
  )
126
- if exp := self._decode_jwt(resp.token).get("exp"):
137
+ if exp := self._decode_jwt(token).get("exp"):
127
138
  expiry = float(exp)
128
139
  else:
129
140
  # This should never happen.
130
- logger.warning("x-modal-auth-token does not contain exp field")
141
+ logger.warning("Auth token does not contain exp field")
131
142
  expiry = time.time() + self.DEFAULT_EXPIRY_OFFSET
132
- self._token = resp.token
143
+ self._token = token
133
144
  self._set_expiry(expiry)
134
145
  self._retry_after = 0.0
135
146
  self._backoff = self.FAILURE_BACKOFF_BASE
@@ -25,7 +25,7 @@ from grpclib.encoding.base import CodecBase, StatusDetailsCodecBase
25
25
  from grpclib.exceptions import StreamTerminatedError
26
26
  from grpclib.protocol import H2Protocol
27
27
 
28
- from modal.exception import ClientClosed, ConnectionError
28
+ from modal.exception import ClientClosed, ConnectionError, NotFoundError
29
29
  from modal_proto import api_pb2
30
30
  from modal_version import __version__
31
31
 
@@ -121,12 +121,12 @@ class ModalChannel(grpclib.client.Channel):
121
121
  self.__target_host = host
122
122
  self.__target_port = port
123
123
  self.__ssl_context = ssl_context
124
- self.__closed = False
125
- self.__closed_error_message = closed_error_message
124
+ self._permanently_closed = False
125
+ self._closed_error_message = closed_error_message
126
126
 
127
127
  async def __connect__(self):
128
- if self.__closed and self.__closed_error_message is not None:
129
- raise ClientClosed(self.__closed_error_message)
128
+ if self._permanently_closed and self._closed_error_message is not None:
129
+ raise ClientClosed(self._closed_error_message)
130
130
  return await super().__connect__()
131
131
 
132
132
  async def _create_connection(self) -> H2Protocol:
@@ -161,8 +161,13 @@ class ModalChannel(grpclib.client.Channel):
161
161
  and not self._protocol.connection.is_closing()
162
162
  )
163
163
 
164
+ def release_connection(self):
165
+ """Release the connection while leaving the channel reusable."""
166
+ return super().close()
167
+
164
168
  def close(self):
165
- self.__closed = True
169
+ """Close the channel permanently."""
170
+ self._permanently_closed = True
166
171
  return super().close()
167
172
 
168
173
 
@@ -450,6 +455,15 @@ async def retry_transient_errors(
450
455
  return await _retry_transient_errors(fn, req, retry=Retry(max_retries=max_retries))
451
456
 
452
457
 
458
+ def is_class_function_lookup_error(exc: NotFoundError) -> bool:
459
+ """Whether a function lookup failed because the requested name identifies a class."""
460
+ return any(
461
+ isinstance(detail, api_pb2.FunctionLookupError)
462
+ and detail.reason == api_pb2.FunctionLookupError.REASON_CLASS_NAME_USED
463
+ for detail in (getattr(exc, "_grpc_details", None) or [])
464
+ )
465
+
466
+
453
467
  def get_server_retry_policy(exc: Exception) -> api_pb2.RPCRetryPolicy | None:
454
468
  """Get server retry policy."""
455
469
  if not isinstance(exc, GRPCError) or not exc.details:
@@ -0,0 +1,64 @@
1
+ # Copyright Modal Labs 2025
2
+ import asyncio
3
+ from collections.abc import Callable
4
+
5
+
6
+ class IdleCountdown:
7
+ """An extendable idle timeout tracker."""
8
+
9
+ def __init__(self, loop: asyncio.AbstractEventLoop) -> None:
10
+ self._loop = loop
11
+ self._handle: asyncio.TimerHandle | None = None
12
+ self._retired = False
13
+ self._expire_on_idle = False
14
+ self._release: Callable[[], None] | None = None
15
+
16
+ def _assert_owning_loop(self) -> None:
17
+ assert asyncio.get_running_loop() is self._loop, "Idle bookkeeping must run on its owning event loop"
18
+
19
+ def expire_when_idle(self) -> None:
20
+ """Release at the current or next idle boundary, even if the timeout is disabled.
21
+
22
+ Must run on the owning event loop without yielding control.
23
+ """
24
+ self._assert_owning_loop()
25
+ self._expire_on_idle = True
26
+ if self._release is not None and not self._retired:
27
+ release = self._release
28
+ self.stop()
29
+ release()
30
+
31
+ def stop(self) -> None:
32
+ """Cancel the countdown on the owning event loop without yielding control."""
33
+ self._assert_owning_loop()
34
+ self._release = None
35
+ if self._handle is not None:
36
+ self._handle.cancel()
37
+ self._handle = None
38
+
39
+ def retire(self) -> None:
40
+ """Stop the countdown and refuse every subsequent arm.
41
+
42
+ Must run on the owning event loop without yielding control.
43
+ """
44
+ self._assert_owning_loop()
45
+ self._retired = True
46
+ self.stop()
47
+
48
+ def arm(self, timeout_secs: float, release: Callable[[], None]) -> None:
49
+ """Start the countdown again, calling `release` when it elapses.
50
+
51
+ Must run on the owning event loop without yielding control.
52
+ A timeout of zero or less never fires unless `expire_when_idle` was
53
+ requested. `release` runs as a plain callback on the event loop, so it
54
+ must not block.
55
+ """
56
+ self.stop()
57
+ if self._retired:
58
+ return
59
+ self._release = release
60
+ if self._expire_on_idle:
61
+ timeout_secs = 0
62
+ elif timeout_secs <= 0:
63
+ return
64
+ self._handle = self._loop.call_later(timeout_secs, release)
@@ -1,6 +1,7 @@
1
1
  # Copyright Modal Labs 2025
2
2
  import asyncio
3
3
  import base64
4
+ import contextlib
4
5
  import io
5
6
  import json
6
7
  import socket
@@ -18,8 +19,15 @@ import grpclib.events
18
19
  from grpclib import GRPCError, Status
19
20
  from grpclib.exceptions import StreamTerminatedError
20
21
 
21
- from modal.config import logger
22
- from modal.exception import AuthError, ExecTimeoutError, InternalError, ServiceError, TimeoutError as ModalTimeoutError
22
+ from modal.config import config, logger
23
+ from modal.exception import (
24
+ AuthError,
25
+ ClientClosed,
26
+ ExecTimeoutError,
27
+ InternalError,
28
+ ServiceError,
29
+ TimeoutError as ModalTimeoutError,
30
+ )
23
31
  from modal_proto import api_pb2, task_command_router_pb2 as sr_pb2
24
32
  from modal_proto.task_command_router_grpc import TaskCommandRouterStub
25
33
 
@@ -27,6 +35,7 @@ from .._grpc_client import grpc_error_converter
27
35
  from .._utils.grpc_utils import ModalChannel, create_channel_config
28
36
  from .async_utils import aclosing, retry
29
37
  from .grpc_utils import RETRYABLE_GRPC_STATUS_CODES
38
+ from .idle_countdown import IdleCountdown
30
39
 
31
40
  STREAMING_STDIN_CHUNK_SIZE = 256 * 1024
32
41
 
@@ -271,7 +280,10 @@ class TaskCommandRouterClient:
271
280
  loop = asyncio.get_running_loop()
272
281
  jwt_refresh_lock = asyncio.Lock()
273
282
 
274
- return cls(server_client, task_id, url, jwt, channel, loop, jwt_refresh_lock, sandbox_id=sandbox_id)
283
+ client = cls(server_client, task_id, url, jwt, channel, loop, jwt_refresh_lock, sandbox_id=sandbox_id)
284
+ # An unused client releases its connection too.
285
+ client._arm_idle_release()
286
+ return client
275
287
 
276
288
  @classmethod
277
289
  async def init(
@@ -323,7 +335,7 @@ class TaskCommandRouterClient:
323
335
  task_id: str,
324
336
  server_url: str,
325
337
  jwt: str,
326
- channel: grpclib.client.Channel,
338
+ channel: ModalChannel,
327
339
  loop: asyncio.AbstractEventLoop,
328
340
  jwt_refresh_lock: asyncio.Lock,
329
341
  *,
@@ -337,7 +349,7 @@ class TaskCommandRouterClient:
337
349
  # even if finalization happens from a different thread (e.g. via synchronicity).
338
350
  self._loop = loop
339
351
 
340
- # Attach bearer token on all requests to the worker-side router service.
352
+ # Attach the bearer token to every request.
341
353
  self._server_client = server_client
342
354
  self._task_id = task_id
343
355
  self._sandbox_id = sandbox_id
@@ -356,6 +368,11 @@ class TaskCommandRouterClient:
356
368
 
357
369
  self._closed = False
358
370
 
371
+ self.idle_timeout_secs: float = config["sandbox_channel_idle_timeout"]
372
+ self._inflight = 0
373
+ self._idle = IdleCountdown(loop)
374
+ self._release_seq = 0
375
+
359
376
  self._channel_finalizer = weakref.finalize(
360
377
  self,
361
378
  _finalize_channel,
@@ -369,18 +386,96 @@ class TaskCommandRouterClient:
369
386
  def _is_v2_sandbox(self) -> bool:
370
387
  return self._sandbox_id is not None or _is_v2_task_id(self._task_id)
371
388
 
389
+ def _ensure_open(self) -> None:
390
+ if self._closed:
391
+ raise ClientClosed("Unable to perform operation on a detached sandbox")
392
+
372
393
  def _get_metadata(self):
394
+ self._ensure_open()
373
395
  return {"authorization": f"Bearer {self._jwt}"}
374
396
 
375
- async def close(self) -> None:
376
- """Close the client."""
397
+ @contextlib.asynccontextmanager
398
+ async def _lease(self):
399
+ """Keep the connection active for the duration of an operation."""
400
+ self._ensure_open()
401
+ self._take_lease()
402
+ try:
403
+ yield
404
+ finally:
405
+ self._drop_lease()
406
+
407
+ def _assert_owning_loop(self) -> None:
408
+ assert asyncio.get_running_loop() is self._loop, "Channel bookkeeping must run on its owning event loop"
409
+
410
+ def _take_lease(self) -> None:
411
+ """Acquire a lease on the owning event loop without yielding control."""
412
+ self._idle.stop()
413
+ self._inflight += 1
414
+
415
+ def _drop_lease(self) -> None:
416
+ """Release a lease on the owning event loop without yielding control."""
417
+ self._assert_owning_loop()
418
+ self._inflight -= 1
419
+ if self._inflight == 0:
420
+ self._arm_idle_release()
421
+
422
+ def _arm_idle_release(self) -> None:
423
+ """Arm idle release on the owning event loop without yielding control."""
424
+ self._idle.arm(self.idle_timeout_secs, self._release_if_idle)
425
+
426
+ def _release_if_idle(self) -> None:
427
+ """Release the inactive connection, permanently if the client is closed.
428
+
429
+ Must run on the owning event loop without yielding control.
430
+ """
431
+ self._assert_owning_loop()
432
+ if self._inflight:
433
+ return
377
434
  if self._closed:
435
+ self._close_channel()
436
+ return
437
+ if not self._channel._connected:
378
438
  return
439
+ logger.debug(f"Releasing idle command router channel for task {self._task_id}")
440
+ self._release_seq += 1
441
+ self._channel.release_connection()
442
+
443
+ @contextlib.asynccontextmanager
444
+ async def _lease_suspended(self):
445
+ """Temporarily suspend the caller's lease."""
446
+ self._drop_lease()
447
+ try:
448
+ yield
449
+ finally:
450
+ self._take_lease()
451
+
452
+ def _was_released_since(self, release_seq: int) -> bool:
453
+ """Whether an idle release invalidated the current stream."""
454
+ return self._release_seq != release_seq
455
+
456
+ async def close_when_idle(self) -> None:
457
+ """Request the "close soon" behavior used by Sandbox.detach().
379
458
 
459
+ Reject new operations and close the channel as soon as active leases
460
+ finish, without waiting for or interrupting those operations.
461
+ """
462
+ self._assert_owning_loop()
380
463
  self._closed = True
381
- self._channel.close()
464
+ self._idle.expire_when_idle()
465
+ self._release_if_idle()
466
+
467
+ async def close(self) -> None:
468
+ """Close the client permanently; later use raises `ClientClosed`."""
469
+ self._assert_owning_loop()
470
+ self._closed = True
471
+ self._idle.retire()
472
+ self._close_channel()
473
+
474
+ def _close_channel(self) -> None:
475
+ """Close on the owning event loop without yielding, including from an idle timer."""
476
+ self._assert_owning_loop()
382
477
  if self._channel_finalizer.alive:
383
- # skip the finalizer if we've closed the channel anyway
478
+ self._channel.close()
384
479
  self._channel_finalizer.detach()
385
480
 
386
481
  async def exec_start(self, request: sr_pb2.TaskExecStartRequest) -> sr_pb2.TaskExecStartResponse:
@@ -590,27 +685,28 @@ class TaskCommandRouterClient:
590
685
  Does not retry; `exec_stdin_write_stream` owns resume.
591
686
  """
592
687
  with grpc_error_converter():
593
- stream = self._stub.TaskExecStdinWriteStream.open(metadata=self._get_metadata())
594
- async with stream as s:
595
- start = sr_pb2.TaskExecStdinWriteStreamRequest(
596
- start=sr_pb2.TaskExecStdinWriteStreamStart(
597
- task_id=task_id,
598
- exec_id=exec_id,
599
- offset=start_offset,
600
- ),
601
- )
602
- await s.send_message(start)
603
- async for data in chunks:
604
- if not data:
605
- continue
606
- await s.send_message(sr_pb2.TaskExecStdinWriteStreamRequest(data=data))
607
- # The server closes stdin only on this explicit End message. A
608
- # stream that breaks before it leaves stdin open for resume.
609
- await s.send_message(
610
- sr_pb2.TaskExecStdinWriteStreamRequest(end=sr_pb2.TaskExecStdinWriteStreamEnd()),
611
- end=True,
612
- )
613
- return await s.recv_message()
688
+ async with self._lease():
689
+ stream = self._stub.TaskExecStdinWriteStream.open(metadata=self._get_metadata())
690
+ async with stream as s:
691
+ start = sr_pb2.TaskExecStdinWriteStreamRequest(
692
+ start=sr_pb2.TaskExecStdinWriteStreamStart(
693
+ task_id=task_id,
694
+ exec_id=exec_id,
695
+ offset=start_offset,
696
+ ),
697
+ )
698
+ await s.send_message(start)
699
+ async for data in chunks:
700
+ if not data:
701
+ continue
702
+ await s.send_message(sr_pb2.TaskExecStdinWriteStreamRequest(data=data))
703
+ # Only an explicit End marks stdin as closed. A failed write
704
+ # stream remains resumable.
705
+ await s.send_message(
706
+ sr_pb2.TaskExecStdinWriteStreamRequest(end=sr_pb2.TaskExecStdinWriteStreamEnd()),
707
+ end=True,
708
+ )
709
+ return await s.recv_message()
614
710
 
615
711
  async def exec_stdin_status(self, task_id: str, exec_id: str) -> sr_pb2.TaskExecStdinStatusResponse:
616
712
  """Read the current stdin write status for an exec'd command, to support retries from the right offset.
@@ -778,14 +874,15 @@ class TaskCommandRouterClient:
778
874
  self._jwt_exp = _parse_jwt_expiration(jwt)
779
875
 
780
876
  async def _call_with_auth_retry(self, func, *args, **kwargs):
781
- try:
782
- return await func(*args, **kwargs, metadata=self._get_metadata())
783
- except GRPCError as exc:
784
- if exc.status == Status.UNAUTHENTICATED:
785
- await self._refresh_jwt()
786
- # Retry with the original arguments preserved
877
+ async with self._lease():
878
+ try:
787
879
  return await func(*args, **kwargs, metadata=self._get_metadata())
788
- raise
880
+ except GRPCError as exc:
881
+ if exc.status == Status.UNAUTHENTICATED:
882
+ await self._refresh_jwt()
883
+ # Retry with the original arguments preserved
884
+ return await func(*args, **kwargs, metadata=self._get_metadata())
885
+ raise
789
886
 
790
887
  async def _stream_stdio_with_retries(
791
888
  self,
@@ -795,17 +892,14 @@ class TaskCommandRouterClient:
795
892
  deadline_label: str,
796
893
  deadline: float | None = None,
797
894
  ) -> AsyncGenerator[_StdioResp, None]:
798
- """Drive a streaming-stdio RPC with offset bookkeeping, transient-error
799
- retries, and JWT-refresh auth retries.
800
-
801
- Shared by [`_stream_stdio`] (exec stdio) and [`_stream_sandbox_stdio`]
802
- (V2 sandbox top-level stdio); both response types have a ``bytes data``
803
- field that this helper uses to advance the offset. For V2 sandbox
804
- responses (which carry ``starting_offset``), the offset is rebased off
805
- the first chunk of each attempt so transient reconnects don't miss
806
- bytes.
895
+ """Read stdio with offset tracking, authentication, and transient retries.
896
+
897
+ The connection is kept active while waiting for output and may be
898
+ released while the caller holds a chunk.
807
899
  """
808
900
  offset = 0
901
+ # Set when an idle release invalidates the stream while its lease is suspended.
902
+ reopen_after_release = False
809
903
  delay_secs = self.stream_stdio_retry_delay_secs
810
904
  delay_factor = self.stream_stdio_retry_delay_factor
811
905
  num_retries_remaining = self.stream_stdio_max_retries
@@ -815,6 +909,7 @@ class TaskCommandRouterClient:
815
909
 
816
910
  async def sleep_and_update_delay_and_num_retries_remaining(e: Exception):
817
911
  nonlocal delay_secs, num_retries_remaining
912
+ self._ensure_open()
818
913
  logger.debug(f"Retrying stdio read with delay {delay_secs}s due to error: {e}")
819
914
  if deadline is not None and deadline - time.monotonic() <= delay_secs:
820
915
  raise ExecTimeoutError(f"Deadline exceeded while streaming stdio for {deadline_label}")
@@ -826,41 +921,54 @@ class TaskCommandRouterClient:
826
921
  while True:
827
922
  timeout = max(0, deadline - time.monotonic()) if deadline is not None else None
828
923
  try:
829
- stream = stub_method.open(timeout=timeout, metadata=self._get_metadata())
830
- async with stream as s:
831
- req = request_factory(offset)
832
-
833
- # Auth retry is scoped to a single refresh per streaming attempt. While auth metadata is
834
- # sent on request start, UNAUTHENTICATED may sometimes surface during iteration,
835
- # so we handle it at both send and receive boundaries.
836
- is_first_chunk_of_attempt = True
837
- try:
838
- await s.send_message(req, end=True)
839
- async for item in s:
840
- # We successfully authenticated after a JWT refresh, reset the auth retry flag.
841
- if did_auth_retry:
842
- did_auth_retry = False
843
- # Any received chunk is progress: reset the backoff and refill the
844
- # retry budget so it bounds consecutive failures, not failures over
845
- # the stream's lifetime.
846
- delay_secs = self.stream_stdio_retry_delay_secs
847
- num_retries_remaining = self.stream_stdio_max_retries
848
- # Track it so transient reconnects request the
849
- # correct next byte.
850
- if is_first_chunk_of_attempt and isinstance(item, sr_pb2.SandboxStdioReadV2Response):
851
- offset = item.starting_offset
852
- is_first_chunk_of_attempt = False
853
- offset += len(item.data)
854
- yield item
855
- except GRPCError as exc:
856
- if exc.status == Status.UNAUTHENTICATED and not did_auth_retry:
857
- await self._refresh_jwt()
858
- # Mark that we've retried authentication for this streaming attempt, to
859
- # prevent subsequent retries.
860
- did_auth_retry = True
861
- continue
862
- raise
863
-
924
+ async with self._lease():
925
+ release_seq_at_open = self._release_seq
926
+ stream = stub_method.open(timeout=timeout, metadata=self._get_metadata())
927
+ async with stream as s:
928
+ req = request_factory(offset)
929
+
930
+ # Auth retry is scoped to a single refresh per streaming attempt. While auth metadata is
931
+ # sent on request start, UNAUTHENTICATED may sometimes surface during iteration,
932
+ # so we handle it at both send and receive boundaries.
933
+ is_first_chunk_of_attempt = True
934
+ try:
935
+ await s.send_message(req, end=True)
936
+ async for item in s:
937
+ # We successfully authenticated after a JWT refresh, reset the auth retry flag.
938
+ if did_auth_retry:
939
+ did_auth_retry = False
940
+ # A successful chunk ends the current run of transient failures.
941
+ delay_secs = self.stream_stdio_retry_delay_secs
942
+ num_retries_remaining = self.stream_stdio_max_retries
943
+ # Track it so transient reconnects request the
944
+ # correct next byte.
945
+ if is_first_chunk_of_attempt and isinstance(item, sr_pb2.SandboxStdioReadV2Response):
946
+ offset = item.starting_offset
947
+ is_first_chunk_of_attempt = False
948
+ offset += len(item.data)
949
+ async with self._lease_suspended():
950
+ yield item
951
+ # Reject output buffered while the consumer held the chunk
952
+ # if the Sandbox was detached during the yield.
953
+ self._ensure_open()
954
+ # Resume from the current offset if an idle release made
955
+ # this stream stale while the lease was suspended.
956
+ if self._was_released_since(release_seq_at_open):
957
+ reopen_after_release = True
958
+ break
959
+ except GRPCError as exc:
960
+ if exc.status == Status.UNAUTHENTICATED and not did_auth_retry:
961
+ await self._refresh_jwt()
962
+ # Mark that we've retried authentication for this streaming attempt, to
963
+ # prevent subsequent retries.
964
+ did_auth_retry = True
965
+ continue
966
+ raise
967
+
968
+ if reopen_after_release:
969
+ # Idle release does not consume the transient-error retry budget.
970
+ reopen_after_release = False
971
+ continue
864
972
  # We successfully streamed all output.
865
973
  return
866
974
  except GRPCError as e:
@@ -872,7 +980,9 @@ class TaskCommandRouterClient:
872
980
  # StreamTerminatedError are not properly raised in grpclib<=0.4.7
873
981
  # fixed in https://github.com/vmagamedov/grpclib/issues/185
874
982
  # TODO: update to newer version (>=0.4.8) once stable
875
- if num_retries_remaining > 0 and "_write_appdata" in str(e):
983
+ if "_write_appdata" not in str(e):
984
+ raise e
985
+ if num_retries_remaining > 0:
876
986
  await sleep_and_update_delay_and_num_retries_remaining(e)
877
987
  else:
878
988
  raise e