[Bugfix] Compensate cancelled duplex open, append and resume; fix serving README drift (#7636 Issues 1, 2, 3, 21) (#7785)

Signed-off-by: chickeyton <ngton2014@gmail.com>
Signed-off-by: Yueqian Lin <70319226+linyueqian@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Yueqian Lin <70319226+linyueqian@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-09-24 19:23:08 -07:00
1 parent 551a03102f
commit 9e7e3a802c
19 files changed
+1697 -96

No files matched your search

+71 -15
View File
@@ -523,32 +523,74 @@ class DuplexSessionManager:
# and the stage resource straight back.
self._abandoned_opens.discard(session_id)
await self._close_runner(runner, kind="close", reason="open_abandoned")
except asyncio.CancelledError:
# A cancel (engine teardown, task cancellation) while awaiting the
# plugin or the result sink is not an ``Exception``: without this
# branch the runner stayed in ``runners`` with its Stage0
# reservation, and since admission counts runners, every cancelled
# open burned one slot for good. Nobody is answered: the caller's
# RPC waiter is being torn down with us or times out.
logger.info("open_duplex_session cancelled for %s; rolling the admission back", session_id)
await self._rollback_open(session_id, session=session, runner=runner)
raise
except Exception as exc:
error_code, _, _ = self._control_error(exc)
if error_code in {"resource_exhausted", "session_exists"}:
logger.info("open_duplex_session rejected: %s", exc)
else:
logger.exception("open_duplex_session failed: %s", exc)
if runner is not None and self.runners.get(session_id) is runner:
self.runners.pop(session_id, None)
try:
await runner.shutdown()
except Exception:
logger.exception("duplex open rollback: runner shutdown failed for %s", session_id)
if session is not None:
reserved = session.release_all_requests()
if reserved:
try:
await self.stage_port.cleanup(list(reserved))
except Exception:
logger.warning("duplex open rollback: request cleanup pending for %s", session_id)
self._unregister_session_requests(session_id)
await self._rollback_open(session_id, session=session, runner=runner)
await self._put_result(message, operation="open", ok=False, error=exc)
finally:
if holds_admission_slot:
self._admitting.discard(session_id)
self._abandoned_opens.discard(session_id)
async def _rollback_open(
self,
session_id: str,
*,
session: DuplexEngineSession | None,
runner: DuplexSessionRunner | None,
) -> None:
"""Undo whatever an open that will not complete already did.
The bookkeeping that decides admission (``runners`` and the session's
request reservations) is undone synchronously first, so a second
cancellation landing in the awaits below cannot leave the slot burned.
The Stage0 request ids are recorded as a pending request cleanup
before anything is awaited: if the runner shutdown or the cleanup
itself is cancelled or fails, the reaper retries the cleanup instead
of the orchestrator keeping the request state forever.
"""
owned_runner = runner is not None and self.runners.get(session_id) is runner
if owned_runner:
self.runners.pop(session_id, None)
pending: _PendingRequestCleanup | None = None
key: tuple[str, int] | None = None
if session is not None:
reserved = tuple(session.release_all_requests())
self._unregister_session_requests(session_id)
if reserved:
key = (session_id, session.lease_generation)
pending = _PendingRequestCleanup(
session_id=session_id,
lease_generation=session.lease_generation,
request_ids=reserved,
abort=False,
)
self._pending_request_cleanups[key] = pending
if owned_runner and runner is not None:
try:
await runner.shutdown()
except Exception:
logger.exception("duplex open rollback: runner shutdown failed for %s", session_id)
if pending is not None and key is not None:
try:
await self._complete_request_cleanup(key, pending)
except Exception:
logger.warning("duplex open rollback: request cleanup pending for %s; the reaper retries", session_id)
def _require_runner(self, session_id: str) -> DuplexSessionRunner:
runner = self.runners.get(session_id)
if runner is None:
@@ -668,7 +710,13 @@ class DuplexSessionManager:
runner = self._require_runner(message.session_id)
session = runner.session
try:
session.resume_lease(expected_lease_generation=message.expected_lease_generation)
# Keyed by the control id, so a caller that lost the answer
# (waiter cancelled or timed out) can replay the same resume
# to learn whether it landed instead of resuming twice.
session.resume_lease(
expected_lease_generation=message.expected_lease_generation,
control_id=message.control_id,
)
except ValueError as exc:
raise DuplexSessionError(str(exc), code="session_resume_conflict") from exc
await self._put_result(message, operation="resume", ok=True, session=session)
@@ -683,6 +731,14 @@ class DuplexSessionManager:
session = runner.session
activity = DuplexLeaseActivity(message.activity)
if activity is DuplexLeaseActivity.DETACH:
expected = message.expected_lease_generation
if expected is not None and expected != session.lease_generation:
# The caller is giving up a lease it no longer holds: a
# later resume owns the current one and must keep it.
raise DuplexSessionError(
f"duplex lease generation mismatch: expected {session.lease_generation}, got {expected}",
code="session_resume_conflict",
)
session.detach_lease()
else:
session.touch_lease(activity)