Skip to content

src/genro_asgi_multiworker_spa/orchestration/spa_worker.py

Source from this local checkout, regenerated when the reader rebuilds. Line links use #L<number>; a GitHub line range opens its first line.

1 # Copyright 2025 Softwell S.r.l.2 #3 # Licensed under the Apache License, Version 2.0 (the "License");4 # you may not use this file except in compliance with the License.5 # You may obtain a copy of the License at6 #7 #     https://www.apache.org/licenses/LICENSE-2.08 #9 # Unless required by applicable law or agreed to in writing, software10 # distributed under the License is distributed on an "AS IS" BASIS,11 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.12 # See the License for the specific language governing permissions and13 # limitations under the License.14 15 """SpaWorker: the three registers of one process, and the row a request lands on.16 17 A worker holds the users it was given, and for each of them the connections and18 the pages under them. That picture lives in three registers — ``user_register``,19 ``connection_register``, ``page_register`` — and one entry of a register is a20 **register item**: the same word the deposit files under, so the thing in memory21 and the thing on disk are never two words for one object.22 23 **The registers are the shared registry's.** They are built by24 ``build_registry`` — the seam a consumer replaces to pair its own row types with25 the tree — and the three names above are properties onto its ``user_items`` /26 ``connection_items`` / ``page_items``. So a row is born with the whole data27 plane already on it (the live store, and whatever a consumer's row class28 adds) and the worker's own fields — ``state``, the29 transfer flag, the three clocks — ride that same row: one object, whichever30 half of the machine is reading it. Reading goes through the register idioms31 (``get``, ``keys``, ``keys_by``, ``in``); writing stays where it already was, in32 the single-writer mutators, which speak the registry's own lifecycle vocabulary.33 34 **The tree lives in the items.** A page belongs to a CONNECTION and a connection35 to a USER. Downwards the edge is a set the parent carries (a user item's36 ``connections``, a connection item's ``pages``); upwards it is the parent key the37 child already holds (a connection item's ``user``, a page item's38 ``connection_id``). Both directions are written in the same gesture by ONE39 mutator, so they cannot disagree, and a page stores no user label at all: the40 owner is derived by walking up, and what is derived cannot go stale.41 42 **The unified row.** A request for a user finds its row ``active`` and is served;43 finds it ``frozen`` and the store is pulled back from the deposit first; finds no44 row at all and one is added ``frozen``, then pulled the same way. The arrival of45 somebody nobody has ever seen and the waking of a hibernated user are the SAME46 line of code — the pull simply finds nothing for the first and a parcel for the47 second. The three states are derived from what the worker holds, never oracle48 booleans somebody has to remember to set.49 50 **One trip to the freezer, and the sisters wait for it.** A page fires dozens of51 calls at once, so a burst on a frozen user arrives as many coroutines together.52 The FIRST marks the row ``unfreezing`` before it reads anything, and that mark is53 what the others find: they await the transition — never the service, which stays54 parallel per user — and then all go on together. So the disk is read once, by55 one call, however wide the burst. A pull that fails takes the row away instead56 of leaving it half-born: the parcel is still on disk and the mark still on at57 the vertex, so the next request of his carries the verdict again and the trip58 is retried by the unified row's own shape, with nothing to reconcile.59 60 **What may be adopted, and what may not.** The user store comes home ONLY when61 the envelope authorises it: the Commander, routing the request, attaches its own62 verdict under ``user_frozen``, and without that verdict the parcel on disk is63 residue and is never touched — the sweep's business, not the worker's. A64 CONNECTION needs no verdict: a worker that does not hold the connection a request65 names looks in the user's own folder by itself — found, it installs the66 connection and its pages and serves; not found, it starts that connection empty.67 One code path for both, which is why the stranger needs no special treatment.68 69 **Adoption reads, empties, then announces.** Read the parcel, delete the file —70 and the folder with it when that file was the last thing in it, so the deposit71 holds the frozen and nothing else — and only then announce. The user store72 announces ``user_adopted``, which is what turns the sleeping mark off at the73 fold. An adopted CONNECTION announces nothing of its own: it is born through the74 ordinary mutators and emits the ordinary ``new_connection``/``new_page`` — one75 birth path in the machine, not two.76 77 **Announcements ride the reply of the CALL that caused them — two channels,78 never one queue** (owner, 2026-09-04; #60). Every mutation queues its protocol79 name in the ``worker_events`` of the ``RequestSlot`` of the CALL being served:80 each CALL is served on a task of its own, the stitching runs on the pool under a81 copy of that task's context, and ``send_reply`` sends that slot's events and no82 other's, then closes the slot. The names are the inherited83 ``new_user``/``new_connection``/``new_page`` and84 ``drop_user``/``drop_connection``/``drop_connections``/``drop_page``/85 ``drop_pages``, plus ``user_adopted``, ``user_frozen``,86 ``connection_user_changed``, ``user_rows_released``. A cascade speaks the87 plural: dropping a connection announces its pages as one ``drop_pages``, dropping88 a user its connections as one ``drop_connections``. Outside any CALL there is no89 slot and a mutation raises. The ONE producer that answers no CALL — the transfer90 cycle, which is the quit's — gives each departure a slot of its own and sends91 what it announced with ONE CALL of this worker's, ``/group/announce``, folded at92 the vertex exactly as a reply's envelope is; the REPLY is the acknowledgement, and a93 vertex that does not answer loses that announcement, counted and logged, never94 retried.95 96 **A drop asks for absence.** Dropping something already gone is that same97 outcome — no error, and nothing announced, because nothing happened.98 99 **Three clocks, one climb.** ``last_refresh_ts`` is technical contact and every100 call stamps it, the beat included; ``last_user_ts`` is a real human event and is101 the prince; ``last_rpc_ts`` is a real call, the surrogate metre until the page102 protocol carries the human event of its own. Whoever judges idleness or expiry103 reads the real clocks and never ``last_refresh_ts``, which a beat alone can keep104 warm forever. A stamp climbs the chain — page, its connection, its user — with an105 instant the server takes itself: a client cannot buy immortality by claiming106 activity.107 108 **One lock.** Every mutation is serialized on ``dispatch_lock``; nothing awaits109 while holding it. Finer grain was measured and refused: at a couple of kilobytes110 per user, reading and unpickling a parcel costs microseconds.111 112 **Leaving is the mirror of arriving.** ``freeze_user`` writes what the adoption113 reads back — the store under the user, one parcel per connection carrying that114 connection and its pages — under the folder semaphore, which is the deposit's115 only coherence mechanism, and then says ``user_frozen``. WHERE he wakes is not116 this worker's business: the worker event keeps a ``placement`` slot for the117 vertex that will decide it and this worker never fills it, so every departure118 leaves with the placement to be assigned and the row leaves memory WHOLE. No119 drop is announced beside it: the freeze worker event already told the story, and120 the wake tells it back through the ordinary births. A write that fails aborts121 the departure whole — the semaphore goes back, the user stays alive exactly122 where he is, nothing is announced, and the failure is logged and counted. Nobody123 here kills what could not be saved.124 125 **The semaphore is waited for, never forever.** A folder somebody else holds is126 waited on with a coroutine, and the first miss says out loud whose it is; past127 ``DEPOSIT_LOCK_WAIT_LIMIT`` the wait gives up rather than hang silently — an128 adoption raises, and the caller's own REPLY carries the failure; a freeze takes129 the shape of any other refused departure. That bound is a floor against a130 semaphore nobody gives back, not a budget: how long a REQUEST may wait before131 the vertex answers it something else is the Commander's parking budget, and132 arrives with the fold.133 134 **Nothing is parked while a call of its user runs.** Every call opens under its135 user and closes there (``open_request`` / ``close_request``, WSGI stitching136 included); a freeze happens only at empty pendings, because a store photographed137 with live calls inside would take their work nowhere while the browser was told138 it was done. The question is asked ONCE, at the door: what holds that window139 shut is the BLOCK whoever orders the departure raises at the vertex before140 ordering it, so nothing of his can be born while his parcels are written and no141 check under the semaphore would have anything left to decide. The end of a call142 is therefore where a143 departure that had to wait for it happens — one mechanism, whether the worker is144 being emptied or a single user is being ceded — and a departure is CLAIMED145 before the first await of its path, so the cycle and the hook can never park the146 same user twice.147 148 **The departures are decided above.** At photo time ``plan_transfers`` pairs149 every user row with a ``transfer_flag``: ``None`` kept, ``'T'`` ceded. WHO is150 ceded is handed in — by the group, which reads the clocks the photo carries and151 judges the silence, or by the quit, which cedes everybody. No user is named here152 by a policy of this process: silence and expiry are the group's judgment, and153 this rung has no gauge of its own. Then THE GATE: the worker does not park154 anybody in the same turn it announced them. It waits ``TRANSFER_START_DELAY``,155 the time the fold needs to park the users just named, and only then lets them156 go, one at a time, the loop breathing between two. So there is ONE departure157 scheme and no special case: the158 window in which somebody could come back to a row that was already emptied159 cannot open, because whoever comes back either is already in the pendings and160 his freeze waits for him, or arrives after the fold parked him and starts again161 from the vertex with the verdict in hand.162 163 **The ordered freeze.** ``/group/freeze_user`` parks ONE user on the parent's164 order. The worker only executes: it waits for whatever holds him — a pull165 bringing him home, his calls in flight — parks him through the same departure166 every road uses, and only then answers, so the REPLY IS the confirmation and167 ``user_frozen`` rides it as always. A user this worker does not host is168 refused out loud in that same REPLY. The waits are serialization, not169 policy: the judgment of WHO sleeps is the caller's alone, and so is the block170 that keeps new work of his from arriving while he leaves.171 172 **The exit.** ``quit`` is that same departure applied to everybody — flag, gate,173 park as the last calls end, leave — and once it starts the plan is TERMINAL: a174 later shot may add a newborn to the departing, never take anybody off them. A175 single user's failure is contained where it happens, so one refused parcel176 cannot keep the process from ending. The worker has no verb of rebirth: whoever177 wants a successor launches one.178 179 **The wire is handed in, never opened here.** Whoever runs this worker in a180 process connects to the handler's socket and hands the stream over181 (``attach_stream``); the worker presents itself on it — its pid and the182 configuration it was built from — and the answer brings the whole global store183 down. Then it reads envelopes until the wire ends. A CALL coming down is served184 on its own task, so a long one cannot make this worker deaf to the next; a REPLY185 coming down is the answer to a call this worker placed UPWARD and is resolved186 inline, on the frame id the future was parked under. Any other kind of envelope187 is denounced.188 189 **The lane upward.** ``call`` places a CALL of this worker's own on the same190 wire and awaits the answer: the id makes the conversations independent, so calls191 placed without awaiting the first resolve each with its own REPLY in whatever192 order the parent gives them, and an answer carrying an ``error`` raises193 ``CommanderCallFailed`` rather than returning a result nobody made. What the194 site's verbs need is the sync door onto it: they run on a traffic-pool thread,195 and ``run_on_loop`` hops the coroutine onto the loop the wire lives on.196 197 **Two pools, and what runs where.** The TRAFFIC pool takes the WSGI stitching198 and the long calls, the SERVICE pool — much smaller — takes the deposit IO;199 their sizes come down in the spawn payload. Neither ever takes a wait: waiting200 for a busy folder is a coroutine on the loop, because whoever holds that201 semaphore is working, and a thread parked here would be a thread not doing that202 work.203 204 **The orders are a tree, and one form.** Every order the parent gives is205 resolved by name on ``worker_dispatcher`` (#59): the first segment names who206 issues it — ``group`` for the orchestration, ``commander`` for the vertex — and207 the last one is a ``@route`` method of ``GroupOrders`` or ``CommanderOrders``.208 ``/group/ping`` answers the health beat and nothing else — are you alive.209 ``/group/quit`` is answered AT ONCE with the photo that shows every user flagged210 for cession, the departure running on a task of its own after the answer because211 the process ends with it; ``/group/drop_user`` and ``/group/drop_connection`` are212 answered when the drop is done, so the worker events it made ride that same213 reply. A path nobody serves answers ``NotFound``. The http CALL form (an ``http`` dict beside the214 ``identity`` and the ``user_frozen`` verdict) is a request the front packed215 whole: it lands on the unified row FIRST — the store adopted when the verdict216 authorises it, the connection looked up in the deposit by itself, the clocks217 stamped — and only then goes to ``hosted_app_seam``, which is the one road to218 whatever this worker hosts. Both seams are ``None`` here: this class hosts no219 application, and the property says so out loud. A subclass assigns ONE of them220 — ``asgi_app`` for an ASGI application, or the ``wsgi_app`` shortcut, which the221 core wraps in a ``WsgiSeam`` of its own and runs on the traffic pool because222 WSGI is synchronous — and that is the whole contract with a consumer.223 224 **The photo rides out.** ``worker_snapshot`` is a slot ANY envelope leaving here225 may carry beside its own payload: the presentation carries it (a live process is226 never without a photo), every population change carries it (a user entering or227 leaving is when the thing the photo describes really changes) — on the reply or228 on the announcement, whichever carries the change — and any reply carries it229 once ``worker_snapshot_ttl`` has run out on the last one. So there is one road230 instead of three, and the beat keeps the only question its name asks.231 232 **The global store is NOT here at all.** There is no replica: the one copy — a233 dictionary — lives on the commander, and every access is a CALL on the lane234 through ``global_store``, a ``GlobalStoreClient``: ``get``/``set``/``delete``235 answered under the commander's lock, and ``for_update`` — the turn, whose236 ``GlobalStoreLease`` yields the private working value the grant decoded and237 sends the COMPLETE value back at the exit. A body that raises releases with238 nothing applied, and a process that dies holding the turn has the vertex give239 it back.240 241 **When the wire dies.** The handler watches the process and the process watches242 the wire: two guardians converging on the same safe state. A wire gone means243 nobody can be told anything, so the worker parks everybody in the deposit — the244 road to safety does not pass through the channel — and leaves. The245 worker events stay unsaid: whoever finds the parcels needs no telling.246 """247 248 from __future__ import annotations249 250 import asyncio251 import contextvars252 import inspect253 import copy254 import contextlib255 import functools256 import logging257 import os258 import threading259 import time260 from collections.abc import Iterable261 from concurrent.futures import ThreadPoolExecutor262 from typing import Any, Callable263 264 import psutil265 from genro_routes import RoutingClass, route266 from genro_tytx import to_tytx267 268 from genro_asgi.channel.control import ControlPayload269 from genro_asgi.http_record import HttpRecord270 from genro_asgi.transport_limits import FrameTooLarge271 from genro_asgi.channel.frame import REGISTER_METHOD, REGISTER_PATH, Frame, FrameStream272 from genro_asgi.exceptions import HTTPException273 from ..environ import AsgiSeam, WsgiSeam274 from ..global_store import (275     GLOBAL_STORE_DEL_OP_PATH,276     GLOBAL_STORE_GET_OP_PATH,277     GLOBAL_STORE_LOCK_OP_PATH,278     GLOBAL_STORE_SET_OP_PATH,279     GLOBAL_STORE_UNLOCK_OP_PATH,280     GlobalStoreClient,281 )282 from ..register import Register283 from ..register_registry import RegisterRegistry284 from .freeze_handler import FreezeHandler285 from .worker_connector import (286     CALL_METHOD,287     ENVELOPE_SLOT_PRESENTATION,288     ENVELOPE_SLOT_WORKER_EVENTS,289     ENVELOPE_SLOT_WORKER_SNAPSHOT,290     REPLY_METHOD,291     CommanderCallFailed,292 )293 from .worker_handler import ANNOUNCE_OP_PATH294 295 #: The reserved prefix that names an anonymous user — the daemon's own296 #: convention, so the name itself carries the guest rule. Redefined here with297 #: its ratified value rather than imported: the legacy machine dies at the298 #: cutover, this one must outlive it.299 GUEST_PREFIX = "guest_"300 301 #: How often a wait for a busy deposit folder looks at it again, in seconds.302 DEPOSIT_LOCK_RETRY_INTERVAL = 0.05303 304 #: How long a wait for a busy deposit folder goes on before it gives up, in305 #: seconds. A technical floor against a semaphore nobody gives back — never a306 #: budget for how long a request may wait, which is the vertex's to spend.307 DEPOSIT_LOCK_WAIT_LIMIT = 30.0308 309 #: How long the worker waits between announcing its departures and starting to310 #: park them, in seconds — the time the fold needs to park the users just named.311 #: A technical time, not a grammar of configuration.312 TRANSFER_START_DELAY = 2.0313 314 #: How long a photo already sent stays fresh enough, in seconds: past it, the315 #: next envelope out carries a new one.316 WORKER_SNAPSHOT_TTL = 0.5317 318 #: How long a quit waits for a user whose call is still in flight, in seconds.319 #: Past it the wait is dropped and he is parked without that call.320 PENDING_CALL_GRACE_SECONDS = 5.0321 322 #: Where a channel command is resolved on this worker's own dispatcher. The323 #: front names the branch on the lane; the leaf is this worker's business.324 WSX_COMMAND_PATH = "/wsx/openchannel"325 326 #: Where a message for a browser is placed on the lane: the front attached that327 #: branch under the commander's operations, because the delivery needs the328 #: registry of sockets and the vertex's own map of pages.329 WEBSOCKET_SEND_PATH = "/commander/websocket/send"330 331 #: How long the worker waits for the vertex to take an announcement, in332 #: seconds. Past it the announcement is counted lost: a wire that answers333 #: nothing is a vertex that is gone, and the cycle must not hang on it.334 ANNOUNCE_TIMEOUT_SECONDS = 10.0335 336 #: The three clocks every register item carries, in the order of their rank.337 CLOCK_NAMES = ("last_refresh_ts", "last_user_ts", "last_rpc_ts")338 339 #: The routing keys of the lane going UP are paths on the trees the group and340 #: the commander host (#59): the first segment names the level that serves, the341 #: next ones the operation class and the operation — ``/commander/store/get``.342 #: The routing keys of the global store are the store module's own, re-exported here.343 344 #: The routing key one observation climbs: a mutation of this process's345 #: registers, as it happens, for whoever is watching at the vertex.346 OBSERVATION_OP_PATH = "/commander/observation"347 348 # What the census puts in place of a field it cannot carry as JSON: the field is349 # left out of the reading entirely, and a sentinel says so without colliding350 # with a legitimate None.351 _NOT_JSON_SAFE = object()352 353 # The worker events that mean the population changed — a user entering or354 # leaving — and therefore that the next envelope out owes a fresh photo.355 POPULATION_WORKER_EVENTS = frozenset(356     {357         "new_user",358         "drop_user",359         "user_frozen",360         "user_adopted",361         "connection_user_changed",362         "user_rows_released",363     }364 )365 366 __all__ = [367     "CLOCK_NAMES",368     "DEPOSIT_LOCK_RETRY_INTERVAL",369     "DEPOSIT_LOCK_WAIT_LIMIT",370     "GLOBAL_STORE_DEL_OP_PATH",371     "GLOBAL_STORE_GET_OP_PATH",372     "GLOBAL_STORE_LOCK_OP_PATH",373     "GLOBAL_STORE_SET_OP_PATH",374     "GLOBAL_STORE_UNLOCK_OP_PATH",375     "GUEST_PREFIX",376     "TRANSFER_START_DELAY",377     "WORKER_SNAPSHOT_TTL",378     "RequestSlot",379     "SpaWorker",380 ]381 382 383 class RequestSlot:384     """What one request has produced so far, waiting for its own exchange.385 386     The events of a request belong to THAT request: they accumulate here and387     leave together at its end. ``worker_events`` are what happened on the388     registers while this CALL was being served, and they ride ITS reply — never389     another's (owner, 2026-09-04). A consumer's slot class adds what its own390     verbs produce per request. ``connection_id`` is the one field that travels back OUT: the front reads391     it off the reply to write its cookie with. ``connection_previous_user`` is set392     when THIS request logged the connection in, and it is what makes the tail of393     this request, and of no other, carry that connection to the deposit.394     """395 396     def __init__(self) -> None:397         self.worker_events: list[dict[str, Any]] = []398         #: The connection the site named while serving this request — born, or399         #: changed owner. None when it named none, which is every request that400         #: reused the connection its cookie already carried.401         self.connection_id: str | None = None402         #: Who owned the connection before this request logged it in; None when403         #: this request logged nobody in.404         self.connection_previous_user: str | None = None405 406 407 class GroupOrders(RoutingClass):408     """The ``group`` branch of the worker's dispatcher: the orders the GROUP gives.409 410     Orchestration, all of it: placement undone (``drop_user``,411     ``drop_connection``), the ordered freeze of one user, the departure, the412     beat. Each is a ``@route`` method the vertex reaches on413     ``/group/<order>``; what it answers is the ``result`` of the REPLY, what it414     raises is the ``error``.415 416     Args:417         spa_worker: the process these orders act on.418     """419 420     def __init__(self, spa_worker: Any) -> None:421         self.spa_worker = spa_worker422 423     @route()424     def ping(self) -> dict[str, Any]:425         """The beat: an answer is the whole point — the photo rides the envelope."""426         return {}427 428     @route()429     def drop_user(self, user: str) -> dict[str, Any]:430         """Take one user off this process."""431         self.spa_worker.drop_user(user)432         return {}433 434     @route()435     def drop_connection(self, cid: str) -> dict[str, Any]:436         """Take one connection off this process; one nobody holds is answered quietly."""437         connection = self.spa_worker.connection_register.get(cid)438         if connection is not None:439             self.spa_worker.drop_connection(connection["user"], cid)440         return {}441 442     @route()443     async def freeze_user(self, user: str) -> dict[str, Any]:444         """Park ONE user and answer only then: the REPLY is the confirmation."""445         return await self.spa_worker.freeze_designated_user(user)446 447     @route()448     def quit(self, freezer_path: str | None = None) -> dict[str, Any]:449         """Flag everybody for departure, answer, THEN leave.450 451         Args:452             freezer_path: where the parcels of this departure go, when not the453                 working deposit — the reboot directory of a soft quit.454 455         Acts on the flags before the answer, so the photo riding it shows every456         user ceded and the level above parks them all in one read; the457         departure itself is put on a task of its own, so the REPLY goes down458         the wire first.459         """460         self.spa_worker.begin_quit(freezer_path=freezer_path)461         return {}462 463 464 class CommanderOrders(RoutingClass):465     """The ``commander`` branch of the worker's dispatcher: the orders the VERTEX gives.466 467     Reading and switching, nothing of the placement: the census, the eval468     door, the observation switch. A consumer attaches its own order class under469     here with ``add_branches``.470 471     Args:472         spa_worker: the process these orders act on.473     """474 475     def __init__(self, spa_worker: Any) -> None:476         self.spa_worker = spa_worker477 478     @route()479     def observe(self, on: bool) -> dict[str, Any]:480         """Turn this process's observation on or off."""481         self.spa_worker.observation_on = bool(on)482         return {}483 484     @route()485     def census(self) -> dict[str, Any]:486         """The structured reading of the whole process, JSON-safe."""487         return self.spa_worker.census()488 489     @route()490     def eval(self, expr: str) -> dict[str, Any]:491         """Evaluate one expression inside the process, ``repr`` back."""492         return {"repr": self.spa_worker.eval_expression(expr)}493 494 495 class WsxCommands(RoutingClass):496     """The ``wsx`` branch of the worker's dispatcher: what a PAGE asks of its channel.497 498     The other two branches name who ISSUES an order — the group, the vertex.499     This one names a matter instead: nobody orders here, a page opens its500     channel and says how it wants to be served on it.501 502     Args:503         spa_worker: the process the command acts on.504     """505 506     def __init__(self, spa_worker: Any) -> None:507         self.spa_worker = spa_worker508 509     @route()510     def openchannel(self, page_id: str, parameters: Any = None) -> str:511         """Open the channel of one page, and say how its calls must be served.512 513         Args:514             page_id: the page opening its channel.515             parameters: how it wants to be served — nothing for the ordinary516                 page, a dict for one that asked for something (``sequential``517                 serialises its calls).518 519         Returns:520             The word the front turns into the answer of the command.521 522         Raises:523             KeyError: no page of that name lives here. A page is born by the524                 site while it serves, so a channel opened for a page nobody525                 created is a client talking about something that does not526                 exist, and it is told so.527 528         Acts on the page's row, under its own lock. Saying it twice changes529         nothing: a browser that reconnects opens its channel again.530         """531         worker = self.spa_worker532         with worker.dispatch_lock:533             row = worker.page_register.get(page_id)534         if row is None:535             raise KeyError(f"no page {page_id!r} on this worker: it was never born here")536         with row["item_lock"]:537             row["wsx"] = parameters if parameters else True538         return "channel open"539 540 541 class WorkerDispatcher(RoutingClass):542     """The root of the orders a worker takes: ``group/…`` and ``commander/…``.543 544     The first segment of a path names who issues the order; the tree is the545     table, and ``route.nodes()`` lists it without an order being placed. The two546     branches are kept as attributes so a consumer can attach its own class under547     the issuer it extends.548 549     Args:550         spa_worker: the process this dispatcher belongs to.551     """552 553     def __init__(self, spa_worker: Any) -> None:554         self.spa_worker = spa_worker555         self.group_orders = GroupOrders(spa_worker)556         self.commander_orders = CommanderOrders(spa_worker)557         self.wsx_commands = WsxCommands(spa_worker)558         self.add_branches(559             [560                 {"name": "group", "instance": self.group_orders},561                 {"name": "commander", "instance": self.commander_orders},562                 {"name": "wsx", "instance": self.wsx_commands},563             ]564         )565 566 567 class SpaWorker:568     """The users, connections and pages one worker process holds.569 570     Args:571         name: the worker's name, the one its handler minted; it stamps every572             worker event and holds the deposit semaphore.573         freeze_handler: the deposit surface — the only way to the parcels.574         group: the group this worker serves in; it goes in the diagnostic header575             of every parcel, which is read for counting and for the sysop.576         deposit_lock_retry_interval: how often a busy user folder is looked at577             again while waiting for its semaphore.578         deposit_lock_wait_limit: how long that wait goes on before it gives up579             loud.580         transfer_start_delay: how long the gate stays shut between announcing581             the departures and parking them.582         main_threadpool_size: the traffic pool's size — the WSGI stitching and583             the long calls; ``None`` leaves the interpreter's own default.584         aux_threadpool_size: the service pool's size — the deposit IO, and much585             smaller.586         worker_snapshot_ttl: how long a photo already sent stays fresh enough.587     """588 589     def __init__(590         self,591         name: str,592         *,593         freeze_handler: FreezeHandler,594         group: str = "",595         deposit_lock_retry_interval: float = DEPOSIT_LOCK_RETRY_INTERVAL,596         deposit_lock_wait_limit: float = DEPOSIT_LOCK_WAIT_LIMIT,597         transfer_start_delay: float = TRANSFER_START_DELAY,598         main_threadpool_size: int | None = None,599         aux_threadpool_size: int | None = None,600         worker_snapshot_ttl: float = WORKER_SNAPSHOT_TTL,601     ) -> None:602         self.name = name603         self.freeze_handler = freeze_handler604         self.group = group605         self.deposit_lock_retry_interval = deposit_lock_retry_interval606         self.deposit_lock_wait_limit = deposit_lock_wait_limit607         self.transfer_start_delay = transfer_start_delay608         self.worker_snapshot_ttl = worker_snapshot_ttl609         self.traffic_pool = ThreadPoolExecutor(610             max_workers=main_threadpool_size, thread_name_prefix=f"{name}-traffic"611         )612         self.service_pool = ThreadPoolExecutor(613             max_workers=aux_threadpool_size, thread_name_prefix=f"{name}-service"614         )615         #: The wire this worker speaks on, handed in by whoever runs it.616         self.stream: FrameStream | None = None617         #: The loop the wire lives on, taken with the wire: what a pool thread618         #: hops onto to place a call of its own.619         self.loop: asyncio.AbstractEventLoop | None = None620         #: One future per CALL this worker placed upward, by frame id: the read621         #: loop resolves them as the answers land, in whatever order they do.622         self._parent_calls: dict[str, asyncio.Future[Frame]] = {}623         self._parent_call_paths: dict[str, str] = {}624         self._abandoned_parent_calls: dict[str, str] = {}625         #: The consumer seam of the http CALL form: an ASGI application a626         #: subclass assigns. None here — this class hosts no application of its627         #: own. Whoever assigns it also gives it this worker, the way the628         #: genropy bridge gives its hosted site its ``spa_worker``: the core629         #: writes no live object into a scope (owner, 2026-09-06). A task the630         #: application spawns inherits the context at creation, but mutating631         #: the registers AFTER the reply has left raises: the work of a request632         #: ends inside the request, as it does for a WSGI site.633         self.asgi_app: Any = None634         #: The shortcut for whoever hosts WSGI only: a WSGI callable the core635         #: wraps in a ``WsgiSeam`` itself. Alternative to ``asgi_app``, never636         #: an addition — both assigned is a configuration error.637         self.wsgi_app: Callable[..., Any] | None = None638         self.dispatch_lock = threading.RLock()639         #: The rows of the three registers, and the lifecycle vocabulary that640         #: moves them: the shared registry, built through its own hook.641         self.registry = self.build_registry()642         #: Whether every register mutation of this process is reported up the643         #: lane as it happens. Off until somebody watches, and switched only by644         #: the vertex: a debug surface must not cost anything when unobserved.645         self.observation_on = False646         #: One slot per CALL being served. Each CALL is served on a task of its647         #: own, and the stitching runs on the pool under a copy of that task's648         #: context, so the loop and the thread of one request see ONE slot and649         #: two requests never see each other's. None outside any CALL.650         self._request_slot_var: contextvars.ContextVar[RequestSlot | None] = (651             contextvars.ContextVar(f"request_slot:{name}", default=None)652         )653         self._global_store = GlobalStoreClient(self)654         self._announce_failures = 0655         self._observation_tasks: set[asyncio.Task[None]] = set()656         self._unfreeze_waits: dict[str, asyncio.Event] = {}657         self._pendings: dict[str, int] = {}658         #: One event per user a freeze order is waiting on: set by the end of659         #: his last call, which is the instant the order may park him.660         self._freeze_order_waits: dict[str, asyncio.Event] = {}661         self._transfer_flags: dict[str, str] = {}662         #: One entry per connection that logged in during a call and is663         #: waiting for that call's tail to carry it away: the identity it664         #: belonged to BEFORE, which is the only fact the tail needs.665         self._departing_users: set[str] = set()666         self._transfers_start_ts = 0.0667         self._transfers_done = asyncio.Event()668         self._transfers_done.set()669         self._transfers_changed = asyncio.Event()670         self._quitting = False671         self._freeze_failures = 0672         self._exited = False673         self._service_tasks: set[asyncio.Task[None]] = set()674         self._quit_task: asyncio.Task[None] | None = None675         #: The orders this process takes, as a tree: ``group/…`` and ``commander/…``.676         self.worker_dispatcher = WorkerDispatcher(self)677         self._snapshot_sent_ts = 0.0678         self._population_changed = False679         self._logger = logging.getLogger(__name__)680 681     def build_registry(self) -> RegisterRegistry:682         """Build the registry this worker holds its rows in.683 684         Returns:685             A fresh ``RegisterRegistry``.686 687         The seam a consumer replaces to pair its own row types with the tree:688         whoever hosts a site subclasses this and returns its own registry, and689         nothing else in this class names the concrete class.690         """691         return RegisterRegistry()692 693     @property694     def hosted_app_seam(self) -> AsgiSeam:695         """The seam onto the hosted application, the one seam a consumer assigned.696 697         Returns:698             The ASGI seam: on ``asgi_app`` when a consumer assigned one, on the699             WSGI adapter around ``wsgi_app`` when it took the shortcut instead.700 701         Raises:702             RuntimeError: both seams are assigned, or neither is — and the two703                 are not the same kind of trouble. BOTH is a contradiction704                 somebody declared, and ``WorkerEntry`` reads this at boot for705                 exactly that case, so the process dies before the wire exists.706                 NEITHER is the base worker, which is legitimate: it serves its707                 orders and hosts nothing, and it learns so here, when an http708                 CALL finally asks it to serve a request.709         """710         if self.asgi_app is not None and self.wsgi_app is not None:711             raise RuntimeError(712                 f"Worker {self.name}: asgi_app and wsgi_app are both assigned; "713                 "the WSGI shortcut is an alternative to the ASGI seam, not an addition"714             )715         if self.asgi_app is not None:716             return AsgiSeam(self.asgi_app)717         if self.wsgi_app is not None:718             return AsgiSeam(WsgiSeam(self.wsgi_app, self))719         raise RuntimeError(720             f"Worker {self.name}: neither asgi_app nor wsgi_app is assigned; "721             "this worker hosts no application"722         )723 724     async def run_sync(self, work: Callable[[], Any]) -> Any:725         """Run one piece of synchronous work on the traffic pool, in this CALL's slot.726 727         Args:728             work: what to run there — a WSGI callable, a legacy database call.729 730         Returns:731             Whatever the work returned.732 733         The thread runs under a COPY of the calling task's context, so it finds734         the request slot of the CALL it serves and whatever it announces rides735         that CALL's reply. This is how a hosted ASGI application does its736         synchronous work: the legacy database its group engine built is737         synchronous, and neither the loop nor the service pool may be held738         behind it.739         """740         return await self._run_in_pool(self.traffic_pool, work)741 742     def build_request_slot(self) -> RequestSlot:743         """Build the slot of one request: the seam a consumer replaces with its own slot class.744 745         Returns:746             A fresh ``RequestSlot``; a subclass adds the per-request state its747             verbs carry.748         """749         return RequestSlot()750 751     def on_request_served(self) -> None:752         """What runs at the end of every served request, failed ones included.753 754         Called in the ``finally`` of the stitching, on the pool thread, with the755         request's slot still open. The seam a consumer overrides to deliver what756         its verbs left on the slot; the core leaves nothing there.757         """758 759     @property760     def user_register(self) -> Register:761         """The users this worker holds, by identity.762 763         Returns:764             The live register — read it, and leave the writing to the mutators.765         """766         return self.registry.user_items767 768     @property769     def connection_register(self) -> Register:770         """The connections this worker holds, by cid.771 772         Returns:773             The live register — read it, and leave the writing to the mutators.774         """775         return self.registry.connection_items776 777     @property778     def page_register(self) -> Register:779         """The pages this worker holds, by page id.780 781         Returns:782             The live register — read it, and leave the writing to the mutators.783         """784         return self.registry.page_items785 786     @property787     def worker_events(self) -> list[dict[str, Any]]:788         """The worker events of the CALL being served, waiting for its reply.789 790         Returns:791             The live list of the current request slot: whoever composes the792             envelope takes them from here.793 794         Raises:795             RuntimeError: no CALL is being served in this context.796         """797         return self.request_slot.worker_events798 799     @property800     def freeze_failures(self) -> int:801         """How many departures the deposit refused since this worker was born.802 803         Returns:804             The count. Every one of them left a user alive and a loud line in805             the log; a number that grows is a disk to look at.806         """807         return self._freeze_failures808 809     @property810     def exited(self) -> bool:811         """Whether this worker has already left.812 813         Returns:814             True once ``exit_process`` was reached.815         """816         return self._exited817 818     @property819     def rss_bytes(self) -> int | None:820         """The resident set size of this process, in bytes.821 822         Returns:823             What psutil reads for this process on every platform, or None when824             the kernel refuses the reading — the photo carries the counts825             either way.826         """827         try:828             return psutil.Process().memory_info().rss829         except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):830             return None831 832     @property833     def pss_bytes(self) -> int | None:834         """The proportional set size of this process, in bytes.835 836         Returns:837             The ``pss`` psutil reads from the Linux ``smaps_rollup``. Shared838             pages are divided among the processes mapping them, unlike RSS, so839             summing this gauge across prefork workers does not charge the840             template's pages once per child. ``None`` is the honest answer on841             platforms whose full memory info has no ``pss`` field (macOS,842             Windows) or when the kernel refuses the reading.843         """844         try:845             return getattr(psutil.Process().memory_full_info(), "pss", None)846         except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):847             return None848 849     @property850     def worker_snapshot(self) -> dict[str, Any]:851         """What this process honestly knows of itself, ready for the wire.852 853         Returns:854             The aggregates of the process, one row per connection with its three855             clocks, and one pair per user — his projected item and the856             ``transfer_flag`` the last photo decided, ``None`` when he is going857             nowhere. Scalars only: the stores and the working fields are the858             application's business, never the observer's. Each user row carries859             his cumulative service counters — ``served_call_count``,860             ``service_seconds``, ``pending_call_count`` — raw readings the861             envelope layer turns into per-interval deltas. The CPU is measured862             by the commander through psutil, never by this photo. The counters863             live in the register item and never reach a frozen parcel: the freeze864             persists the store and the connections, not the row itself.865         """866         with self.dispatch_lock:867             return {868                 "pid": os.getpid(),869                 "name": self.name,870                 "group": self.group,871                 "rss_bytes": self.rss_bytes,872                 "pss_bytes": self.pss_bytes,873                 "user_count": len(self.user_register),874                 "connection_count": len(self.connection_register),875                 "page_count": len(self.page_register),876                 "connections": {877                     cid: {878                         "user": item["user"],879                         **{clock: item[clock] for clock in CLOCK_NAMES},880                     }881                     for cid, item in self._get_register_rows(self.connection_register)882                 },883                 "users": {884                     user: {885                         "item": self._user_row(user, item),886                         "transfer_flag": self._transfer_flags.get(user),887                     }888                     for user, item in self._get_register_rows(self.user_register)889                 },890             }891 892     def add_worker_event(self, op: str, **payload: Any) -> dict[str, Any]:893         """Queue one worker event for the envelope out.894 895         Args:896             op: the protocol name of what happened.897             payload: the entity keys that name it.898 899         Returns:900             The worker event as it was queued.901 902         Appends to the ``worker_events`` of the CALL being served, and marks the903         photo due when what happened is a user entering or leaving. Outside any904         CALL there is no slot and this raises: an event nobody would ever send905         is a fault, not a queue.906         """907         event = {"op": op, "worker": self.name, **payload}908         self.request_slot.worker_events.append(event)909         if self.observation_on:910             self.report_observation(op, payload)911         if op in POPULATION_WORKER_EVENTS:912             self._population_changed = True913         return event914 915     def add_user(self, user: str, **fields: Any) -> dict[str, Any]:916         """Bring a user into being on this worker and announce it.917 918         Args:919             user: the user identity.920             fields: anything else the item should carry, stored verbatim.921 922         Returns:923             The user register item.924 925         Adds the item and announces ``new_user``.926         """927         with self.dispatch_lock:928             item = self._add_user_item(user, **fields)929             self.add_worker_event("new_user", user=user)930             return item931 932     def add_connection(self, cid: str, user: str | None = None, **fields: Any) -> dict[str, Any]:933         """Bring a connection into being, born guest unless it is given a user.934 935         Args:936             cid: the connection identity.937             user: the user it belongs to; ``None`` is the anonymous reception,938                 which names it ``GUEST_PREFIX`` + the cid.939             fields: anything else the item should carry, stored verbatim.940 941         Returns:942             The connection register item.943 944         Adds the item — with the user above it when that user is unseen — and945         announces the cascade in the order it happened.946         """947         with self.dispatch_lock:948             user = user or GUEST_PREFIX + cid949             if user not in self.user_register:950                 self.add_user(user)951             item = self._add_connection_item(cid, user, **fields)952             self.request_slot.connection_id = cid953             self.add_worker_event("new_connection", user=user, connection_id=cid)954             return item955 956     def add_page(957         self, page_id: str, cid: str, user: str | None = None, **fields: Any958     ) -> dict[str, Any]:959         """Bring a page into being under its connection and announce it.960 961         Args:962             page_id: the page identity.963             cid: the connection the page belongs to.964             user: the user to hang an unseen connection from; ignored when the965                 connection is already here.966             fields: anything else the item should carry, stored verbatim.967 968         Returns:969             The page register item.970 971         Adds the item — with the connection and the user above it when they are972         unseen — and announces the cascade in the order it happened.973         """974         with self.dispatch_lock:975             if cid not in self.connection_register:976                 self.add_connection(cid, user)977             item = self._add_page_item(page_id, cid, **fields)978             self.add_worker_event(979                 "new_page",980                 user=self.registry.page_user(page_id),981                 page_id=page_id,982                 connection_id=cid,983                 **item.announcement_fields(),984             )985             return item986 987     def new_connection(self, identity: str, **fields: Any) -> dict[str, Any]:988         """Open a connection in the form the site calls it, identity first.989 990         Args:991             identity: the connection identity — the session id.992             fields: anything else the row should carry; ``user`` names the owner993                 and its absence is the anonymous reception.994 995         Returns:996             The connection register item.997 998         Acts on the registers through ``add_connection``, so the announcements999         rise exactly as they do for the reception.1000         """1001         return self.add_connection(identity, fields.pop("user", None), **fields)1002 1003     def new_page(1004         self, identity: str, page_id: str, **fields: Any1005     ) -> dict[str, Any]:1006         """Open a page under its user in the form the site calls it.1007 1008         Args:1009             identity: the user the page belongs to.1010             page_id: the page identity.1011             fields: anything else the row should carry; ``connection_id``1012                 names the connection it hangs from.1013 1014         Returns:1015             The page register item.1016 1017         Acts on the registers through ``add_page``, which brings the connection1018         and the user above it into being when they are unseen and announces the1019         cascade in the order it happened.1020         """1021         cid = fields.pop("connection_id", None)1022         return self.add_page(page_id, cid, identity, **fields)1023 1024     def drop_page(self, identity: str, page_id: str) -> None:1025         """Take one page off this worker, and whatever it was the last of.1026 1027         Args:1028             identity: the user the site names as the page's owner; the owner1029                 this worker acts on is derived through the chain.1030             page_id: the page to be gone.1031 1032         Removes the item and announces ``drop_page``, then the1033         ``drop_connection`` and ``drop_user`` its departure empties. A page1034         already gone is the same outcome: nothing happens and nothing is said.1035         """1036         with self.dispatch_lock:1037             page = self.page_register.get(page_id)1038             if page is None:1039                 return1040             cid = page["connection_id"]1041             user = self.registry.page_user(page_id)1042             self._remove_page_item(page_id)1043             self.add_worker_event("drop_page", user=user, page_id=page_id)1044             if not self.connection_register.get(cid)["pages"]:1045                 self._remove_connection_item(cid)1046                 self.add_worker_event("drop_connection", user=user, connection_id=cid)1047                 self._drop_emptied_user(user)1048 1049     def drop_connection(self, identity: str, connection_id: str) -> None:1050         """Take a whole connection off this worker, its pages first.1051 1052         Args:1053             identity: the user the site names as the connection's owner; the1054                 owner this worker acts on is read off the row.1055             connection_id: the connection to be gone.1056 1057         Raises:1058             KeyError: this worker holds no such connection.1059 1060         Removes the pages and the connection, announcing ``drop_pages`` (when it1061         had any), ``drop_connection``, and ``drop_user`` if it was the user's1062         last.1063         """1064         cid = connection_id1065         with self.dispatch_lock:1066             item = self.connection_register.get(cid)1067             if item is None:1068                 raise KeyError(f"drop_connection: no connection {cid!r} here")1069             user = item["user"]1070             page_ids = sorted(item["pages"])1071             for page_id in page_ids:1072                 self._remove_page_item(page_id)1073             if page_ids:1074                 self.add_worker_event("drop_pages", user=user, page_ids=page_ids)1075             self._remove_connection_item(cid)1076             self.add_worker_event("drop_connection", user=user, connection_id=cid)1077             self._drop_emptied_user(user)1078 1079     def drop_user(self, user: str) -> None:1080         """Take a user off this worker with everything under him.1081 1082         Args:1083             user: the user to be gone.1084 1085         Removes the pages, the connections and the user, announcing1086         ``drop_pages`` and ``drop_connections`` for what he had and ``drop_user``1087         last. A user already gone is the same outcome.1088         """1089         with self.dispatch_lock:1090             item = self.user_register.get(user)1091             if item is None:1092                 return1093             connection_ids = sorted(item["connections"])1094             page_ids = sorted(1095                 page_id1096                 for cid in connection_ids1097                 for page_id in self.connection_register.get(cid)["pages"]1098             )1099             for page_id in page_ids:1100                 self._remove_page_item(page_id)1101             if page_ids:1102                 self.add_worker_event("drop_pages", user=user, page_ids=page_ids)1103             for cid in connection_ids:1104                 self._remove_connection_item(cid)1105             if connection_ids:1106                 self.add_worker_event(1107                     "drop_connections", user=user, connection_ids=connection_ids1108                 )1109             self._remove_user_item(user)1110             self._unfreeze_waits.pop(user, None)1111             self.add_worker_event("drop_user", user=user)1112 1113     def refresh_chain(self, page_id: str, *clocks: str) -> float:1114         """Stamp a page and the chain above it with the server's own instant.1115 1116         Args:1117             page_id: the page the contact came in on.1118             clocks: the clocks the contact deserves besides ``last_refresh_ts``,1119                 which every contact stamps — ``last_user_ts`` for a human event,1120                 ``last_rpc_ts`` for a real call.1121 1122         Returns:1123             The instant written, the same on all three levels.1124 1125         Raises:1126             KeyError: no such page here.1127 1128         Stamps the page item, its connection item and its user item.1129         """1130         with self.dispatch_lock:1131             owner = self.registry.page_user(page_id)1132             page = self.page_register.get(page_id)1133             connection = self.connection_register.get(page["connection_id"])1134             user = self.user_register.get(owner)1135             return self._stamp_items((page, connection, user), clocks)1136 1137     # ------------------------------------------------------------------1138     # The local data plane: what a page hears about, what it collects, and1139     # the drain that hands both species over. Every write addressed here is1140     # LOCAL by stickiness — the target page lives on this worker of fact —1141     # so nothing ascends, while the addressing the second pass will route1142     # already rides in the signatures.1143     # ------------------------------------------------------------------1144 1145     @property1146     def request_slot(self) -> RequestSlot:1147         """The slot of the CALL being served in this context.1148 1149         Raises:1150             RuntimeError: no CALL is being served here — nothing opened a slot.1151         """1152         slot = self._request_slot_var.get()1153         if slot is None:1154             raise RuntimeError(f"Worker {self.name}: no request slot open in this context")1155         return slot1156 1157     def open_request_slot(self) -> RequestSlot:1158         """Put a fresh slot in this context, so no CALL inherits another's events.1159 1160         Returns:1161             The slot just opened.1162 1163         Called on the task that serves a CALL before anything is done for it;1164         the pool thread the stitching runs on inherits it through the copied1165         context. Whatever a previous slot of this context held goes with it.1166         """1167         slot = self.build_request_slot()1168         self._request_slot_var.set(slot)1169         return slot1170 1171     @property1172     def global_store(self) -> GlobalStoreClient:1173         """This worker's side of the global store: simple operations and ``for_update``.1174 1175         NEVER open a turn while holding ``dispatch_lock``: both halves place a1176         lane call, and a holder would park on its own lock.1177         """1178         return self._global_store1179 1180     def attach_stream(self, stream: FrameStream) -> None:1181         """Take the wire this worker speaks on.1182 1183         Args:1184             stream: the frame codec over the connection to the handler.1185 1186         Sets ``stream`` and ``loop``. The worker never opens the wire: whoever1187         runs this worker in a process connects, and hands the connection over —1188         from that process's own loop, which is the one the lane then lives on.1189         """1190         self.stream = stream1191         self._abandoned_parent_calls.clear()1192         self.loop = asyncio.get_running_loop()1193 1194     async def send_presentation(self, config: dict[str, Any]) -> None:1195         """Present this process on the wire and install the store that answers.1196 1197         Args:1198             config: the spawn payload this process was built from, echoed back1199                 so the handler sees what its child understood of it.1200 1201         Returns once the handler has answered, which is what tells this process1202         it is on the wire.1203         """1204         await self.stream.write(1205             Frame(1206                 method=REGISTER_METHOD,1207                 path=REGISTER_PATH,1208                 info=self._outbound({ENVELOPE_SLOT_PRESENTATION: os.getpid(), "config": config}),1209             )1210         )1211         await self.stream.read()1212 1213     async def receive_frames(self) -> None:1214         """Read the handler's envelopes until the wire ends.1215 1216         Returns when the wire is gone — EOF, the death signal on a same-host1217         socket — or a protocol violation closed it. What a worker without a wire1218         does is not decided here: the caller asks for ``on_wire_lost``. Every1219         CALL still parked for an answer is failed first, with ``ConnectionError``:1220         an answer that can no longer arrive must not be waited for.1221         """1222         try:1223             while True:1224                 try:1225                     frame = await self.stream.read()1226                 except ValueError:1227                     self._logger.exception(1228                         "Worker %s: protocol violation from its handler; leaving the wire",1229                         self.name,1230                     )1231                     return1232                 if frame is None:1233                     return1234                 try:1235                     self.handle_frame(frame)1236                 except ValueError:1237                     self._logger.exception(1238                         "Worker %s: protocol violation routing a handler reply; leaving the wire",1239                         self.name,1240                     )1241                     await self.stream.close()1242                     return1243         finally:1244             self._fail_parent_calls(ConnectionError("the wire to the handler ended"))1245 1246     def _fail_parent_calls(self, cause: BaseException) -> None:1247         """Wake every parked CALL with ``cause``; the answers will never come."""1248         for future in list(self._parent_calls.values()):1249             if not future.done():1250                 future.set_exception(cause)1251 1252     def handle_frame(self, frame: Frame) -> None:1253         """Route one envelope from the handler: a CALL to serve, a REPLY to resolve.1254 1255         Args:1256             frame: the envelope as it came off the wire.1257 1258         A CALL is served on its own task, so a long op cannot make this worker1259         deaf to the next one; a REPLY is the answer to a call this worker placed1260         upward and is resolved inline, on the frame id it was parked under.1261         """1262         if frame.method == CALL_METHOD:1263             task = asyncio.create_task(self._guarded_call(frame))1264             self._service_tasks.add(task)1265             task.add_done_callback(self._service_tasks.discard)1266         elif frame.method == REPLY_METHOD:1267             self._resolve_parent_reply(frame)1268         else:1269             self._logger.warning(1270                 "Worker %s: unexpected envelope %s from its handler", self.name, frame.method1271             )1272 1273     def eval_expression(self, expr: str) -> str:1274         """Evaluate one debug expression against this process, repr back.1275 1276         Args:1277             expr: a Python expression; ``worker`` names this instance.1278 1279         Returns:1280             The ``repr`` of the value — the debug door's whole answer, so1281             anything unforeseen is readable without a tool having predicted it.1282 1283         Evaluates under ``dispatch_lock``, so the rows it reads are coherent.1284         Full eval power by construction: the door exists only where the1285         console surface was mounted on purpose, never in production.1286         """1287         with self.dispatch_lock:1288             return repr(eval(expr, {"worker": self}))1289 1290     def report_observation(self, kind: str, data: dict[str, Any]) -> None:1291         """Send one observation up the lane, and forget about it.1292 1293         Args:1294             kind: the mutation it reports, named as the worker event is.1295             data: the keys that name what moved, JSON-safe as they travel.1296 1297         Best-effort by design: the answer is never read and a failure is logged1298         and dropped. Nothing here may raise into the traffic path — an observer1299         that changes what it observes is worse than no observer.1300         """1301         payload = {"kind": kind, "source": self.name, "data": data}1302         try:1303             self.loop.call_soon_threadsafe(self._start_observation_call, payload)1304         except (AttributeError, RuntimeError) as exc:1305             self._logger.debug("Worker %s: observation %s dropped (%s)", self.name, kind, exc)1306 1307     def _start_observation_call(self, payload: dict[str, Any]) -> None:1308         """Put one observation on the lane as a detached task, on the loop."""1309         task = asyncio.create_task(self._deliver_observation(payload))1310         self._observation_tasks.add(task)1311         task.add_done_callback(self._observation_tasks.discard)1312 1313     async def _deliver_observation(self, payload: dict[str, Any]) -> None:1314         """Await the observation's own answer, so a failure is logged and no more."""1315         try:1316             await self.call(OBSERVATION_OP_PATH, payload)1317         except Exception as exc:1318             self._logger.debug(1319                 "Worker %s: observation %s lost (%s)", self.name, payload["kind"], exc1320             )1321 1322     def census(self) -> dict[str, Any]:1323         """The whole process read out for a human: every register, JSON-safe.1324 1325         Returns:1326             The three registers key by key with their scalar fields. Live1327             objects are left out by construction — only what survives1328             ``json.dumps`` is in here.1329         """1330         with self.dispatch_lock:1331             return {1332                 "name": self.name,1333                 "group": self.group,1334                 "pid": os.getpid(),1335                 "user_register": self._census_register(self.user_register),1336                 "connection_register": self._census_register(self.connection_register),1337                 "page_register": self._census_register(self.page_register),1338             }1339 1340     def _census_register(self, register: Register) -> dict[str, Any]:1341         """One register key by key, each item reduced to its JSON-safe fields."""1342         return {1343             key: {1344                 field: self._census_field(value)1345                 for field, value in register.get(key).items()1346                 if self._census_field(value) is not _NOT_JSON_SAFE1347             }1348             for key in register.keys()1349         }1350 1351     def _census_field(self, value: Any) -> Any:1352         """One field as the census carries it, or ``_NOT_JSON_SAFE`` to leave it out.1353 1354         Args:1355             value: whatever the item holds under that name.1356 1357         Returns:1358             The value itself when it is a scalar, the sorted elements of a1359             container of scalars (the keys, for a dict), the count when those1360             elements are objects — never the objects themselves.1361         """1362         if value is None or isinstance(value, (str, int, float, bool)):1363             return value1364         if isinstance(value, dict):1365             value = list(value)1366         if isinstance(value, (set, frozenset, list, tuple)):1367             if all(1368                 element is None or isinstance(element, (str, int, float, bool))1369                 for element in value1370             ):1371                 return sorted(str(element) for element in value)1372             return len(value)1373         return _NOT_JSON_SAFE1374 1375     async def answer_call(self, frame: Frame) -> None:1376         """Answer one CALL: the http form, or an order resolved on the dispatcher.1377 1378         Args:1379             frame: the CALL as it came off the wire.1380 1381         Sends exactly one REPLY, whatever the outcome: the order's result, or1382         its error — ``NotFound`` for a path nobody serves, ``TypeError`` for a1383         payload that does not fit the order's signature, both raised before any1384         body runs. The HTTP form is selected by format metadata; only this1385         destination opens the application's HTTP record.1386         """1387         wire_format = frame.info.get("format")1388         if wire_format == "http":1389             info = frame.info1390             if set(info) - {"format", "cid", "page_id", "reply_path", "identity", "user_frozen"}:1391                 raise ValueError("unexpected HTTP routing metadata")1392             if "cid" not in info:1393                 raise ValueError("HTTP routing metadata is missing cid")1394             for key in ("cid", "page_id", "reply_path", "identity"):1395                 if info.get(key) is not None and not isinstance(info[key], str):1396                     raise ValueError(f"invalid HTTP routing {key}")1397             if not isinstance(info.get("user_frozen", False), bool):1398                 raise ValueError("user_frozen must be a boolean")1399             scope, body = HttpRecord().decode_request(frame.payload)1400             http = {**scope, "body": body,1401                     "headers": [(n.decode("latin-1"), v.decode("latin-1"))1402                                 for n, v in scope.get("headers", [])],1403                     "query_string": scope.get("query_string", b"").decode("latin-1"),1404                     "cid": frame.info.get("cid")}1405             for key in ("page_id", "reply_path"):1406                 if key in frame.info:1407                     http[key] = frame.info[key]1408             await self.serve_http(frame, {**frame.info, "http": http})1409             return1410         if wire_format not in (None, "control-json"):1411             raise ValueError("unsupported parent call payload format")1412         payload = ControlPayload().decode(frame.payload) or {}1413         if "http" in payload:1414             raise ValueError("HTTP requests require an opaque HTTP frame")1415         if "wsx" in payload:1416             if (frame.info.get("identity") is not None1417                     and not isinstance(frame.info["identity"], str)):1418                 raise ValueError("invalid WSX routing identity")1419             if not isinstance(frame.info.get("user_frozen", False), bool):1420                 raise ValueError("WSX user_frozen must be a boolean")1421             payload = {**payload, "identity": frame.info.get("identity"),1422                        "user_frozen": frame.info.get("user_frozen", False)}1423             await self.serve_wsx(frame, payload)1424             return1425         try:1426             result = self.worker_dispatcher.route.node(frame.path)(**payload)1427             if inspect.isawaitable(result):1428                 result = await result1429         except Exception as exc:1430             await self.send_reply(frame, error=f"{type(exc).__name__}: {exc}")1431         else:1432             await self.send_reply(frame, result=result)1433 1434     async def serve_http(self, frame: Frame, payload: dict[str, Any]) -> None:1435         """Serve the http CALL form through the WSGI seam, or refuse it.1436 1437         Args:1438             frame: the CALL being answered.1439             payload: its whole payload — the ``http`` dict the front packed, the1440                 ``identity`` to route on and the ``user_frozen`` verdict, which1441                 belong together because the row is resolved from all three.1442 1443         Whether this worker hosts anything at all is ``hosted_app_seam``'s1444         judgment, and it was already made at boot: what can still go wrong here1445         comes back as this same REPLY — a caller is answered once, always, and1446         a deposit that refuses a parcel must not leave a browser waiting for a1447         timeout.1448         """1449         try:1450             result: Any = await self._serve_request(payload)1451             error: Any = None1452             status: int | None = None1453         except HTTPException as refused:1454             # A refusal of the CLIENT: it knows what to answer, and the words1455             # are the ones the browser must read.1456             result, error, status = None, refused.detail, refused.status1457         except Exception as exc:1458             self._logger.exception("Worker %s: http CALL %s failed", self.name, frame.path)1459             result, error, status = None, f"{type(exc).__name__}: {exc}", None1460         await self.send_reply(frame, result=result, error=error, status=status)1461 1462     async def serve_wsx(self, frame: Frame, payload: dict[str, Any]) -> None:1463         """Serve the ``wsx`` CALL form: a channel command of one page.1464 1465         Args:1466             frame: the CALL being answered.1467             payload: its whole payload — the ``wsx`` dict the front composed,1468                 the ``identity`` to route on and the ``user_frozen`` verdict.1469 1470         The command runs through the SAME prologue an http request does: the1471         call is written in the user's pendings, and his row is put in order —1472         adopted from the deposit when the verdict says he was frozen, because a1473         page's row is not in memory until its user is, and there would be1474         nowhere to write the channel. What differs is the middle: instead of1475         the hosted application, the command is resolved on this worker's own1476         dispatcher.1477         """1478         try:1479             result: Any = await self._serve_command(payload)1480             error: Any = None1481             status: int | None = None1482         except HTTPException as refused:1483             result, error, status = None, refused.detail, refused.status1484         except Exception as exc:1485             self._logger.exception("Worker %s: wsx CALL %s failed", self.name, frame.path)1486             result, error, status = None, f"{type(exc).__name__}: {exc}", None1487         await self.send_reply(frame, result=result, error=error, status=status)1488 1489     async def send_reply(1490         self, frame: Frame, *, result: Any = None, error: Any = None, status: int | None = None1491     ) -> None:1492         """Answer a CALL, carrying what happened here while it was being served.1493 1494         Args:1495             frame: the CALL being answered; its id is what makes this a REPLY.1496             result: the answer, when there is one.1497             error: what went wrong instead.1498             status: what the parent should answer, when this worker knows —1499                 only a refusal knows. Absent from every other failure, and the1500                 front then answers as it always did.1501 1502         Empties the ``worker_events`` of this CALL's slot onto the envelope — the1503         worker events are delivered once, and the send IS the delivery — and1504         attaches the photo when it is due. Then the slot is closed: an event1505         offered after the reply of the CALL that could have carried it is a1506         fault, and ``request_slot`` says so.1507         """1508         with self.dispatch_lock:1509             slot = self.request_slot1510             events = slot.worker_events1511         info: dict[str, Any] = {ENVELOPE_SLOT_WORKER_EVENTS: events}1512         if error is not None:1513             info["error"] = error1514             if status is not None:1515                 info["status"] = status1516             payload = ControlPayload().encode({"error": error, **({"status": status} if status else {})})1517             info["format"] = "control-json"1518         elif frame.info.get("format") == "http":1519             info["format"] = "http"1520             info["connection_id"] = result.get("connection_id")1521             payload = HttpRecord().encode_response(1522                 {key: result[key] for key in ("status", "headers", "body")}1523             )1524         else:1525             info["format"] = "control-json"1526             payload = ControlPayload().encode({"result": result})1527         reply = Frame(id=frame.id, method=REPLY_METHOD, path=frame.path,1528                       info=self._outbound(info), payload=payload)1529         # Serialization failures above leave this call's slot available for an1530         # explicit error reply. Once sending begins, failure is uncertain: close1531         # the wire so its caller fails instead of waiting forever or replaying.1532         try:1533             await self.stream.write(reply)1534         except FrameTooLarge:1535             # No bytes were written. Keep the events for the error reply and1536             # ensure the snapshot rejected with this response is sent again.1537             if ENVELOPE_SLOT_WORKER_SNAPSHOT in reply.info:1538                 self._population_changed = True1539             raise1540         except Exception:1541             self._request_slot_var.set(None)1542             await self.stream.close()1543             raise1544         with self.dispatch_lock:1545             slot.worker_events = []1546         self._request_slot_var.set(None)1547 1548     async def call(1549         self, path: str, data: Any = None, timeout: float | None = None1550     ) -> Any:1551         """CALL the commander on the lane and await its answer.1552 1553         Args:1554             path: the routing key of the call, which is what the parent serves.1555             data: the payload, JSON-serializable.1556             timeout: the caller's own deadline; None waits until the answer lands.1557 1558         Returns:1559             The ``result`` the parent put in its REPLY.1560 1561         Raises:1562             CommanderCallFailed: the answer carried an ``error`` instead.1563 1564         The frame carries its own id and the future is parked under it, so calls1565         placed without awaiting the first resolve each with its own answer, in1566         whatever order the parent gives them. Reachable from a pool thread1567         through ``run_on_loop``.1568         """1569         frame = Frame(method=CALL_METHOD, path=path, info={"format": "control-json"},1570                       payload=ControlPayload().encode(data))1571         reply_frame = await self.call_frame(frame, timeout=timeout)1572         reply = ControlPayload().decode(reply_frame.payload) or {}1573         if "error" in reply:1574             raise CommanderCallFailed(path, str(reply["error"]))1575         return reply.get("result")1576 1577     async def call_frame(self, frame: Frame, timeout: float | None = None) -> Frame:1578         """Call the parent with opaque bytes; parked calls belong to this wire."""1579         if frame.method != CALL_METHOD:1580             raise ValueError("parent request/reply calls require method CALL")1581         if frame.id in self._parent_calls or frame.id in self._abandoned_parent_calls:1582             raise ValueError("duplicate in-flight correlation id")1583         if len(self._parent_calls) >= 256:1584             raise ConnectionError("parent call capacity exhausted; request not sent")1585         future: asyncio.Future[Frame] = asyncio.get_running_loop().create_future()1586         self._parent_calls[frame.id] = future1587         self._parent_call_paths[frame.id] = frame.path1588         sent = False1589         try:1590             sent = True1591             await self.stream.write(frame)1592             if timeout is None:1593                 return await asyncio.shield(future)1594             return await asyncio.wait_for(asyncio.shield(future), timeout)1595         except (asyncio.CancelledError, TimeoutError):1596             if sent and not future.done():1597                 if len(self._abandoned_parent_calls) >= 256:1598                     await self.stream.close()1599                 else:1600                     self._abandoned_parent_calls[frame.id] = frame.path1601             raise1602         finally:1603             self._parent_calls.pop(frame.id, None)1604             self._parent_call_paths.pop(frame.id, None)1605             if not future.done():1606                 future.cancel()1607 1608     def send_message(self, page_id: str, path: str, data: Any = None) -> bool:1609         """Write one message of the site onto the page's websocket.1610 1611         Args:1612             page_id: the page to address — one of this worker's own.1613             path: what the client routes the message on.1614             data: the payload, as a Python value.1615 1616         Returns:1617             ``True`` when the message reached a socket, ``False`` when that1618             page speaks on none.1619 1620         Callable from a traffic-pool thread, which is where the site's own code1621         runs: the CALL goes up on the loop and this thread waits for its REPLY.1622         Fire and forget in meaning, not in mechanics — delivered says written1623         to the socket, never executed by the page (W-12).1624 1625         The payload is serialised HERE, before it reaches the lane: the lane is1626         opaque bytes. The browser's codec reads the JSON TYTX value; the1627         frontend only wraps that serialized text in the public WSX envelope.1628         """1629         with self.dispatch_lock:1630             row = self.page_register.get(page_id)1631             cid = row.get("connection_id") if row is not None else None1632         encoded = to_tytx(data, "json") if data is not None else None1633         reply_frame = self.run_on_loop(self.call_frame(Frame(1634             method=CALL_METHOD, path=WEBSOCKET_SEND_PATH,1635             info={"format": "wsx-json", "page_id": page_id, "reply_path": path,1636                   "cid": cid, "data_present": encoded is not None},1637             payload=encoded.encode("utf-8") if encoded is not None else b"")))1638         reply = ControlPayload().decode(reply_frame.payload) or {}1639         if "error" in reply:1640             raise CommanderCallFailed(WEBSOCKET_SEND_PATH, str(reply["error"]))1641         return bool((reply.get("result") or {}).get("delivered"))1642 1643     def run_on_loop(self, coro: Any) -> Any:1644         """Run a coroutine on this worker's loop from a pool thread, and wait.1645 1646         Args:1647             coro: what to run there — a ``call`` of this worker's, in practice.1648 1649         Returns:1650             Whatever the coroutine returned, on the calling thread.1651 1652         The bridge the site's own verbs need: they are served on a traffic-pool1653         thread, where blocking costs nothing, and the lane lives on the loop.1654         """1655         return asyncio.run_coroutine_threadsafe(coro, self.loop).result()1656 1657     def _resolve_parent_reply(self, frame: Frame) -> None:1658         """Hand the answer to the parked call; a caller already gone drops it."""1659         abandoned_path = self._abandoned_parent_calls.get(frame.id)1660         if abandoned_path is not None:1661             if frame.path != abandoned_path:1662                 raise ValueError("abandoned parent REPLY does not belong to its route")1663             self._abandoned_parent_calls.pop(frame.id, None)1664             return1665         future = self._parent_calls.get(frame.id)1666         if future is None or future.done():1667             self._logger.debug("Worker %s: the REPLY %s has no parked call", self.name, frame.id)1668             return1669         if frame.path != self._parent_call_paths[frame.id]:1670             raise ValueError("parent REPLY does not belong to its parked route")1671         future.set_result(frame)1672 1673     async def on_wire_lost(self) -> None:1674         """The wire is gone: leave, and save nothing.1675 1676         A process that lost its wire is unhealthy, so what it holds is not1677         vouched for: it writes no parcel to the deposit. Its users are lost at1678         the vertex, which drops what such a worker leaves and counts them in1679         ``frozen_users_discarded``.1680         """1681         self._logger.warning("Worker %s: its wire is gone — leaving, saving nothing", self.name)1682         self.exit_process()1683 1684     async def adopt_user(self, user: str) -> dict[str, Any]:1685         """Bring a user's store home from the deposit — the pull of the unified row.1686 1687         Args:1688             user: the user the envelope authorised, under its ``user_frozen``1689                 verdict.1690 1691         Returns:1692             The user register item, ``active``.1693 1694         Raises:1695             Whatever the deposit raised, to the call that made the trip; the1696             sisters of that burst are woken with a failure of their own.1697 1698         Adds the item as ``frozen`` when the user is unknown, marks it1699         ``unfreezing`` for the one call that makes the trip — the sisters of a1700         burst await that transition and read nothing — installs the parcel,1701         deletes it from the deposit and announces ``user_adopted``. A pull that1702         fails leaves NO row behind: his parcel is still in the deposit and the1703         mark is still on at the vertex, so the next request of his carries the1704         verdict again and the adoption is retried by construction. A resident1705         row would have to be reconciled with that verdict; an absent one is1706         already the truth.1707         """1708         with self.dispatch_lock:1709             item = self.user_register.get(user)1710             if item is None:1711                 item = self._add_user_item(user, state="frozen")1712             if item["state"] == "active":1713                 return item1714             waiting = self._unfreeze_waits.get(user)1715             if waiting is None:1716                 waiting = self._unfreeze_waits[user] = asyncio.Event()1717                 item["state"] = "unfreezing"1718                 mine = True1719             else:1720                 mine = False1721         if not mine:1722             await waiting.wait()1723             item = self.user_register.get(user)1724             if item is None:1725                 raise RuntimeError(1726                     f"the adoption of {user} failed in the call that made the trip; "1727                     "his parcel is still in the deposit"1728                 )1729             return item1730         try:1731             store = await self._take_from_deposit(user, self._read_user_parcel)1732             if store is None:1733                 self._logger.warning(1734                     "Worker %s: %s was announced frozen but has no store in the deposit",1735                     self.name,1736                     user,1737                 )1738             with self.dispatch_lock:1739                 if store is not None:1740                     item["store"] = store1741                 item["state"] = "active"1742                 self.add_worker_event("user_adopted", user=user)1743         finally:1744             with self.dispatch_lock:1745                 del self._unfreeze_waits[user]1746                 if item["state"] == "unfreezing":1747                     self._release_rows(user)1748                     self._logger.error(1749                         "Worker %s: the deposit would not give %s back; his row goes "1750                         "and the verdict on his next request retries the adoption",1751                         self.name,1752                         user,1753                     )1754             waiting.set()1755         return item1756 1757     async def adopt_connection(self, user: str, cid: str) -> dict[str, Any] | None:1758         """Look for this connection of ``user`` in the deposit, install it if it is there.1759 1760         Args:1761             user: the user the connection belongs to.1762             cid: the connection the request carries.1763 1764         Returns:1765             The connection register item — or None when nothing is held and1766             nothing is parked: the rows are the site's to bear, and it baptises1767             again while being served.1768 1769         Reads the parcel by itself (no verdict authorises a connection), deletes1770         it from the deposit and brings the connection and its pages into being1771         through the ordinary mutators: the worker events are the natural1772         ``new_connection``/``new_page``, never one of its own. ONE key is looked1773         up, because there is one identity: the deposit files the parcel under1774         the very id the cookie carries. A connection already held is already1775         home and spares the trip — a living row and a parked parcel of the same1776         connection cannot both exist.1777         """1778         with self.dispatch_lock:1779             item = self.connection_register.get(cid)1780         if item is not None:1781             return item1782         parcel = await self._take_from_deposit(user, self._read_connection_parcel, cid)1783         if parcel is None:1784             return None1785         with self.dispatch_lock:1786             resident = user in self.user_register1787             self.add_connection(cid, user, **parcel.get("connection", {}))1788             for page_id, fields in parcel.get("pages", {}).items():1789                 replayed = {1790                     key: fields.pop(key)1791                     for key in self.registry.page_row_class.fields_replayed1792                     if key in fields1793                 }1794                 page = self._add_page_item(page_id, cid, **fields)1795                 page.replay_fields(self.registry, replayed)1796                 # Announced AFTER the replay, so the event carries the1797                 # subscriptions the vertex rebuilds its index from.1798                 self.add_worker_event(1799                     "new_page",1800                     user=self.registry.page_user(page_id),1801                     page_id=page_id,1802                     connection_id=cid,1803                     **page.announcement_fields(),1804                 )1805             self._install_carried_store(user, parcel.get("store"), resident)1806             return self.connection_register.get(cid)1807 1808     def _install_carried_store(self, user: str, store: Any, resident: bool) -> None:1809         """Give a login's store to the row it belongs to, or let it die out loud.1810 1811         A connection that logged in carries what its guest had accumulated. It1812         becomes the user's own store when the row was born a moment ago with1813         this very connection; when a row of his was already here — his own state1814         came home first, or he is living on this worker already — the RESIDENT1815         wins and what the guest did before logging in dies, said out loud rather1816         than silently dropped. The caller holds the lock.1817         """1818         if store is None:1819             return1820         if resident:1821             self._logger.info(1822                 "Worker %s: %s was already here, so what his guest accumulated is dropped",1823                 self.name,1824                 user,1825             )1826             return1827         self.user_register.get(user)["store"] = store1828 1829     def change_connection_user(self, cid: str, user: str, **fields: Any) -> None:1830         """The login: this connection stops being anonymous and becomes his.1831 1832         Args:1833             cid: the connection that logged in.1834             user: the identity it belongs to from now on.1835             fields: whatever else the site puts on the connection row, stored1836                 verbatim.1837 1838         Raises:1839             ValueError: the target carries ``GUEST_PREFIX`` — nobody logs in as a1840                 guest, and the value crosses a border of trust to get here.1841             KeyError: this worker holds no such connection.1842 1843         Acts on the registers AT ONCE — the row changes owner, the user is born1844         here if he was unknown (the worker's way, with ``state`` and the three1845         clocks the photo reads), and the pages follow their connection without1846         being touched, their owner being derived through it — on the flag the1847         tail of this call reads, and on the departure a GUEST may have been1848         promised: one that is ceasing to exist is not carried to the deposit, so1849         his flag is dropped in this same breath. A previous identity that is no1850         guest KEEPS his: he stays here with whatever else he holds, the round1851         that promised his departure still means it, and cancelling it would leave1852         the wait on him at the vertex with nothing to release it. Announces the1853         login, which is what the vertex folds. The connection is written in this1854         request's slot as well: the login is the second way a request settles on1855         a connection the browser does not carry yet, and the cookie the front1856         writes on the way out is whatever the slot ends up holding.1857         """1858         if user.startswith(GUEST_PREFIX):1859             raise ValueError(f"{user!r} is reserved: nobody logs in as a guest")1860         with self.dispatch_lock:1861             connection = self.connection_register.get(cid)1862             if connection is None:1863                 raise KeyError(f"change_connection_user: no connection {cid!r} here")1864             previous_user = connection["user"]1865             if user not in self.user_register and not previous_user.startswith(GUEST_PREFIX):1866                 # An avatar switch onto an identity unknown here: the registry1867                 # would bring his row into being bare, and the photo reads1868                 # ``state`` and the three clocks off every row. Born here, the1869                 # worker's way, so the registry finds him and joins.1870                 self._add_user_item(user)1871             self.registry.change_connection_user(cid, user, **fields)1872             if previous_user.startswith(GUEST_PREFIX):1873                 self._transfer_flags.pop(previous_user, None)1874             slot = self.request_slot1875             slot.connection_id = cid1876             slot.connection_previous_user = previous_user1877             self.add_worker_event(1878                 "connection_user_changed",1879                 user=user,1880                 previous_user=previous_user,1881                 connection_id=cid,1882             )1883 1884     def open_request(self, user: str) -> None:1885         """Write one live call under the user it is for.1886 1887         Args:1888             user: the user the call belongs to.1889 1890         Adds to his pendings: nothing of his is parked while it is open.1891         """1892         with self.dispatch_lock:1893             self._pendings[user] = self._pendings.get(user, 0) + 11894 1895     async def close_request(self, user: str) -> None:1896         """Close one live call, and execute the departure that was waiting for it.1897 1898         Args:1899             user: the user the call belonged to.1900 1901         Takes the call out of his pendings and, when it was his last and a1902         departure of his is past the gate, lets him go now — the closure of a1903         whole worker and the cession of a single user hang on this same hook.1904         A user with nothing open was CUT by a quit that would not wait for this1905         call any longer: he is already parked, and there is nothing to close.1906         """1907         with self.dispatch_lock:1908             if user not in self._pendings:1909                 return1910             self._pendings[user] -= 11911             if self._pendings[user]:1912                 return1913             del self._pendings[user]1914             flag = self._transfer_flags.get(user)1915             waiting_order = self._freeze_order_waits.pop(user, None)1916         if waiting_order is not None:1917             waiting_order.set()1918         if flag is not None and self._transfers_open:1919             await self._execute_transfer(user, flag)1920 1921     async def freeze_designated_user(self, user: str) -> dict[str, Any]:1922         """Park one user on the parent's order, waiting for whatever holds him.1923 1924         Args:1925             user: the user the order names.1926 1927         Returns:1928             ``{"frozen": user}`` — sent only once he is in the deposit, so the1929             REPLY that carries it IS the confirmation.1930 1931         Raises:1932             KeyError: this worker does not host him.1933             RuntimeError: he stayed here for good — a departure already under1934                 way (a state the caller's own serialization makes impossible),1935                 or a deposit that refused his parcels.1936 1937         The waits are serialization, never policy: a pull bringing him home is1938         awaited on its own event, a call of his in flight on the event the end1939         of that call sets — and a call born while the parcels were on the disk1940         sends the order back to that same wait. The judgment of WHO sleeps is1941         the caller's alone; this verb only executes.1942         """1943         while True:1944             with self.dispatch_lock:1945                 if user not in self.user_register:1946                     raise KeyError(f"freeze order refused: no user {user!r} here")1947                 adopting = self._unfreeze_waits.get(user)1948                 drained = None1949                 if adopting is None and user in self._pendings:1950                     drained = self._freeze_order_waits.get(user)1951                     if drained is None:1952                         drained = self._freeze_order_waits[user] = asyncio.Event()1953             if adopting is not None:1954                 await adopting.wait()1955                 continue1956             if drained is not None:1957                 await drained.wait()1958                 continue1959             if not self._claim_departure(user):1960                 raise RuntimeError(1961                     f"freeze order refused: a departure of {user!r} is already under way"1962                 )1963             try:1964                 parked = await self.freeze_user(user)1965             finally:1966                 self._release_departure(user)1967             if parked:1968                 return {"frozen": user}1969             if parked is False:1970                 raise RuntimeError(f"freeze order failed: {user!r} stayed here")1971 1972     async def freeze_user(self, user: str) -> bool | None:1973         """Park a user in the deposit and announce that he left.1974 1975         Args:1976             user: the user leaving memory.1977 1978         Returns:1979             True when he went to the deposit; None when a call of his is what1980             holds him at the door — DEFERRED: that call's own end is where his1981             departure happens, and the flag that sent him here must stay1982             untouched; False when he STAYED for good as far as this attempt1983             goes — a row that is not ``active``, a semaphore that never came1984             free, or a deposit that refused the parcels (both failures counted,1985             B1).1986 1987         Writes his store and one parcel per connection under the folder1988         semaphore, announces ``user_frozen`` — placement always ``None``, the1989         vertex's to decide — and takes his rows out of memory whole. The row is1990         judged ONCE, at the door: whoever orders a departure BLOCKS him at the1991         vertex first (``GroupHandler.freeze_hosted_user``), so no work of his1992         can be born while the parcels are being written and nothing is ever1993         taken back off the deposit. A failed write aborts the whole departure:1994         the semaphore goes back, he stays alive where he is, nothing is1995         announced, and the failure is logged and counted.1996         """1997         with self.dispatch_lock:1998             item = self.user_register.get(user)1999             if item is None or item["state"] != "active":2000                 return False2001             if user in self._pendings:2002                 return None2003         try:2004             await self._take_folder_lock(user)2005         except TimeoutError:2006             self._freeze_failures += 12007             self._logger.error(2008                 "Worker %s: the folder of %s never came free; he stays here", self.name, user2009             )2010             return False2011         try:2012             store, connection_parcels = self._get_user_parcels(item)2013             await self._run_in_pool(2014                 self.service_pool,2015                 functools.partial(self._write_parcels, user, store, connection_parcels),2016             )2017             with self.dispatch_lock:2018                 self.add_worker_event("user_frozen", user=user, placement=None)2019                 self._release_rows(user)2020         except Exception:2021             self._freeze_failures += 12022             self._logger.exception(2023                 "Worker %s: the deposit refused the parcels of %s; he stays here",2024                 self.name,2025                 user,2026             )2027             return False2028         finally:2029             self.freeze_handler.release_lock(user, self.name)2030         return True2031 2032     async def freeze_connection(self, cid: str, previous_user: str) -> bool:2033         """Carry one logged-in connection to the deposit, under its new identity.2034 2035         Args:2036             cid: the connection the call that has just ended logged in.2037             previous_user: who owned it before that login — read off the slot of2038                 that very call, so no other call's tail can do this.2039 2040         Returns:2041             True when it went to the deposit; False when it stayed (somebody else2042             is already taking the previous identity away, or the deposit refused2043             the parcel, which is counted).2044 2045         Writes ONE parcel under the identity the connection now belongs to — the2046         connection, its pages, and the store the previous identity accumulated2047         when that identity was a guest, which is the only thing that makes an2048         anonymous visit survive its own login — then takes the rows out of memory:2049         the connection, its pages, the guest left behind, and the new identity2050         too when this was all it had here, so that his own next request finds a2051         row just born and installs the carried store instead of discarding it.2052         A departure that does not happen leaves EVERYTHING alive and announces2053         nothing: the identity stays resident on this worker with its connection2054         attached, which is a legitimate shape of the machine, and the failure is2055         counted. BOTH ways of not happening end there — a folder that never comes2056         free and a deposit that refuses the parcel — so the claim taken on the2057         previous identity is given back on every road out. A claim kept by a2058         departure that gave up would be held forever, and the whole worker could2059         never finish leaving.2060         """2061         with self.dispatch_lock:2062             user = self.connection_register.get(cid)["user"]2063         if not self._claim_departure(previous_user):2064             return False2065         try:2066             try:2067                 await self._take_folder_lock(user)2068             except TimeoutError:2069                 self._freeze_failures += 12070                 self._logger.error(2071                     "Worker %s: the folder of %s never came free; the connection of his "2072                     "login stays here",2073                     self.name,2074                     user,2075                 )2076                 return False2077             try:2078                 with self.dispatch_lock:2079                     parcel = self._connection_parcel(cid)2080                     if previous_user.startswith(GUEST_PREFIX):2081                         parcel["store"] = self.user_register.get(user)["store"]2082                     parcel = copy.deepcopy(parcel)2083                     self._detach_parcel_capture(parcel)2084                 await self._run_in_pool(2085                     self.service_pool,2086                     functools.partial(2087                         self.freeze_handler.write_connection_register_item,2088                         user,2089                         cid,2090                         parcel,2091                         writer=self.name,2092                         cause="login",2093                         group=self.group,2094                     ),2095                 )2096             except Exception:2097                 self._freeze_failures += 12098                 self._logger.exception(2099                     "Worker %s: the deposit refused the connection of %s; he stays here",2100                     self.name,2101                     user,2102                 )2103                 return False2104             finally:2105                 self.freeze_handler.release_lock(user, self.name)2106         finally:2107             self._release_departure(previous_user)2108         with self.dispatch_lock:2109             self._release_login_rows(cid, user, previous_user)2110         return True2111 2112     def plan_transfers(2113         self, *, transfer_users: Iterable[str] = ()2114     ) -> dict[str, tuple[dict[str, Any], str | None]]:2115         """Pair every user with the flag the next photo carries, and shut the gate.2116 2117         Args:2118             transfer_users: the users this round cedes, named by whoever judged2119                 them — the group reading the silence off the photo's clocks, or2120                 the quit ceding everybody. This rung names nobody.2121 2122         Returns:2123             Every user, mapped to his register item and his flag: ``None`` kept,2124             ``'T'`` ceded.2125 2126         Remembers the flags that are not ``None`` and starts the clock of the2127         gate: nothing departs before ``transfer_start_delay`` has passed. A row2128         that is not active is left to the vertex. Once ``quit`` has begun the2129         plan is terminal: everybody is ceded, a row still mid-adoption included2130         (he is a straggler, ceded as soon as his pull lands), no flag already2131         given is taken back, and the gate already open is not shut again. Every2132         plan that leaves flags behind wakes the cycle, so a man named here while2133         the quit waits for a straggler leaves with the others.2134         """2135         now = time.time()2136         ceded = set(transfer_users)2137         transfers: dict[str, tuple[dict[str, Any], str | None]] = {}2138         with self.dispatch_lock:2139             if not self._quitting:2140                 self._transfer_flags = {}2141             for user, item in self._get_register_rows(self.user_register):2142                 flag = self._transfer_flags.get(user)2143                 if flag is None and (self._quitting or user in ceded):2144                     if item["state"] == "active" or self._quitting:2145                         flag = "T"2146                 if flag is not None:2147                     self._transfer_flags[user] = flag2148                 transfers[user] = (item, flag)2149             if not self._quitting:2150                 self._transfers_start_ts = now + self.transfer_start_delay2151             if self._transfer_flags:2152                 # A flag is a promise the vertex must read: the photo that2153                 # carries it is due whatever the throttle says.2154                 self._population_changed = True2155                 self._transfers_done.clear()2156                 self._transfers_changed.set()2157             elif not self._quitting:2158                 self._transfers_done.set()2159         return transfers2160 2161     async def execute_transfers(self) -> None:2162         """Wait out the gate, then let the flagged users go, one at a time.2163 2164         The ceded go to the deposit as soon as no call of theirs is in flight;2165         whoever still has one is taken by the end of that call. The loop2166         breathes between two users.2167 2168         An ordinary cycle passes over the flags it found and comes back. The2169         cycle of a ``quit`` does not: it re-reads the flag map at every pass —2170         so a man a later shot names, or one whose adoption has only just landed,2171         leaves with the others — and returns only when the departures are over,2172         which is no flag left and nobody on his way out. Between two passes it2173         sleeps on the changes, never on a clock: a flag added, a departure2174         ended, and it looks again.2175         """2176         await asyncio.sleep(self._transfers_start_ts - time.time())2177         while True:2178             self._transfers_changed.clear()2179             for user, flag in list(self._transfer_flags.items()):2180                 await self._execute_transfer(user, flag)2181                 await asyncio.sleep(0)2182             if not self._quitting:2183                 return2184             self._settle_transfers()2185             if self._transfers_done.is_set():2186                 return2187             await self._transfers_changed.wait()2188 2189     async def quit(self, *, freezer_path: str | None = None) -> None:2190         """Leave: everybody departs, the last call is waited for, the process ends.2191 2192         Args:2193             freezer_path: where the parcels of THIS departure go, when they must2194                 not go to the working deposit — the reboot directory of a soft2195                 quit. The handler is replaced for good: nothing else will use2196                 this process's deposit.2197 2198         Flags every user for cession, waits the gate, parks them as their calls2199         end, and only then leaves the process. From here the plan is this2200         routine's: a shot taken while it waits for a straggler may name a2201         newborn among the departing, but can take nobody off them, and the2202         cycle picks that newborn up itself. A straggler is whoever is not gone2203         yet — a call of his in flight, or a pull of his still on the way — and2204         the exit is behind the last of them, because a process that left with2205         an adoption in flight would shut the pool it is running on. A departure2206         that fails is counted where it fails, so the exit is reached whatever2207         the disk says. Rebirth is not the worker's: whoever wants a successor2208         launches one.2209         """2210         if freezer_path is not None:2211             self.freeze_handler = FreezeHandler(freezer_path)2212         self._flag_everybody_for_departure()2213         departures = asyncio.ensure_future(self.execute_transfers())2214         done, _ = await asyncio.wait({departures}, timeout=PENDING_CALL_GRACE_SECONDS)2215         if not done:2216             self._cut_stragglers()2217             await departures2218         self.exit_process()2219 2220     def _cut_stragglers(self) -> None:2221         """Give up on the calls still in flight so their users can be parked.2222 2223         A call is not interrupted — the site runs it on a thread of the traffic2224         pool and a thread cannot be killed. What is dropped is the WAIT: the2225         users are taken out of the pendings, which is the one thing keeping2226         ``freeze_user`` from parking them, and the cycle is woken to take them.2227         An ordered freeze parked on the end of one of those calls is woken with2228         them: the wait it is on would otherwise never be set, since the call it2229         was waiting for is the one being given up on.2230         The call finishes into a process that is leaving and its answer is lost;2231         the front turns that into the same 503 a refusal gets, because the wire2232         died on a server that is quitting.2233 2234         Accepted, and weighed: a call stuck this long is waiting on something,2235         not writing, so the parcel it photographs is almost always quiet. The2236         rare loser is a write that lands after the photo — lost — or a pickle2237         that meets a store mid-change, which fails loudly on that user alone.2238         """2239         with self.dispatch_lock:2240             cut = list(self._pendings)2241             self._pendings.clear()2242             for waiting_order in self._freeze_order_waits.values():2243                 waiting_order.set()2244             self._freeze_order_waits.clear()2245         if cut:2246             self._logger.warning(2247                 "Worker %s: %s user(s) still had a call in flight %.1fs into the quit — "2248                 "parking them without it: %s",2249                 self.name,2250                 len(cut),2251                 PENDING_CALL_GRACE_SECONDS,2252                 ", ".join(sorted(cut)),2253             )2254         self._transfers_changed.set()2255 2256     def exit_process(self) -> None:2257         """Leave the process — the last act of ``quit`` and of ``on_wire_lost``.2258 2259         Closes the wire, which is what ends the read the shell is parked on, and2260         stops the two pools. Nothing here kills a process: the shell returns2261         from its run and the process ends with it.2262         """2263         self._exited = True2264         if self.stream is not None:2265             self.stream.writer.close()2266         self.traffic_pool.shutdown(wait=False)2267         self.service_pool.shutdown(wait=False)2268 2269     @property2270     def _snapshot_due(self) -> bool:2271         """Whether the next envelope out owes a photo: a change, or a stale one."""2272         return (2273             self._population_changed2274             or time.time() - self._snapshot_sent_ts >= self.worker_snapshot_ttl2275         )2276 2277     @property2278     def _transfers_open(self) -> bool:2279         """Whether the gate opened on the departures last announced."""2280         return time.time() >= self._transfers_start_ts2281 2282     def _outbound(self, data: dict[str, Any]) -> dict[str, Any]:2283         """Attach the photo to an envelope going out, when one is due."""2284         if not self._snapshot_due:2285             return data2286         data[ENVELOPE_SLOT_WORKER_SNAPSHOT] = self.worker_snapshot2287         self._snapshot_sent_ts = time.time()2288         self._population_changed = False2289         return data2290 2291     def begin_quit(self, *, freezer_path: str | None = None) -> None:2292         """Flag everybody for departure now, and leave on a task of its own.2293 2294         Args:2295             freezer_path: where the parcels of THIS departure go; see ``quit``.2296 2297         Acts on the flags before it returns, so the REPLY the caller sends next2298         carries the photo with every user ceded; ``quit`` runs on2299         ``_quit_task`` and reaches the wire only after that REPLY is on it.2300         """2301         self._flag_everybody_for_departure()2302         self._quit_task = asyncio.create_task(self.quit(freezer_path=freezer_path))2303 2304     def _flag_everybody_for_departure(self) -> None:2305         """Cede every user and make the plan terminal: sets ``_quitting`` and the flags."""2306         self._quitting = True2307         self.plan_transfers(transfer_users=self.user_register.keys())2308 2309     async def _guarded_call(self, frame: Frame) -> None:2310         """Serve one CALL on a slot of its own, with the guard inside the task.2311 2312         The slot is opened HERE, on the task that is this CALL's and nobody2313         else's: whatever the service announces — on the loop or on the pool2314         thread the stitching runs on — lands in it and leaves with THIS reply.2315         The guard keeps the task from dying unretrieved.2316         """2317         self.open_request_slot()2318         try:2319             await self.answer_call(frame)2320         except Exception as failure:2321             self._logger.exception("Worker %s: service of CALL %s failed", self.name, frame.path)2322             if self._request_slot_var.get() is not None:2323                 try:2324                     await self.send_reply(frame, error=f"{type(failure).__name__}: {failure}")2325                 except Exception:2326                     # A malformed/oversized control envelope cannot carry even2327                     # the error. Link loss must release every parked caller.2328                     await self.stream.close()2329                     self._logger.exception("Worker %s: error reply failed", self.name)2330 2331     async def _serve_request(self, payload: dict[str, Any]) -> dict[str, Any]:2332         """The pendings, the row and the stitching — everything that can fail as one.2333 2334         The call is written in the user's pendings FIRST, before the row is put2335         in order: the pendings cover the adoption too, so no departure can wake2336         in the gap between the loading and the serving of the same call. Then2337         the row (the store adopted when the verdict authorises it, the2338         connection found by itself, the clocks stamped), and then the hosted2339         application, awaited on the task of this CALL — the slot is that task's,2340         so nothing needs copying; a WSGI site behind the shortcut goes to the2341         traffic pool from inside the adapter, because WSGI is synchronous. The2342         end of the call is where a2343         departure that had to wait for it happens — on the connection the2344         SERVICE settled on, which is the one the site named while serving when2345         it named one, and the one the request came in on otherwise.2346         """2347         user = payload.get("identity")2348         served: dict[str, Any] = {}2349         async with self._serving(payload, payload["http"]["cid"]):2350             service_started = time.monotonic()2351             try:2352                 try:2353                     # The message is admitted FIRST: whether this page may speak2354                     # at all is the client's own business, and it is answered2355                     # before anybody asks what would have served it.2356                     async with self._page_queue(payload["http"].get("page_id")):2357                         seam = self.hosted_app_seam2358                         served = await seam.serve(payload["http"], payload.get("identity"))2359                 finally:2360                     # On a pool thread, with this request's slot still open and2361                     # BEFORE the counters, the pendings and the login's tail:2362                     # a consumer delivers here what its verbs left on the slot,2363                     # and does it from a thread, because it may call up the lane.2364                     await self.run_sync(self.on_request_served)2365                 served["connection_id"] = self.request_slot.connection_id2366             finally:2367                 # Counted whatever the stitching did: a call that failed or ran2368                 # long is exactly the one the measure must not lose.2369                 if user is not None:2370                     self._record_service(user, time.monotonic() - service_started)2371             return served2372 2373     @contextlib.asynccontextmanager2374     async def _page_queue(self, page_id: str | None) -> Any:2375         """Refuse a page with no open channel, or hold its queue for the whole call.2376 2377         Args:2378             page_id: the page this call belongs to, ``None`` for a request that2379                 names none — an ordinary http request of the site.2380 2381         Raises:2382             HTTPException: 409, when the page named here never opened its2383                 channel or was never born on this worker. A message of a page2384                 is refused before anything is served: ``openchannel`` is what2385                 makes a page addressable, and a message that skips it is a2386                 client out of step with its own row. It is the CLIENT's2387                 mistake, not the site's, so it carries a status of its own and2388                 reaches the browser with these words rather than as the 502 of2389                 an upstream that broke (#70).2390 2391         A page that opened its channel with ``sequential`` is served one call at2392         a time: the lock lives on ITS row, so pages never wait for each other,2393         and it is taken around the WHOLE call — the slot is open and the call is2394         already in the user's pendings, so a freeze waits for the queue too,2395         which is what it must do. Every other call passes straight through.2396         """2397         if page_id is None:2398             yield2399             return2400         with self.dispatch_lock:2401             row = self.page_register.get(page_id)2402         if row is None or row.get("wsx") is None:2403             raise HTTPException(2404                 409,2405                 f"page {page_id!r} has no open channel on this worker: "2406                 "send openchannel before any message of its own",2407             )2408         # The site owns the connection name; it may differ from the cookie2409         # carried by the request. This gate checks channel readiness and order.2410         channel = row["wsx"]2411         if not isinstance(channel, dict) or not channel.get("sequential"):2412             yield2413             return2414         async with row["call_lock"]:2415             yield2416 2417     @contextlib.asynccontextmanager2418     async def _serving(self, payload: dict[str, Any], cid: str | None) -> Any:2419         """The opening and the closing every form of call shares.2420 2421         Args:2422             payload: the CALL's whole payload — the identity is read from it.2423             cid: the connection this call came in on.2424 2425         The call is written in the user's pendings FIRST, before the row is put2426         in order: the pendings cover the adoption too, so no departure can wake2427         in the gap between the loading and the serving of the same call. At the2428         end the call leaves the pendings — which is where a departure that was2429         waiting for it happens — and the login's tail runs, on the connection2430         the SERVICE settled on.2431 2432         What differs between the forms is only what happens in between: the2433         hosted application for a request, the dispatcher for a channel command.2434         """2435         user = payload.get("identity")2436         if user is not None:2437             self.open_request(user)2438         try:2439             await self._resolve_row(user, cid, payload)2440             yield user2441         finally:2442             if user is not None:2443                 await self.close_request(user)2444             slot = self.request_slot2445             if slot.connection_previous_user is not None:2446                 await self.freeze_connection(slot.connection_id, slot.connection_previous_user)2447 2448     async def _serve_command(self, payload: dict[str, Any]) -> Any:2449         """The shared prologue, and the command resolved on this worker's tree."""2450         command = payload["wsx"]2451         async with self._serving(payload, command.get("cid")):2452             return self.worker_dispatcher.route.node(WSX_COMMAND_PATH)(2453                 **{key: value for key, value in command.items() if key != "cid"}2454             )2455 2456     async def _resolve_row(self, user: str | None, cid: str, payload: dict[str, Any]) -> None:2457         """Put the row of an incoming request in order.2458 2459         Who the user IS was decided by the caller: the identity the front2460         routed on, or None for a cookie the indexes do not carry yet — the2461         ANONYMOUS first visit, which touches no register: the site baptises2462         while serving, and the rows are born from its own verbs. For a known2463         user the store comes home only if the envelope authorises it, and the2464         connection is looked for in the deposit with no authorisation at all.2465         """2466         if user is None:2467             return2468         if payload.get("user_frozen"):2469             await self.adopt_user(user)2470         await self.adopt_connection(user, cid)2471         self._stamp_request(user, cid)2472 2473     def _record_service(self, user: str, seconds: float) -> None:2474         """Add one served call to the user's cumulative counters.2475 2476         Args:2477             user: whom the call belonged to.2478             seconds: how long the stitching held a traffic thread for it.2479 2480         Acts on his register item: ``served_call_count`` and ``service_seconds``2481         grow monotonically, raw readings for the envelope layer's deltas. A user2482         whose row is gone — dropped mid-call — is counted nowhere, silently.2483         """2484         with self.dispatch_lock:2485             item = self.user_register.get(user)2486             if item is None:2487                 return2488             item["served_call_count"] = item.get("served_call_count", 0) + 12489             item["service_seconds"] = item.get("service_seconds", 0.0) + seconds2490 2491     def _stamp_request(self, user: str, cid: str) -> None:2492         """Stamp the user a request came in for, and his connection under it.2493 2494         The http form names no page: what it proves is a real call of that2495         user, which is the clock the group's judgment reads off the photo.2496         """2497         with self.dispatch_lock:2498             connection = self.connection_register.get(cid)2499             row = self.user_register.get(user)2500             items = [item for item in (connection, row) if item is not None]2501             self._stamp_items(items, ("last_rpc_ts",))2502 2503     def _stamp_items(self, items: Iterable[dict[str, Any]], clocks: Iterable[str]) -> float:2504         """Write the server's own instant on the items given.2505 2506         ``last_refresh_ts`` always, the clocks named besides it: a client cannot2507         buy immortality by claiming activity, so the instant is taken here.2508         """2509         now = time.time()2510         for item in items:2511             item["last_refresh_ts"] = now2512             for clock in clocks:2513                 item[clock] = now2514         return now2515 2516     def _user_row(self, user: str, item: dict[str, Any]) -> dict[str, Any]:2517         """One user item projected for the photo: state, clocks, service counters.2518 2519         Args:2520             user: whose row it is — the pendings are keyed by him, not by the item.2521             item: his user register item.2522 2523         Returns:2524             The scalar projection: his state, his connection count, his three2525             clocks, and the three service counters — the two cumulatives the2526             calls of his wrote (0 before his first), plus how many are open now.2527         """2528         row: dict[str, Any] = {2529             "state": item["state"],2530             "connection_count": len(item["connections"]),2531             "served_call_count": item.get("served_call_count", 0),2532             "service_seconds": item.get("service_seconds", 0.0),2533             "pending_call_count": self._pendings.get(user, 0),2534         }2535         row.update({clock: item[clock] for clock in CLOCK_NAMES})2536         return row2537 2538     async def _run_in_pool(self, pool: ThreadPoolExecutor, work: Callable[[], Any]) -> Any:2539         """Run one piece of synchronous work on the pool it belongs to.2540 2541         The work runs under a COPY of the calling task's context, so the thread2542         finds the request slot of the CALL it serves: what the site announces2543         there lands in that CALL's events.2544         """2545         return await asyncio.get_running_loop().run_in_executor(2546             pool, contextvars.copy_context().run, work2547         )2548 2549     async def _execute_transfer(self, user: str, flag: str) -> None:2550         """Let one flagged user go to the deposit.2551 2552         The departure is CLAIMED before the first await — the transfer cycle,2553         the end-of-call hook and the mass cycle all come through the same claim,2554         and whoever arrives second finds it taken and nothing to do. A row still2555         mid-adoption is WAITED for and never parked under its own pull: that is2556         how the quit keeps a straggler whose store is still travelling. What2557         goes wrong for one user is counted here and goes no further: a whole2558         worker leaving must not be stopped by one refused parcel. The flag is2559         the promise (owner, 2026-08-16): only a departure that HAPPENED, a2560         counted failure or the man's own absence consumes it — a freeze2561         deferred to a call's tail keeps it, and the wakeup set below lets the2562         quit's cycle find it again, so no instant between a closing call and a2563         releasing claim can drop a man between two hands.2564 2565         No CALL is being answered here, so the departure gets a slot of its own2566         and what it announces goes up the second channel — ONE CALL of this2567         worker's per departure, placed once the departure is over.2568         """2569         slot_token = self._request_slot_var.set(self.build_request_slot())2570         try:2571             await self._execute_transfer_in_slot(user, flag)2572         finally:2573             announced = self.request_slot.worker_events2574             self._request_slot_var.reset(slot_token)2575         if announced:2576             await self.announce_worker_events(announced)2577 2578     async def _execute_transfer_in_slot(self, user: str, flag: str) -> None:2579         """The departure itself: claim, wait out an adoption, freeze, settle the flag."""2580         with self.dispatch_lock:2581             if self._transfer_flags.get(user) != flag:2582                 return2583             if user in self._pendings:2584                 return2585             if not self._claim_departure(user):2586                 return2587             adopting = self._unfreeze_waits.get(user)2588         settled = True2589         try:2590             if adopting is not None:2591                 await adopting.wait()2592             settled = await self.freeze_user(user) is not None2593         except Exception:2594             settled = True2595             self._freeze_failures += 12596             self._logger.exception(2597                 "Worker %s: the departure of %s fell over; the others go on", self.name, user2598             )2599         finally:2600             with self.dispatch_lock:2601                 if settled or user not in self.user_register:2602                     self._transfer_flags.pop(user, None)2603                 self._release_departure(user)2604                 self._transfers_changed.set()2605 2606     async def announce_worker_events(self, worker_events: list[dict[str, Any]]) -> None:2607         """Send up what happened while no CALL was being served — the second channel.2608 2609         Args:2610             worker_events: the events of a slot no reply will ever carry.2611 2612         ONE CALL to the vertex, its envelope shaped as a reply's — the photo2613         rides it when due — and folded there the same way. The REPLY is the2614         acknowledgement. A vertex that answers an error or nothing within2615         ``ANNOUNCE_TIMEOUT_SECONDS`` has lost this announcement: counted and2616         logged, never retried — a wire that does not answer is a vertex that is2617         gone, and its death settles what this process held.2618         """2619         data = self._outbound({ENVELOPE_SLOT_WORKER_EVENTS: worker_events})2620         data["worker"] = self.name2621         try:2622             await self.call(ANNOUNCE_OP_PATH, data, timeout=ANNOUNCE_TIMEOUT_SECONDS)2623         except (CommanderCallFailed, TimeoutError):2624             self._announce_failures += 12625             self._logger.exception(2626                 "Worker %s: %d worker event(s) lost, the vertex did not take the announcement",2627                 self.name,2628                 len(worker_events),2629             )2630 2631     def _claim_departure(self, user: str) -> bool:2632         """Take the one departure a user is allowed at a time.2633 2634         Args:2635             user: the user about to leave.2636 2637         Returns:2638             True when the claim is the caller's; False when somebody is already2639             taking him away.2640 2641         Marks him departing. Three roads reach a freeze — the transfer cycle,2642         the end-of-call hook, the mass cycle of a lost wire — and the second to2643         arrive must find the door shut, or it would queue on a folder semaphore2644         this same worker is holding.2645         """2646         with self.dispatch_lock:2647             if user in self._departing_users:2648                 return False2649             self._departing_users.add(user)2650             return True2651 2652     def _release_departure(self, user: str) -> None:2653         """Give the claim back, say so if that was the last departure, wake the cycle.2654 2655         The wakeup is owed to whoever found this claim TAKEN and went away with2656         nothing done: the ordered freeze and the transfer cycle reach the same2657         user by two roads, and the loser leaves the flag where it is. Without2658         this the cycle of a quit would sleep on a change that nobody else is2659         going to announce.2660         """2661         with self.dispatch_lock:2662             self._departing_users.discard(user)2663             self._settle_transfers()2664             self._transfers_changed.set()2665 2666     def _settle_transfers(self) -> None:2667         """Declare the departures over: no flag left, and nobody on his way out.2668 2669         Both halves are asked, because a flag popped by the man who is at that2670         instant writing his parcels would otherwise let a ``quit`` leave from2671         under him.2672         """2673         with self.dispatch_lock:2674             if not self._transfer_flags and not self._departing_users:2675                 self._transfers_done.set()2676 2677     def _get_user_parcels(self, item: dict[str, Any]) -> tuple[Any, dict[str, dict[str, Any]]]:2678         """The store payload and the connection parcels, photographed off the registers.2679 2680         Args:2681             item: the user register item leaving memory.2682 2683         Returns:2684             Two things: the payload of his store, and one parcel per connection2685             in the shape the adoption reads back, in the order they are written.2686 2687         The photograph is DEEP and is taken under the dispatch lock: at the2688         scale of these parcels the copy is memory work of microseconds, and2689         nothing live then crosses onto the service pool, where the deposit2690         pickles what it is handed with no lock of ours at all. The write itself2691         is disk and runs with the lock let go, because a loop-side mutation must2692         never wait on a spinning disk.2693         """2694         with self.dispatch_lock:2695             parcels = (2696                 item["store"],2697                 {cid: self._connection_parcel(cid) for cid in sorted(item["connections"])},2698             )2699             store, connection_parcels = copy.deepcopy(parcels)2700             for parcel in connection_parcels.values():2701                 self._detach_parcel_capture(parcel)2702             return store, connection_parcels2703 2704     def _detach_parcel_capture(self, parcel: dict[str, Any]) -> None:2705         """Take the capture off the copied page stores, before they are pickled.2706 2707         A copied store arrives with the subscriber of the LIVE row still on it —2708         the copy would feed a queue that is not its own, and a subscriber does2709         not pickle at all. The birth on the other side attaches its own.2710         """2711         for page_id, fields in parcel["pages"].items():2712             self.registry.detach_page({**fields, "register_item_id": page_id})2713 2714     def _write_parcels(2715         self, user: str, store: Any, connection_parcels: dict[str, dict[str, Any]]2716     ) -> None:2717         """Write the store and the connection parcels already copied out of the registers.2718 2719         Runs on the service pool — this is real disk work — and holds NO lock of2720         the dispatch: what it writes was photographed before it was handed over,2721         so nothing here reads a register a mutation could be changing.2722         """2723         self.freeze_handler.write_user_register_item(2724             user, store, writer=self.name, cause="freeze", group=self.group2725         )2726         for cid, parcel in connection_parcels.items():2727             self.freeze_handler.write_connection_register_item(2728                 user, cid, parcel, writer=self.name, cause="freeze", group=self.group2729             )2730 2731     def _connection_parcel(self, cid: str) -> dict[str, Any]:2732         """One connection with its pages, in the shape the adoption reads back.2733 2734         What each row leaves behind is the row's own knowledge2735         (``fields_left_behind`` on its class): the edges of the tree, which the2736         folder already says; the lock; the live objects bound to the Bags of2737         THIS process, which the birth on the other side makes anew. What2738         travels but must be put back after the birth is the row's too2739         (``fields_replayed``), and the wake asks it.2740         """2741         item = self.connection_register.get(cid)2742         pages = {page_id: self.page_register.get(page_id) for page_id in sorted(item["pages"])}2743         return {2744             # The parcel names its own connection: the deposit filename hashes2745             # the id one-way, and the wake reads it back from here.2746             "connection_id": cid,2747             "connection": {2748                 key: value for key, value in item.items() if key not in item.fields_left_behind2749             },2750             "pages": {2751                 page_id: {2752                     key: value for key, value in page.items() if key not in page.fields_left_behind2753                 }2754                 for page_id, page in pages.items()2755             },2756         }2757 2758     def _release_login_rows(self, cid: str, user: str, previous_user: str) -> None:2759         """Take out of memory what the login left here: the caller holds the lock.2760 2761         The connection and its pages are gone to the deposit; the guest that used2762         to own it has nothing left anywhere and goes with them; and the identity2763         that received it goes too when this connection was all he had here — a2764         row left empty would make his own next request look like a resident and2765         throw away the store his connection is carrying. A previous identity that2766         is NOT a guest STAYS, empty if this was his last connection: he is a2767         person the machine knows, and the idleness sweep is what parks him.2768 2769         Losing that row is ANNOUNCED, and with a word of its own: he has not gone2770         to the deposit and he has not left the machine — he lives wherever he2771         lived before this login, and the only rung that has to hear it is the2772         handler of this process, whose list of who is on board is what a wild2773         death is settled on. A death reading a name whose rows are gone would2774         report the loss of somebody who is perfectly well somewhere else.2775         The pages leaving are announced as ``drop_pages`` in every case — the2776         vertex's projection follows the page rows, resident or not.2777         """2778         page_ids = sorted(self.connection_register.get(cid)["pages"])2779         if page_ids:2780             self.add_worker_event("drop_pages", user=user, page_ids=page_ids)2781         for page_id in page_ids:2782             self._remove_page_item(page_id)2783         self._remove_connection_item(cid)2784         if previous_user.startswith(GUEST_PREFIX) and previous_user in self.user_register:2785             self._remove_user_item(previous_user)2786         resident = self.user_register.get(user)2787         if resident is not None and not resident["connections"]:2788             self._remove_user_item(user)2789             self.add_worker_event("user_rows_released", user=user)2790 2791     def _release_rows(self, user: str) -> None:2792         """Take a user's rows out of memory; his departure names him, this names his pages.2793 2794         Everything of his goes — pages, connections, the user row itself. No2795         emptied row is left resident: he is the vertex's to place now, and2796         whatever comes back for him starts from the parcel in the deposit. Two2797         departures end here — the freeze that parked him, and the pull that2798         failed to bring him home. The pages leaving are announced as2799         ``drop_pages``: what the vertex keeps per page is a projection of the2800         page rows, and a page taken out of memory is taken out of the2801         projection — the wake's own announcements rebuild it.2802         """2803         item = self.user_register.get(user)2804         for cid in sorted(item["connections"]):2805             page_ids = sorted(self.connection_register.get(cid)["pages"])2806             if page_ids:2807                 self.add_worker_event("drop_pages", user=user, page_ids=page_ids)2808             for page_id in page_ids:2809                 self._remove_page_item(page_id)2810             self._remove_connection_item(cid)2811         self._remove_user_item(user)2812         self._unfreeze_waits.pop(user, None)2813 2814     def _add_user_item(self, user: str, **fields: Any) -> dict[str, Any]:2815         """Put a user item in the register, born stamped and with a live store."""2816         fields.setdefault("state", "active")2817         return self.registry.new_user(user, **self._stamped(**fields))2818 2819     def _add_connection_item(self, cid: str, user: str, **fields: Any) -> dict[str, Any]:2820         """Put a connection item in the register and join it to its user."""2821         return self.registry.new_connection(cid, user, **self._stamped(**fields))2822 2823     def _add_page_item(self, page_id: str, cid: str, **fields: Any) -> dict[str, Any]:2824         """Put a page item in the register and join it to its connection."""2825         return self.registry.new_page(2826             page_id,2827             user=self.connection_register.get(cid)["user"],2828             connection_id=cid,2829             **self._stamped(**fields),2830         )2831 2832     def _remove_page_item(self, page_id: str) -> None:2833         """Take a page item out of the register, capture and edge with it."""2834         self.registry.drop_page(page_id, cascade=False)2835 2836     def _remove_connection_item(self, cid: str) -> None:2837         """Take a connection item out of the register and off its user."""2838         self.registry.drop_connection(cid, cascade=False)2839 2840     def _remove_user_item(self, user: str) -> None:2841         """Take a user item out of the register."""2842         self.registry.user_items.drop(user)2843 2844     def _get_register_rows(self, register: Register) -> list[tuple[str, dict[str, Any]]]:2845         """Every key of a register paired with its live item, in one snapshot."""2846         return [(key, register.get(key)) for key in register.keys()]2847 2848     def _drop_emptied_user(self, user: str) -> None:2849         """Take the user away when the connection just removed was his last."""2850         if not self.user_register.get(user)["connections"]:2851             self._remove_user_item(user)2852             self._unfreeze_waits.pop(user, None)2853             self.add_worker_event("drop_user", user=user)2854 2855     def _stamped(self, **fields: Any) -> dict[str, Any]:2856         """An item born with the three clocks on the server's own instant."""2857         now = time.time()2858         for clock in CLOCK_NAMES:2859             fields.setdefault(clock, now)2860         return fields2861 2862     async def _take_folder_lock(self, user: str) -> None:2863         """Wait on the loop until the semaphore of the user's folder is this worker's.2864 2865         Args:2866             user: the user whose folder is being entered.2867 2868         Raises:2869             TimeoutError: ``deposit_lock_wait_limit`` passed and it never came2870                 free — a folder nobody gave back, which is a disk to look at and2871                 never something to go on waiting for in silence.2872 2873         The wait is a coroutine and never a thread: whoever holds the semaphore2874         is working, and a thread parked here would be a thread not doing that2875         work. The FIRST miss says out loud who is holding it, once for this2876         wait and not once per look. The limit is a technical floor, not a2877         budget: how long a REQUEST may wait before the vertex answers it2878         something else is the Commander's parking budget, and arrives with the2879         fold.2880         """2881         if self.freeze_handler.take_lock(user, self.name):2882             return2883         self._logger.warning(2884             "Worker %s: the deposit folder of %s is held by %s; waiting for it",2885             self.name,2886             user,2887             self.freeze_handler.lock_holder(user),2888         )2889         deadline = time.time() + self.deposit_lock_wait_limit2890         while not self.freeze_handler.take_lock(user, self.name):2891             if time.time() >= deadline:2892                 raise TimeoutError(2893                     f"the deposit folder of {user} was held by "2894                     f"{self.freeze_handler.lock_holder(user)!r} for "2895                     f"{self.deposit_lock_wait_limit}s"2896                 )2897             await asyncio.sleep(self.deposit_lock_retry_interval)2898 2899     async def _take_from_deposit(self, user: str, read: Any, *args: Any) -> Any:2900         """Hold the user's folder, read one parcel and delete it, then let go.2901 2902         The reading is real disk work and runs on the service pool; the wait for2903         the semaphore is not, and stays a coroutine on the loop. Releasing the2904         semaphore takes the folder away when the parcel read was the last thing2905         in it. A semaphore that never comes free raises out of here and travels2906         to the caller, whose REPLY says so: an adoption nobody can make is an2907         answered failure, never a request left hanging.2908         """2909         await self._take_folder_lock(user)2910         try:2911             return await self._run_in_pool(self.service_pool, functools.partial(read, user, *args))2912         finally:2913             self.freeze_handler.release_lock(user, self.name)2914 2915     def _read_user_parcel(self, user: str) -> Any:2916         """Read the user's store off the deposit and take the parcel away."""2917         payload = self.freeze_handler.read_user_register_item(user)2918         self.freeze_handler.drop_user_register_item(user)2919         return payload2920 2921     def _read_connection_parcel(self, user: str, cid: str) -> Any:2922         """Read one connection with its pages off the deposit and take it away."""2923         payload = self.freeze_handler.read_connection_register_item(user, cid)2924         self.freeze_handler.drop_connection_register_item(user, cid)2925         return payload