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.
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/PKG-INFO +1 -1
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_functions.py +8 -4
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_output/status.py +5 -6
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_utils/auth_token_manager.py +28 -17
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_utils/grpc_utils.py +20 -6
- modal-1.5.6.dev13/modal/_utils/idle_countdown.py +64 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_utils/task_command_router_client.py +192 -82
- modal-1.5.6.dev13/modal/cli/_logs.py +131 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/cli/app.py +16 -111
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/cli/container.py +1 -1
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/cli/entry_point.py +2 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/cli/function.py +148 -9
- modal-1.5.6.dev13/modal/cli/server.py +136 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/cli/utils.py +13 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/client.py +0 -1
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/client.pyi +2 -2
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/config.py +4 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/exception.py +0 -4
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/functions.pyi +6 -6
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/io_streams.py +18 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/io_streams.pyi +18 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/sandbox.py +50 -152
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/sandbox.pyi +15 -188
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal.egg-info/PKG-INFO +1 -1
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal.egg-info/SOURCES.txt +3 -2
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal_docs/gen_reference_docs.py +0 -1
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal_docs/reference_intro.md +0 -1
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal_proto/api_grpc.py +48 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal_proto/api_pb2.py +1285 -1191
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal_proto/api_pb2.pyi +210 -7
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal_proto/api_pb2_grpc.py +99 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal_proto/api_pb2_grpc.pyi +30 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal_proto/modal_api_grpc.py +3 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal_version/__init__.py +1 -1
- modal-1.5.6.dev12/modal/file_io.py +0 -564
- modal-1.5.6.dev12/modal/file_io.pyi +0 -447
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/LICENSE +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/README.md +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/__init__.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/__main__.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_billing.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_clustered_functions.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_clustered_functions.pyi +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_container_entrypoint.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_environments.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_function_variants.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_grpc_client.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_image.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_ipython.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_load_context.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_location.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_logs.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_logs_manager.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_logs_manager.pyi +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_object.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_output/__init__.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_output/manager.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_output/pty.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_output/rich.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_partial_function.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_resolver.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_resources.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_runtime/__init__.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_runtime/asgi.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_runtime/container_io_manager.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_runtime/container_io_manager.pyi +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_runtime/execution_context.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_runtime/execution_context.pyi +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_runtime/gpu_memory_snapshot.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_runtime/task_lifecycle_manager.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_runtime/task_lifecycle_manager.pyi +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_runtime/telemetry.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_runtime/user_code_event_loop.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_runtime/user_code_imports.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_serialization.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_server.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_supports_logs.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_traceback.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_tunnel.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_tunnel.pyi +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_type_manager.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_utils/__init__.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_utils/app_utils.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_utils/async_utils.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_utils/blob_utils.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_utils/browser_utils.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_utils/bytes_io_segment_payload.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_utils/curl_utils.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_utils/deprecation.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_utils/display_utils.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_utils/docker_utils.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_utils/function_utils.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_utils/git_utils.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_utils/grpc_testing.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_utils/hash_utils.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_utils/http_utils.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_utils/jwt_utils.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_utils/logger.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_utils/mount_utils.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_utils/name_utils.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_utils/package_utils.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_utils/pattern_utils.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_utils/proxy_support.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_utils/rand_pb_testing.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_utils/sandbox_fs_utils.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_utils/shell_utils.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_utils/time_utils.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_vendor/__init__.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_vendor/a2wsgi_wsgi.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_vendor/cloudpickle.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_vendor/tblib.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_vendor/version.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_watcher.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/_workspace.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/app.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/app.pyi +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/billing.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/billing.pyi +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/builder/2023.12.312.txt +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/builder/2023.12.txt +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/builder/2024.04.txt +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/builder/2024.10.txt +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/builder/2025.06.txt +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/builder/PREVIEW.txt +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/builder/README.md +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/builder/base-images.json +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/call_graph.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/cli/__init__.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/cli/_download.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/cli/_help.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/cli/_stats.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/cli/_traceback.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/cli/billing.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/cli/bootstrap.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/cli/changelog.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/cli/cluster.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/cli/config.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/cli/curl.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/cli/dashboard.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/cli/dict.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/cli/endpoint.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/cli/environment.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/cli/image.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/cli/import_refs.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/cli/launch.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/cli/logo.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/cli/network_file_system.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/cli/profile.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/cli/programs/__init__.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/cli/programs/run_jupyter.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/cli/programs/vscode.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/cli/queues.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/cli/run.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/cli/secret.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/cli/selector.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/cli/shell.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/cli/skills.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/cli/token.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/cli/volume.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/cli/workspace.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/cloud_bucket_mount.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/cloud_bucket_mount.pyi +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/cls.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/cls.pyi +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/container_process.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/container_process.pyi +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/dict.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/dict.pyi +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/environments.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/environments.pyi +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/experimental/__init__.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/experimental/flash.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/experimental/flash.pyi +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/experimental/ipython.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/file_pattern_matcher.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/functions.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/image.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/image.pyi +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/mount.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/mount.pyi +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/network_file_system.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/network_file_system.pyi +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/object.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/object.pyi +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/output.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/parallel_map.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/parallel_map.pyi +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/partial_function.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/partial_function.pyi +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/proxy.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/proxy.pyi +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/py.typed +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/queue.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/queue.pyi +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/retries.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/runner.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/runner.pyi +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/running_app.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/sandbox_fs.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/sandbox_fs.pyi +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/schedule.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/scheduler_placement.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/secret.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/secret.pyi +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/server.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/server.pyi +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/serving.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/serving.pyi +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/skills/modal/SKILL.md +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/snapshot.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/snapshot.pyi +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/stream_type.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/token_flow.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/token_flow.pyi +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/types.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/volume.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/volume.pyi +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/workspace.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal/workspace.pyi +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal.egg-info/dependency_links.txt +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal.egg-info/entry_points.txt +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal.egg-info/requires.txt +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal.egg-info/top_level.txt +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal_docs/__init__.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal_docs/gen_cli_docs.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal_docs/gen_cli_docs_main.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal_docs/gen_reference_docs_main.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal_docs/mdmd/__init__.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal_docs/mdmd/mdmd.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal_docs/mdmd/signatures.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal_docs/mdmd/types.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal_proto/__init__.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal_proto/py.typed +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal_proto/task_command_router_grpc.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal_proto/task_command_router_pb2.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal_proto/task_command_router_pb2.pyi +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal_proto/task_command_router_pb2_grpc.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal_proto/task_command_router_pb2_grpc.pyi +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/modal_version/__main__.py +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/pyproject.toml +0 -0
- {modal-1.5.6.dev12 → modal-1.5.6.dev13}/setup.cfg +0 -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
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
"""
|
|
122
|
+
"""Fetch a new token and cache it, or arm the failure backoff."""
|
|
110
123
|
try:
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
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(
|
|
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("
|
|
141
|
+
logger.warning("Auth token does not contain exp field")
|
|
131
142
|
expiry = time.time() + self.DEFAULT_EXPIRY_OFFSET
|
|
132
|
-
self._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.
|
|
125
|
-
self.
|
|
124
|
+
self._permanently_closed = False
|
|
125
|
+
self._closed_error_message = closed_error_message
|
|
126
126
|
|
|
127
127
|
async def __connect__(self):
|
|
128
|
-
if self.
|
|
129
|
-
raise ClientClosed(self.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
376
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
start=sr_pb2.
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
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
|
-
|
|
782
|
-
|
|
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
|
-
|
|
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
|
-
"""
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
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
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
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
|
|
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
|