crabcode 0.1.6__py3-none-win_amd64.whl
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.
- crabcode-0.1.6.dist-info/METADATA +63 -0
- crabcode-0.1.6.dist-info/RECORD +205 -0
- crabcode-0.1.6.dist-info/WHEEL +4 -0
- crabcode-0.1.6.dist-info/entry_points.txt +2 -0
- crabcode_cli/__init__.py +7 -0
- crabcode_cli/__main__.py +4 -0
- crabcode_cli/_native/crabcode-computer-use-host.exe +0 -0
- crabcode_cli/_native/runtime.json +6 -0
- crabcode_cli/app.py +776 -0
- crabcode_cli/banner.py +39 -0
- crabcode_cli/computer_use.py +286 -0
- crabcode_cli/model_manager.py +649 -0
- crabcode_cli/model_picker.py +168 -0
- crabcode_cli/pipe.py +148 -0
- crabcode_cli/render/__init__.py +0 -0
- crabcode_cli/render/messages.py +19 -0
- crabcode_cli/repl.py +4841 -0
- crabcode_cli/session_picker.py +239 -0
- crabcode_core/__init__.py +7 -0
- crabcode_core/_version.py +3 -0
- crabcode_core/_windows_process.py +68 -0
- crabcode_core/agent_manager.py +1731 -0
- crabcode_core/api/__init__.py +20 -0
- crabcode_core/api/anthropic_adapter.py +648 -0
- crabcode_core/api/azure_adapter.py +69 -0
- crabcode_core/api/base.py +233 -0
- crabcode_core/api/codex_adapter.py +1327 -0
- crabcode_core/api/error_logging.py +132 -0
- crabcode_core/api/gemini_adapter.py +26 -0
- crabcode_core/api/model_info.py +257 -0
- crabcode_core/api/network.py +62 -0
- crabcode_core/api/ollama_adapter.py +27 -0
- crabcode_core/api/openai_adapter.py +323 -0
- crabcode_core/api/registry.py +58 -0
- crabcode_core/command_line.py +18 -0
- crabcode_core/compact/__init__.py +17 -0
- crabcode_core/compact/compact.py +514 -0
- crabcode_core/compact/context_tokens.py +165 -0
- crabcode_core/computer_use_mode.py +38 -0
- crabcode_core/computer_use_observation.py +144 -0
- crabcode_core/config/__init__.py +3 -0
- crabcode_core/config/claudemd.py +61 -0
- crabcode_core/config/manager.py +237 -0
- crabcode_core/config/model_catalog.py +256 -0
- crabcode_core/events.py +4896 -0
- crabcode_core/file_lock.py +81 -0
- crabcode_core/filesystem.py +22 -0
- crabcode_core/goal.py +149 -0
- crabcode_core/hooks/__init__.py +5 -0
- crabcode_core/hooks/manager.py +290 -0
- crabcode_core/image_io.py +111 -0
- crabcode_core/io_worker.py +211 -0
- crabcode_core/logging_utils.py +168 -0
- crabcode_core/lsp/__init__.py +6 -0
- crabcode_core/lsp/client.py +786 -0
- crabcode_core/lsp/diagnostics.py +150 -0
- crabcode_core/lsp/manager.py +583 -0
- crabcode_core/mcp/__init__.py +4 -0
- crabcode_core/mcp/client.py +169 -0
- crabcode_core/mcp/config.py +57 -0
- crabcode_core/path_validation.py +34 -0
- crabcode_core/paths.py +24 -0
- crabcode_core/peer/__init__.py +10 -0
- crabcode_core/peer/runtime.py +518 -0
- crabcode_core/permissions/__init__.py +7 -0
- crabcode_core/permissions/ai_reviewer.py +184 -0
- crabcode_core/permissions/manager.py +187 -0
- crabcode_core/plan/__init__.py +5 -0
- crabcode_core/plan/executor.py +270 -0
- crabcode_core/plan/types.py +169 -0
- crabcode_core/prompts/__init__.py +0 -0
- crabcode_core/prompts/blocks.py +10 -0
- crabcode_core/prompts/context.py +229 -0
- crabcode_core/prompts/library.py +92 -0
- crabcode_core/prompts/profile.py +79 -0
- crabcode_core/prompts/system.py +356 -0
- crabcode_core/prompts/templates.py +104 -0
- crabcode_core/query/__init__.py +0 -0
- crabcode_core/query/computer_use_history.py +83 -0
- crabcode_core/query/loop.py +2446 -0
- crabcode_core/query/retry.py +116 -0
- crabcode_core/schedule/__init__.py +13 -0
- crabcode_core/schedule/manager.py +631 -0
- crabcode_core/schedule/models.py +204 -0
- crabcode_core/schedule/store.py +558 -0
- crabcode_core/schedule/timing.py +287 -0
- crabcode_core/session/__init__.py +12 -0
- crabcode_core/session/export.py +141 -0
- crabcode_core/session/meta_db.py +684 -0
- crabcode_core/session/storage.py +2032 -0
- crabcode_core/session/title_gen.py +52 -0
- crabcode_core/skills/__init__.py +6 -0
- crabcode_core/skills/context.py +23 -0
- crabcode_core/skills/loader.py +214 -0
- crabcode_core/skills/matcher.py +203 -0
- crabcode_core/snapshot/__init__.py +21 -0
- crabcode_core/snapshot/snapshot.py +436 -0
- crabcode_core/snapshot/tracker.py +359 -0
- crabcode_core/subprocess_utils.py +260 -0
- crabcode_core/team/__init__.py +27 -0
- crabcode_core/team/bridge.py +66 -0
- crabcode_core/team/inbox.py +237 -0
- crabcode_core/team/manager.py +1660 -0
- crabcode_core/team/message_bus.py +821 -0
- crabcode_core/team/models.py +191 -0
- crabcode_core/team/recovery.py +192 -0
- crabcode_core/text_io.py +52 -0
- crabcode_core/tools/__init__.py +112 -0
- crabcode_core/tools/_input_helpers.py +39 -0
- crabcode_core/tools/_input_schema.py +130 -0
- crabcode_core/tools/agent.py +413 -0
- crabcode_core/tools/apply_patch.py +587 -0
- crabcode_core/tools/ask_user.py +122 -0
- crabcode_core/tools/bash.py +154 -0
- crabcode_core/tools/browser.py +678 -0
- crabcode_core/tools/checklist.py +241 -0
- crabcode_core/tools/checkpoint.py +78 -0
- crabcode_core/tools/computer_use.py +599 -0
- crabcode_core/tools/diff_utils.py +123 -0
- crabcode_core/tools/file_edit.py +240 -0
- crabcode_core/tools/file_read.py +187 -0
- crabcode_core/tools/file_write.py +154 -0
- crabcode_core/tools/glob.py +123 -0
- crabcode_core/tools/goal.py +134 -0
- crabcode_core/tools/grep.py +247 -0
- crabcode_core/tools/image.py +173 -0
- crabcode_core/tools/image_generate.py +115 -0
- crabcode_core/tools/lint.py +388 -0
- crabcode_core/tools/loading.py +187 -0
- crabcode_core/tools/memory.py +456 -0
- crabcode_core/tools/monitor.py +677 -0
- crabcode_core/tools/peer.py +103 -0
- crabcode_core/tools/revert.py +90 -0
- crabcode_core/tools/schedule.py +683 -0
- crabcode_core/tools/skill.py +108 -0
- crabcode_core/tools/switch_mode.py +213 -0
- crabcode_core/tools/team.py +329 -0
- crabcode_core/tools/tool_search.py +64 -0
- crabcode_core/tools/web_search.py +444 -0
- crabcode_core/turn_details.py +64 -0
- crabcode_core/types/__init__.py +95 -0
- crabcode_core/types/config.py +434 -0
- crabcode_core/types/event.py +334 -0
- crabcode_core/types/message.py +263 -0
- crabcode_core/types/tool.py +208 -0
- crabcode_core/usage.py +289 -0
- crabcode_core/utf8_sanitize.py +29 -0
- crabcode_debugger/__init__.py +5 -0
- crabcode_debugger/adapters.py +332 -0
- crabcode_debugger/dap.py +304 -0
- crabcode_debugger/memory.py +1806 -0
- crabcode_debugger/process.py +707 -0
- crabcode_debugger/runtime.py +58 -0
- crabcode_debugger/sessions.py +387 -0
- crabcode_debugger/tool.py +731 -0
- crabcode_gateway/__init__.py +18 -0
- crabcode_gateway/acp/__init__.py +13 -0
- crabcode_gateway/acp/agent.py +1581 -0
- crabcode_gateway/acp/session.py +114 -0
- crabcode_gateway/acp/transport.py +39 -0
- crabcode_gateway/acp/types.py +90 -0
- crabcode_gateway/adapter.py +30 -0
- crabcode_gateway/auth.py +139 -0
- crabcode_gateway/computer_use.py +364 -0
- crabcode_gateway/document_engine.py +637 -0
- crabcode_gateway/document_worker.py +301 -0
- crabcode_gateway/event_bus.py +655 -0
- crabcode_gateway/grpc/__init__.py +0 -0
- crabcode_gateway/grpc/crabcode_pb2.py +70 -0
- crabcode_gateway/grpc/crabcode_pb2_grpc.py +474 -0
- crabcode_gateway/grpc/proto/crabcode.proto +160 -0
- crabcode_gateway/grpc/server.py +765 -0
- crabcode_gateway/middleware.py +344 -0
- crabcode_gateway/protocol.py +17 -0
- crabcode_gateway/routes/__init__.py +0 -0
- crabcode_gateway/routes/agent.py +392 -0
- crabcode_gateway/routes/auth.py +150 -0
- crabcode_gateway/routes/computer_use.py +101 -0
- crabcode_gateway/routes/config.py +1698 -0
- crabcode_gateway/routes/document.py +1810 -0
- crabcode_gateway/routes/event.py +3801 -0
- crabcode_gateway/routes/git_review.py +232 -0
- crabcode_gateway/routes/health.py +14 -0
- crabcode_gateway/routes/peer.py +78 -0
- crabcode_gateway/routes/permission.py +121 -0
- crabcode_gateway/routes/schedule.py +300 -0
- crabcode_gateway/routes/session.py +1496 -0
- crabcode_gateway/routes/snapshot.py +211 -0
- crabcode_gateway/routes/tasks.py +318 -0
- crabcode_gateway/routes/team.py +392 -0
- crabcode_gateway/routes/usage.py +49 -0
- crabcode_gateway/routes/workspace.py +299 -0
- crabcode_gateway/safe_file.py +63 -0
- crabcode_gateway/schemas.py +1820 -0
- crabcode_gateway/server.py +514 -0
- crabcode_gateway/session_registry.py +36 -0
- crabcode_gateway/task_registry.py +708 -0
- crabcode_gateway/turn_details.py +60 -0
- crabcode_search/__init__.py +11 -0
- crabcode_search/background.py +368 -0
- crabcode_search/chunker.py +517 -0
- crabcode_search/embedder.py +497 -0
- crabcode_search/indexer.py +215 -0
- crabcode_search/store.py +222 -0
- crabcode_search/tool.py +268 -0
|
@@ -0,0 +1,708 @@
|
|
|
1
|
+
"""Lifecycle tracking for gateway background tasks.
|
|
2
|
+
|
|
3
|
+
HTTP and WebSocket handlers deliberately return before a query/plan finishes.
|
|
4
|
+
This module gives those detached tasks an owner (session and, optionally, a
|
|
5
|
+
WebSocket connection) so lifecycle operations can cancel and await them.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import contextvars
|
|
12
|
+
import logging
|
|
13
|
+
from contextlib import asynccontextmanager
|
|
14
|
+
from collections.abc import Awaitable, Callable
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import Any, TypeVar
|
|
17
|
+
|
|
18
|
+
from crabcode_gateway.session_registry import get_session_lock
|
|
19
|
+
|
|
20
|
+
_TASKS_ATTR = "background_tasks"
|
|
21
|
+
_CLOSING_ATTR = "closing_sessions"
|
|
22
|
+
_GATEWAY_CLOSING_ATTR = "gateway_closing"
|
|
23
|
+
_MAX_CANCEL_ROUNDS = 100
|
|
24
|
+
_LEASES_ATTR = "session_leases"
|
|
25
|
+
_LEASE_EVENTS_ATTR = "session_lease_events"
|
|
26
|
+
_LEASE_TASKS_ATTR = "session_lease_tasks"
|
|
27
|
+
_OPERATION_TASKS_ATTR = "operation_tasks"
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
T = TypeVar("T")
|
|
32
|
+
|
|
33
|
+
# Async generators/stream adapters are sometimes wrapped in ``wait_for`` or
|
|
34
|
+
# another child task. Preserve the logical lease owner across those task
|
|
35
|
+
# boundaries so cleanup does not cancel the parent task that owns the lease.
|
|
36
|
+
_LEASE_OWNER: contextvars.ContextVar[
|
|
37
|
+
tuple[str, asyncio.Task[Any]] | None
|
|
38
|
+
] = contextvars.ContextVar("crabcode_lease_owner", default=None)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class SessionOperationRejected(RuntimeError):
|
|
42
|
+
"""Raised when a request races a session or gateway shutdown."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class OperationAlreadyRegistered(RuntimeError):
|
|
46
|
+
"""Raised when an active operation already owns a session/id pair."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class CancelledOperation:
|
|
51
|
+
"""A cancelled task whose operation id remains reserved for its terminal."""
|
|
52
|
+
|
|
53
|
+
task: asyncio.Task[Any]
|
|
54
|
+
claim: object
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def ensure_task_state(app_state: Any) -> None:
|
|
58
|
+
if not hasattr(app_state, _TASKS_ATTR):
|
|
59
|
+
setattr(app_state, _TASKS_ATTR, {})
|
|
60
|
+
if not hasattr(app_state, _CLOSING_ATTR):
|
|
61
|
+
setattr(app_state, _CLOSING_ATTR, set())
|
|
62
|
+
if not hasattr(app_state, _GATEWAY_CLOSING_ATTR):
|
|
63
|
+
setattr(app_state, _GATEWAY_CLOSING_ATTR, False)
|
|
64
|
+
if not hasattr(app_state, _LEASES_ATTR):
|
|
65
|
+
setattr(app_state, _LEASES_ATTR, {})
|
|
66
|
+
if not hasattr(app_state, _LEASE_EVENTS_ATTR):
|
|
67
|
+
setattr(app_state, _LEASE_EVENTS_ATTR, {})
|
|
68
|
+
if not hasattr(app_state, _LEASE_TASKS_ATTR):
|
|
69
|
+
setattr(app_state, _LEASE_TASKS_ATTR, {})
|
|
70
|
+
if not hasattr(app_state, _OPERATION_TASKS_ATTR):
|
|
71
|
+
setattr(app_state, _OPERATION_TASKS_ATTR, {})
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def operation_is_registered(
|
|
75
|
+
app_state: Any,
|
|
76
|
+
session_id: str,
|
|
77
|
+
operation_id: str,
|
|
78
|
+
) -> bool:
|
|
79
|
+
"""Return whether a live task/claim owns an operation id.
|
|
80
|
+
|
|
81
|
+
Callers that use this for admission must hold the session registry lock
|
|
82
|
+
until they register the replacement owner.
|
|
83
|
+
"""
|
|
84
|
+
ensure_task_state(app_state)
|
|
85
|
+
operations: dict[tuple[str, str], Any] = getattr(
|
|
86
|
+
app_state,
|
|
87
|
+
_OPERATION_TASKS_ATTR,
|
|
88
|
+
)
|
|
89
|
+
owner = operations.get((session_id, operation_id))
|
|
90
|
+
if isinstance(owner, asyncio.Task) and owner.done():
|
|
91
|
+
if operations.get((session_id, operation_id)) is owner:
|
|
92
|
+
operations.pop((session_id, operation_id), None)
|
|
93
|
+
return False
|
|
94
|
+
return owner is not None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def get_operation_task(
|
|
98
|
+
app_state: Any,
|
|
99
|
+
session_id: str,
|
|
100
|
+
operation_id: str,
|
|
101
|
+
*,
|
|
102
|
+
operation_scope: str | None = None,
|
|
103
|
+
) -> asyncio.Task[Any] | None:
|
|
104
|
+
"""Return the live task owning an operation, if it matches the scope.
|
|
105
|
+
|
|
106
|
+
The operation map also contains short-lived opaque claims while an
|
|
107
|
+
interrupt is publishing its terminal event. Those claims intentionally
|
|
108
|
+
do not resolve to a task: a control request must never attach to an
|
|
109
|
+
operation whose owner has already been cancelled. Callers that need a
|
|
110
|
+
race-free answer should hold ``get_session_lock`` while invoking this
|
|
111
|
+
helper.
|
|
112
|
+
"""
|
|
113
|
+
ensure_task_state(app_state)
|
|
114
|
+
operations: dict[tuple[str, str], Any] = getattr(
|
|
115
|
+
app_state,
|
|
116
|
+
_OPERATION_TASKS_ATTR,
|
|
117
|
+
)
|
|
118
|
+
key = (session_id, operation_id)
|
|
119
|
+
owner = operations.get(key)
|
|
120
|
+
if not isinstance(owner, asyncio.Task):
|
|
121
|
+
return None
|
|
122
|
+
if owner.done():
|
|
123
|
+
if operations.get(key) is owner:
|
|
124
|
+
operations.pop(key, None)
|
|
125
|
+
return None
|
|
126
|
+
if operation_scope is not None and getattr(
|
|
127
|
+
owner,
|
|
128
|
+
"_crabcode_operation_scope",
|
|
129
|
+
None,
|
|
130
|
+
) != operation_scope:
|
|
131
|
+
return None
|
|
132
|
+
return owner
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def get_active_operation(
|
|
136
|
+
app_state: Any,
|
|
137
|
+
session_id: str,
|
|
138
|
+
*,
|
|
139
|
+
operation_scope: str | None = None,
|
|
140
|
+
) -> tuple[str, asyncio.Task[Any]] | None:
|
|
141
|
+
"""Return one live operation for a session, optionally by scope.
|
|
142
|
+
|
|
143
|
+
Foreground requests are serialized by ``CoreSession``. Discovering an
|
|
144
|
+
existing task at the Gateway boundary lets callers reject a second request
|
|
145
|
+
instead of queueing another user message behind the active turn.
|
|
146
|
+
Callers should hold ``get_session_lock`` while using this helper.
|
|
147
|
+
"""
|
|
148
|
+
ensure_task_state(app_state)
|
|
149
|
+
operations: dict[tuple[str, str], Any] = getattr(
|
|
150
|
+
app_state,
|
|
151
|
+
_OPERATION_TASKS_ATTR,
|
|
152
|
+
)
|
|
153
|
+
for (candidate_session_id, operation_id), owner in list(operations.items()):
|
|
154
|
+
if candidate_session_id != session_id:
|
|
155
|
+
continue
|
|
156
|
+
if not isinstance(owner, asyncio.Task):
|
|
157
|
+
continue
|
|
158
|
+
if owner.done():
|
|
159
|
+
if operations.get((candidate_session_id, operation_id)) is owner:
|
|
160
|
+
operations.pop((candidate_session_id, operation_id), None)
|
|
161
|
+
continue
|
|
162
|
+
if operation_scope is not None and getattr(
|
|
163
|
+
owner,
|
|
164
|
+
"_crabcode_operation_scope",
|
|
165
|
+
None,
|
|
166
|
+
) != operation_scope:
|
|
167
|
+
continue
|
|
168
|
+
return operation_id, owner
|
|
169
|
+
return None
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def claim_operation(
|
|
173
|
+
app_state: Any,
|
|
174
|
+
session_id: str,
|
|
175
|
+
operation_id: str,
|
|
176
|
+
owner: object,
|
|
177
|
+
) -> None:
|
|
178
|
+
"""Reserve an operation id across an awaited admission sequence."""
|
|
179
|
+
if operation_is_registered(app_state, session_id, operation_id):
|
|
180
|
+
raise OperationAlreadyRegistered(
|
|
181
|
+
f"operation already active: {operation_id}"
|
|
182
|
+
)
|
|
183
|
+
operations: dict[tuple[str, str], Any] = getattr(
|
|
184
|
+
app_state,
|
|
185
|
+
_OPERATION_TASKS_ATTR,
|
|
186
|
+
)
|
|
187
|
+
operations[(session_id, operation_id)] = owner
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def release_operation_claim(
|
|
191
|
+
app_state: Any,
|
|
192
|
+
session_id: str,
|
|
193
|
+
operation_id: str,
|
|
194
|
+
owner: object,
|
|
195
|
+
) -> None:
|
|
196
|
+
"""Release a reservation only when it is still owned by *owner*."""
|
|
197
|
+
operations: dict[tuple[str, str], Any] = getattr(
|
|
198
|
+
app_state,
|
|
199
|
+
_OPERATION_TASKS_ATTR,
|
|
200
|
+
{},
|
|
201
|
+
)
|
|
202
|
+
key = (session_id, operation_id)
|
|
203
|
+
if operations.get(key) is owner:
|
|
204
|
+
operations.pop(key, None)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def mark_session_closing(app_state: Any, session_id: str) -> None:
|
|
208
|
+
"""Fence a session against new background work.
|
|
209
|
+
|
|
210
|
+
Callers should set this while holding the gateway session-registry lock,
|
|
211
|
+
before removing the session from the registry. Keeping the marker until
|
|
212
|
+
the CoreSession has actually closed prevents a concurrent resume/send
|
|
213
|
+
operation from installing or using the same id during cleanup.
|
|
214
|
+
"""
|
|
215
|
+
ensure_task_state(app_state)
|
|
216
|
+
getattr(app_state, _CLOSING_ATTR).add(session_id)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def unmark_session_closing(app_state: Any, session_id: str) -> None:
|
|
220
|
+
ensure_task_state(app_state)
|
|
221
|
+
getattr(app_state, _CLOSING_ATTR).discard(session_id)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def track_task(
|
|
225
|
+
app_state: Any,
|
|
226
|
+
session_id: str,
|
|
227
|
+
task: asyncio.Task[Any],
|
|
228
|
+
*,
|
|
229
|
+
owner_tasks: set[asyncio.Task[Any]] | None = None,
|
|
230
|
+
owner_sessions: dict[asyncio.Task[Any], str] | None = None,
|
|
231
|
+
operation_id: str | None = None,
|
|
232
|
+
operation_scope: str | None = None,
|
|
233
|
+
operation_claim: object | None = None,
|
|
234
|
+
) -> None:
|
|
235
|
+
"""Register a detached task and consume its terminal exception."""
|
|
236
|
+
ensure_task_state(app_state)
|
|
237
|
+
if operation_id is not None:
|
|
238
|
+
operations: dict[tuple[str, str], Any] = getattr(
|
|
239
|
+
app_state,
|
|
240
|
+
_OPERATION_TASKS_ATTR,
|
|
241
|
+
)
|
|
242
|
+
key = (session_id, operation_id)
|
|
243
|
+
previous = operations.get(key)
|
|
244
|
+
if previous is not None and previous is not task:
|
|
245
|
+
if operation_claim is None or previous is not operation_claim:
|
|
246
|
+
raise OperationAlreadyRegistered(
|
|
247
|
+
f"operation already active: {operation_id}"
|
|
248
|
+
)
|
|
249
|
+
operations[key] = task
|
|
250
|
+
setattr(task, "_crabcode_operation_id", operation_id)
|
|
251
|
+
setattr(task, "_crabcode_operation_scope", operation_scope)
|
|
252
|
+
|
|
253
|
+
if (
|
|
254
|
+
getattr(app_state, _GATEWAY_CLOSING_ATTR, False)
|
|
255
|
+
or session_id in getattr(app_state, _CLOSING_ATTR)
|
|
256
|
+
):
|
|
257
|
+
task.cancel()
|
|
258
|
+
else:
|
|
259
|
+
tasks: dict[str, set[asyncio.Task[Any]]] = getattr(app_state, _TASKS_ATTR)
|
|
260
|
+
tasks.setdefault(session_id, set()).add(task)
|
|
261
|
+
|
|
262
|
+
if owner_tasks is not None:
|
|
263
|
+
owner_tasks.add(task)
|
|
264
|
+
if owner_sessions is not None:
|
|
265
|
+
owner_sessions[task] = session_id
|
|
266
|
+
|
|
267
|
+
def _done(done: asyncio.Task[Any]) -> None:
|
|
268
|
+
tasks = getattr(app_state, _TASKS_ATTR, {})
|
|
269
|
+
session_tasks = tasks.get(session_id)
|
|
270
|
+
if session_tasks is not None:
|
|
271
|
+
session_tasks.discard(done)
|
|
272
|
+
if not session_tasks:
|
|
273
|
+
tasks.pop(session_id, None)
|
|
274
|
+
if owner_tasks is not None:
|
|
275
|
+
owner_tasks.discard(done)
|
|
276
|
+
if owner_sessions is not None:
|
|
277
|
+
owner_sessions.pop(done, None)
|
|
278
|
+
if operation_id is not None:
|
|
279
|
+
operations = getattr(app_state, _OPERATION_TASKS_ATTR, {})
|
|
280
|
+
key = (session_id, operation_id)
|
|
281
|
+
if operations.get(key) is done:
|
|
282
|
+
operations.pop(key, None)
|
|
283
|
+
try:
|
|
284
|
+
done.result()
|
|
285
|
+
except asyncio.CancelledError:
|
|
286
|
+
pass
|
|
287
|
+
except Exception:
|
|
288
|
+
# The task's owner is responsible for publishing a user-facing
|
|
289
|
+
# error. Drain the exception here to avoid "Task exception was
|
|
290
|
+
# never retrieved" warnings during shutdown.
|
|
291
|
+
pass
|
|
292
|
+
|
|
293
|
+
task.add_done_callback(_done)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
async def cancel_operation_task(
|
|
297
|
+
app_state: Any,
|
|
298
|
+
session_id: str,
|
|
299
|
+
operation_id: str,
|
|
300
|
+
*,
|
|
301
|
+
expected_session: object | None = None,
|
|
302
|
+
) -> CancelledOperation | None:
|
|
303
|
+
"""Cancel one operation and reserve its id until the caller emits terminal.
|
|
304
|
+
|
|
305
|
+
Replacing the task with an opaque claim under the registry lock also makes
|
|
306
|
+
concurrent interrupts single-owner: only the first caller can cancel the
|
|
307
|
+
task and publish its terminal event. Callers must release ``claim`` after
|
|
308
|
+
terminal publication with :func:`release_operation_claim`.
|
|
309
|
+
"""
|
|
310
|
+
async with get_session_lock(app_state):
|
|
311
|
+
ensure_task_state(app_state)
|
|
312
|
+
if expected_session is not None and (
|
|
313
|
+
getattr(app_state, "sessions", {}).get(session_id) is not expected_session
|
|
314
|
+
or getattr(app_state, _GATEWAY_CLOSING_ATTR, False)
|
|
315
|
+
or session_id in getattr(app_state, _CLOSING_ATTR, set())
|
|
316
|
+
):
|
|
317
|
+
return None
|
|
318
|
+
operations: dict[tuple[str, str], Any] = getattr(
|
|
319
|
+
app_state,
|
|
320
|
+
_OPERATION_TASKS_ATTR,
|
|
321
|
+
)
|
|
322
|
+
owner = operations.get((session_id, operation_id))
|
|
323
|
+
if not isinstance(owner, asyncio.Task) or owner.done():
|
|
324
|
+
return None
|
|
325
|
+
claim = object()
|
|
326
|
+
operations[(session_id, operation_id)] = claim
|
|
327
|
+
owner.cancel()
|
|
328
|
+
task = owner
|
|
329
|
+
|
|
330
|
+
# Shield the operation task from cancellation of the interrupt request.
|
|
331
|
+
# The cancelled operation may have asynchronous cleanup of its own, and a
|
|
332
|
+
# second cancellation must not abandon that cleanup or strand the claim.
|
|
333
|
+
caller = asyncio.current_task()
|
|
334
|
+
caller_cancelled = False
|
|
335
|
+
while not task.done():
|
|
336
|
+
try:
|
|
337
|
+
await asyncio.shield(task)
|
|
338
|
+
except asyncio.CancelledError:
|
|
339
|
+
if caller is not None and caller.cancelling():
|
|
340
|
+
caller_cancelled = True
|
|
341
|
+
except Exception:
|
|
342
|
+
# The operation's detached-task callback is responsible for
|
|
343
|
+
# reporting/draining its error. Cancellation still owns the claim.
|
|
344
|
+
break
|
|
345
|
+
try:
|
|
346
|
+
task.result()
|
|
347
|
+
except (asyncio.CancelledError, Exception):
|
|
348
|
+
pass
|
|
349
|
+
if caller_cancelled:
|
|
350
|
+
release_operation_claim(
|
|
351
|
+
app_state,
|
|
352
|
+
session_id,
|
|
353
|
+
operation_id,
|
|
354
|
+
claim,
|
|
355
|
+
)
|
|
356
|
+
raise asyncio.CancelledError
|
|
357
|
+
return CancelledOperation(task=task, claim=claim)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
async def cancel_tasks(
|
|
361
|
+
tasks: list[asyncio.Task[Any]] | set[asyncio.Task[Any]],
|
|
362
|
+
*,
|
|
363
|
+
exclude_tasks: set[asyncio.Task[Any]] | None = None,
|
|
364
|
+
) -> None:
|
|
365
|
+
excluded = set(exclude_tasks or ())
|
|
366
|
+
current = asyncio.current_task()
|
|
367
|
+
if current is not None:
|
|
368
|
+
excluded.add(current)
|
|
369
|
+
pending = [task for task in tasks if task not in excluded and not task.done()]
|
|
370
|
+
for task in pending:
|
|
371
|
+
task.cancel()
|
|
372
|
+
if pending:
|
|
373
|
+
await asyncio.gather(*pending, return_exceptions=True)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
async def cancel_session_tasks(
|
|
377
|
+
app_state: Any,
|
|
378
|
+
session_id: str,
|
|
379
|
+
*,
|
|
380
|
+
keep_closing: bool = False,
|
|
381
|
+
protected_tasks: set[asyncio.Task[Any]] | None = None,
|
|
382
|
+
) -> None:
|
|
383
|
+
"""Mark a session closing and await all currently registered tasks."""
|
|
384
|
+
ensure_task_state(app_state)
|
|
385
|
+
closing: set[str] = getattr(app_state, _CLOSING_ATTR)
|
|
386
|
+
was_closing = session_id in closing
|
|
387
|
+
closing.add(session_id)
|
|
388
|
+
tasks_by_session: dict[str, set[asyncio.Task[Any]]] = getattr(
|
|
389
|
+
app_state, _TASKS_ATTR
|
|
390
|
+
)
|
|
391
|
+
protected = set(protected_tasks or ())
|
|
392
|
+
current = asyncio.current_task()
|
|
393
|
+
if current is not None:
|
|
394
|
+
protected.add(current)
|
|
395
|
+
try:
|
|
396
|
+
# A task may create a child in a cancellation/finally callback. Keep
|
|
397
|
+
# draining until the registry is empty; the closing marker makes every
|
|
398
|
+
# such late registration immediately cancellable. The bound is only a
|
|
399
|
+
# guard against a broken task that continuously manufactures children.
|
|
400
|
+
for _ in range(_MAX_CANCEL_ROUNDS):
|
|
401
|
+
tasks = list(tasks_by_session.get(session_id, set()))
|
|
402
|
+
if not tasks:
|
|
403
|
+
break
|
|
404
|
+
if tasks and all(task in protected for task in tasks):
|
|
405
|
+
# A session task may defensively invoke its own cleanup. It
|
|
406
|
+
# cannot cancel itself; leave removal to its done callback and
|
|
407
|
+
# avoid spinning through the cancellation-round guard.
|
|
408
|
+
break
|
|
409
|
+
await cancel_tasks(tasks, exclude_tasks=protected)
|
|
410
|
+
# Give done callbacks a scheduling turn before taking the next
|
|
411
|
+
# snapshot. This is important when a task was already complete
|
|
412
|
+
# when it entered ``cancel_tasks``.
|
|
413
|
+
await asyncio.sleep(0)
|
|
414
|
+
if not tasks_by_session.get(session_id):
|
|
415
|
+
break
|
|
416
|
+
else:
|
|
417
|
+
remaining = len(tasks_by_session.get(session_id, set()))
|
|
418
|
+
if remaining:
|
|
419
|
+
logger.warning(
|
|
420
|
+
"Session %s still has %d background task(s) after %d cancellation rounds",
|
|
421
|
+
session_id,
|
|
422
|
+
remaining,
|
|
423
|
+
_MAX_CANCEL_ROUNDS,
|
|
424
|
+
)
|
|
425
|
+
finally:
|
|
426
|
+
operations: dict[tuple[str, str], Any] = getattr(
|
|
427
|
+
app_state,
|
|
428
|
+
_OPERATION_TASKS_ATTR,
|
|
429
|
+
{},
|
|
430
|
+
)
|
|
431
|
+
for key in [key for key in operations if key[0] == session_id]:
|
|
432
|
+
operations.pop(key, None)
|
|
433
|
+
if not keep_closing and not was_closing:
|
|
434
|
+
closing.discard(session_id)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
async def cleanup_session(
|
|
438
|
+
app_state: Any,
|
|
439
|
+
session_id: str,
|
|
440
|
+
session: Any,
|
|
441
|
+
*,
|
|
442
|
+
protected_task: asyncio.Task[Any] | None = None,
|
|
443
|
+
owns_registry: bool = True,
|
|
444
|
+
) -> None:
|
|
445
|
+
"""Cancel owned work, close a CoreSession, then release its fence.
|
|
446
|
+
|
|
447
|
+
The marker intentionally spans both task cancellation and ``session.close``
|
|
448
|
+
so a resume request cannot recreate the same id while the old object's
|
|
449
|
+
resources are still being released. This helper is idempotent with
|
|
450
|
+
respect to CoreSession.close().
|
|
451
|
+
"""
|
|
452
|
+
# A loaded resume candidate can lose an install race to an already
|
|
453
|
+
# registered CoreSession with the same id. Such a candidate must be
|
|
454
|
+
# closed without fencing/cancelling the winner's tasks.
|
|
455
|
+
if not owns_registry:
|
|
456
|
+
await session.close()
|
|
457
|
+
return
|
|
458
|
+
|
|
459
|
+
mark_session_closing(app_state, session_id)
|
|
460
|
+
try:
|
|
461
|
+
initiator = protected_task or asyncio.current_task()
|
|
462
|
+
protected = {initiator} if initiator is not None else None
|
|
463
|
+
await cancel_session_tasks(
|
|
464
|
+
app_state,
|
|
465
|
+
session_id,
|
|
466
|
+
keep_closing=True,
|
|
467
|
+
protected_tasks=protected,
|
|
468
|
+
)
|
|
469
|
+
# ``cancel_session_tasks`` deliberately leaves the current task
|
|
470
|
+
# alive. When cleanup is initiated from inside a streaming lease,
|
|
471
|
+
# waiting for all leases here would wait for this task's own finally
|
|
472
|
+
# block, which cannot run until cleanup returns. Every *other* lease
|
|
473
|
+
# is registered as a session task and has already been cancelled and
|
|
474
|
+
# drained above, so only wait when the current task is not the owner.
|
|
475
|
+
if not await _task_owns_session_lease(app_state, session_id, initiator):
|
|
476
|
+
await wait_session_leases(app_state, session_id)
|
|
477
|
+
await session.close()
|
|
478
|
+
finally:
|
|
479
|
+
unmark_session_closing(app_state, session_id)
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
async def shielded_cleanup_session(
|
|
483
|
+
app_state: Any,
|
|
484
|
+
session_id: str,
|
|
485
|
+
session: Any,
|
|
486
|
+
*,
|
|
487
|
+
owns_registry: bool = True,
|
|
488
|
+
) -> None:
|
|
489
|
+
"""Run session cleanup to completion even if its caller is cancelled."""
|
|
490
|
+
logical_owner = _LEASE_OWNER.get()
|
|
491
|
+
initiator = (
|
|
492
|
+
logical_owner[1]
|
|
493
|
+
if logical_owner is not None and logical_owner[0] == session_id
|
|
494
|
+
else asyncio.current_task()
|
|
495
|
+
)
|
|
496
|
+
cleanup_task = asyncio.create_task(
|
|
497
|
+
cleanup_session(
|
|
498
|
+
app_state,
|
|
499
|
+
session_id,
|
|
500
|
+
session,
|
|
501
|
+
protected_task=initiator,
|
|
502
|
+
owns_registry=owns_registry,
|
|
503
|
+
)
|
|
504
|
+
)
|
|
505
|
+
cancelled = False
|
|
506
|
+
try:
|
|
507
|
+
# ``shield`` keeps the child alive but immediately raises in the
|
|
508
|
+
# caller. A shutdown task can itself receive more than one cancel
|
|
509
|
+
# request while cleanup is draining; keep waiting through every such
|
|
510
|
+
# interruption so the CoreSession and task registry are not abandoned.
|
|
511
|
+
while not cleanup_task.done():
|
|
512
|
+
try:
|
|
513
|
+
await asyncio.shield(cleanup_task)
|
|
514
|
+
except asyncio.CancelledError:
|
|
515
|
+
cancelled = True
|
|
516
|
+
|
|
517
|
+
# Retrieve the child's result/exception after it has reached a
|
|
518
|
+
# terminal state. This also distinguishes an externally cancelled
|
|
519
|
+
# child from cancellation delivered to this waiter.
|
|
520
|
+
await cleanup_task
|
|
521
|
+
except asyncio.CancelledError:
|
|
522
|
+
# If the cleanup child itself was cancelled, preserve that terminal
|
|
523
|
+
# state. Otherwise this is the caller's cancellation, which is
|
|
524
|
+
# re-raised only after the child has been fully drained.
|
|
525
|
+
cancelled = True
|
|
526
|
+
if cancelled:
|
|
527
|
+
raise asyncio.CancelledError
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
async def cancel_owner_tasks(
|
|
531
|
+
owner_tasks: set[asyncio.Task[Any]],
|
|
532
|
+
) -> None:
|
|
533
|
+
# Owner tasks can schedule another task from a cancellation callback. A
|
|
534
|
+
# bounded drain keeps WebSocket disconnect cleanup deterministic without
|
|
535
|
+
# allowing an ill-behaved callback to hang shutdown forever.
|
|
536
|
+
for _ in range(_MAX_CANCEL_ROUNDS):
|
|
537
|
+
tasks = list(owner_tasks)
|
|
538
|
+
if not tasks:
|
|
539
|
+
return
|
|
540
|
+
await cancel_tasks(tasks)
|
|
541
|
+
await asyncio.sleep(0)
|
|
542
|
+
if owner_tasks:
|
|
543
|
+
logger.warning(
|
|
544
|
+
"WebSocket owner still has %d background task(s) after cancellation",
|
|
545
|
+
len(owner_tasks),
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
async def wait_session_leases(app_state: Any, session_id: str) -> None:
|
|
550
|
+
"""Wait for in-flight streaming handlers admitted for a session."""
|
|
551
|
+
ensure_task_state(app_state)
|
|
552
|
+
events: dict[str, asyncio.Event] = getattr(app_state, _LEASE_EVENTS_ATTR)
|
|
553
|
+
event = events.get(session_id)
|
|
554
|
+
if event is not None:
|
|
555
|
+
await event.wait()
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
async def _task_owns_session_lease(
|
|
559
|
+
app_state: Any,
|
|
560
|
+
session_id: str,
|
|
561
|
+
task: asyncio.Task[Any] | None = None,
|
|
562
|
+
) -> bool:
|
|
563
|
+
"""Return whether *task* owns this session's streaming lease."""
|
|
564
|
+
owner = task if task is not None else asyncio.current_task()
|
|
565
|
+
if owner is None:
|
|
566
|
+
return False
|
|
567
|
+
async with get_session_lock(app_state):
|
|
568
|
+
ensure_task_state(app_state)
|
|
569
|
+
owners: dict[str, set[asyncio.Task[Any]]] = getattr(
|
|
570
|
+
app_state,
|
|
571
|
+
_LEASE_TASKS_ATTR,
|
|
572
|
+
{},
|
|
573
|
+
)
|
|
574
|
+
return owner in owners.get(session_id, set())
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
@asynccontextmanager
|
|
578
|
+
async def session_lease(app_state: Any, session: Any):
|
|
579
|
+
"""Admit a long-lived operation until it leaves the session.
|
|
580
|
+
|
|
581
|
+
Unlike ``run_session_operation`` this does not create a child task, so an
|
|
582
|
+
async generator can yield incrementally. Cleanup marks the session as
|
|
583
|
+
closing before waiting on this lease, which closes the resolve/close race.
|
|
584
|
+
"""
|
|
585
|
+
session_id = getattr(session, "session_id", None)
|
|
586
|
+
if not session_id:
|
|
587
|
+
raise SessionOperationRejected("session has no id")
|
|
588
|
+
async with get_session_lock(app_state):
|
|
589
|
+
ensure_task_state(app_state)
|
|
590
|
+
if (
|
|
591
|
+
getattr(app_state, _GATEWAY_CLOSING_ATTR, False)
|
|
592
|
+
or session_id in getattr(app_state, _CLOSING_ATTR)
|
|
593
|
+
or getattr(app_state, "sessions", {}).get(session_id) is not session
|
|
594
|
+
):
|
|
595
|
+
raise SessionOperationRejected("session is closing")
|
|
596
|
+
leases: dict[str, int] = getattr(app_state, _LEASES_ATTR)
|
|
597
|
+
events: dict[str, asyncio.Event] = getattr(app_state, _LEASE_EVENTS_ATTR)
|
|
598
|
+
lease_tasks: dict[str, set[asyncio.Task[Any]]] = getattr(
|
|
599
|
+
app_state,
|
|
600
|
+
_LEASE_TASKS_ATTR,
|
|
601
|
+
)
|
|
602
|
+
leases[session_id] = leases.get(session_id, 0) + 1
|
|
603
|
+
events.setdefault(session_id, asyncio.Event()).clear()
|
|
604
|
+
# Keep the owning request visible to shutdown. A stalled stream must
|
|
605
|
+
# be cancelled before cleanup waits for its lease to drain.
|
|
606
|
+
current = asyncio.current_task()
|
|
607
|
+
owner_token = None
|
|
608
|
+
if current is not None:
|
|
609
|
+
getattr(app_state, _TASKS_ATTR).setdefault(session_id, set()).add(current)
|
|
610
|
+
lease_tasks.setdefault(session_id, set()).add(current)
|
|
611
|
+
owner_token = _LEASE_OWNER.set((session_id, current))
|
|
612
|
+
try:
|
|
613
|
+
yield
|
|
614
|
+
finally:
|
|
615
|
+
if owner_token is not None:
|
|
616
|
+
_LEASE_OWNER.reset(owner_token)
|
|
617
|
+
async with get_session_lock(app_state):
|
|
618
|
+
leases = getattr(app_state, _LEASES_ATTR, {})
|
|
619
|
+
events = getattr(app_state, _LEASE_EVENTS_ATTR, {})
|
|
620
|
+
lease_tasks = getattr(app_state, _LEASE_TASKS_ATTR, {})
|
|
621
|
+
tasks_by_session = getattr(app_state, _TASKS_ATTR, {})
|
|
622
|
+
current = asyncio.current_task()
|
|
623
|
+
if current is not None:
|
|
624
|
+
session_tasks = tasks_by_session.get(session_id)
|
|
625
|
+
if session_tasks is not None:
|
|
626
|
+
session_tasks.discard(current)
|
|
627
|
+
if not session_tasks:
|
|
628
|
+
tasks_by_session.pop(session_id, None)
|
|
629
|
+
if current is not None:
|
|
630
|
+
owners = lease_tasks.get(session_id)
|
|
631
|
+
if owners is not None:
|
|
632
|
+
owners.discard(current)
|
|
633
|
+
if not owners:
|
|
634
|
+
lease_tasks.pop(session_id, None)
|
|
635
|
+
remaining = leases.get(session_id, 0) - 1
|
|
636
|
+
if remaining <= 0:
|
|
637
|
+
leases.pop(session_id, None)
|
|
638
|
+
event = events.pop(session_id, None)
|
|
639
|
+
if event is not None:
|
|
640
|
+
event.set()
|
|
641
|
+
else:
|
|
642
|
+
leases[session_id] = remaining
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
async def run_session_operation(
|
|
646
|
+
app_state: Any,
|
|
647
|
+
session: Any,
|
|
648
|
+
operation: Callable[[], Awaitable[T]],
|
|
649
|
+
*,
|
|
650
|
+
owner_tasks: set[asyncio.Task[Any]] | None = None,
|
|
651
|
+
owner_sessions: dict[asyncio.Task[Any], str] | None = None,
|
|
652
|
+
) -> T:
|
|
653
|
+
"""Run one session operation with lifecycle admission fencing.
|
|
654
|
+
|
|
655
|
+
Route handlers often resolve a ``CoreSession`` and then await into it. A
|
|
656
|
+
concurrent archive/stop can remove and close that object in the gap. The
|
|
657
|
+
operation is therefore admitted and registered as a session-owned task
|
|
658
|
+
while holding the registry lock. Cleanup marks the session as closing,
|
|
659
|
+
cancels all registered work, and waits for it before closing resources.
|
|
660
|
+
|
|
661
|
+
The caller's cancellation is propagated after the owned operation is
|
|
662
|
+
settled. If cleanup cancelled the operation, expose a stable rejection
|
|
663
|
+
error instead of leaking ``CancelledError`` through an unrelated HTTP/WS
|
|
664
|
+
handler.
|
|
665
|
+
"""
|
|
666
|
+
session_id = getattr(session, "session_id", None)
|
|
667
|
+
if not session_id:
|
|
668
|
+
raise SessionOperationRejected("session has no id")
|
|
669
|
+
|
|
670
|
+
async with get_session_lock(app_state):
|
|
671
|
+
ensure_task_state(app_state)
|
|
672
|
+
if (
|
|
673
|
+
getattr(app_state, _GATEWAY_CLOSING_ATTR, False)
|
|
674
|
+
or session_id in getattr(app_state, _CLOSING_ATTR)
|
|
675
|
+
or getattr(app_state, "sessions", {}).get(session_id) is not session
|
|
676
|
+
):
|
|
677
|
+
raise SessionOperationRejected("session is closing")
|
|
678
|
+
task: asyncio.Task[T] = asyncio.create_task(operation())
|
|
679
|
+
track_task(
|
|
680
|
+
app_state,
|
|
681
|
+
session_id,
|
|
682
|
+
task,
|
|
683
|
+
owner_tasks=owner_tasks,
|
|
684
|
+
owner_sessions=owner_sessions,
|
|
685
|
+
)
|
|
686
|
+
|
|
687
|
+
try:
|
|
688
|
+
# Shield lets us distinguish a shutdown cancellation (the owned task
|
|
689
|
+
# itself is cancelled) from a client disconnect (the waiter is
|
|
690
|
+
# cancelled while the operation remains alive).
|
|
691
|
+
return await asyncio.shield(task)
|
|
692
|
+
except asyncio.CancelledError:
|
|
693
|
+
if task.done() and task.cancelled():
|
|
694
|
+
if (
|
|
695
|
+
getattr(app_state, _GATEWAY_CLOSING_ATTR, False)
|
|
696
|
+
or session_id in getattr(app_state, _CLOSING_ATTR, set())
|
|
697
|
+
or getattr(app_state, "sessions", {}).get(session_id) is not session
|
|
698
|
+
):
|
|
699
|
+
raise SessionOperationRejected("session is closing")
|
|
700
|
+
raise
|
|
701
|
+
|
|
702
|
+
# The caller was cancelled (for example a disconnected HTTP client).
|
|
703
|
+
# Do not leave an operation running against a session whose owner has
|
|
704
|
+
# gone away; cancel and drain it before propagating cancellation.
|
|
705
|
+
if not task.done():
|
|
706
|
+
task.cancel()
|
|
707
|
+
await asyncio.gather(task, return_exceptions=True)
|
|
708
|
+
raise
|