#!/usr/bin/env python3.14# -*- coding: utf-8 -*-"""The shutdown notice: one instance's countdown to closing its shell (`logout-all`).The operator's push (:mod:`euler_webshell.logout_all`) arms it over `/internal/logout-notice`,and the instance keeps the clock, so a later `--extend` or `--now` from any terminal just talksto the same state. It ticks to every attached terminal over text frames, which the replaynever carries, and `terminal.js` draws them.The shell closes at the first of:- the countdown ending with the shell idle — at once if no terminal is attached, since nobody is reading the countdown;- the limit, with or without running work: waiting counts as running, so an idle `claude` would otherwise hold a shutdown for ever;- `--now`, which needs no notice standing: with none it closes the shell just the same.Running work past the countdown extends the wait on its own, up to the limit. The final frametells open tabs to sign out; a user with no tab open is signed out by the operator's push."""from__future__importannotations__all__=['LogoutNotice']importasyncioimportjsonimportloggingimporttimefromtypingimportCallablefromeuler_webshell.ws.managerimportPtyManagerlog=logging.getLogger('euler-user')classLogoutNotice:"""The countdown on one instance: armed once, then extended, forced or run out."""def__init__(self,manager:PtyManager,on_end:Callable[[],None],tick:float=1.0)->None:self._manager=managerself._on_end=on_endself._tick=tickself._task:asyncio.Task[None]|None=Noneself._wake=asyncio.Event()self._forced=Falseself._reason=''self._deadline=0.0self._limit=0.0@propertydefactive(self)->bool:"""True from arming until the shell has closed."""returnself._taskisnotNoneandnotself._task.done()defarm(self,reason:str,seconds:int,limit:int)->None:"""Start the countdown; a notice already running keeps its clock."""ifself.active:returnnow=time.monotonic()self._reason=reasonself._deadline=now+secondsself._limit=now+max(seconds,limit)self._forced=Falseself._wake.clear()self._task=asyncio.create_task(self._run())defextend(self,seconds:int)->None:"""Add *seconds* to the countdown and the limit: past the limit, the operator decides."""self._deadline=max(self._deadline,time.monotonic())+secondsself._limit+=secondsdefnow(self)->None:"""Close the shell at once, running work or not, with or without a notice standing."""ifnotself.active:self.arm('',0,0)self._forced=Trueself._wake.set()defstatus(self)->dict[str,object]:"""Where the notice stands, for the operator's wait."""running=self._running()return{'active':self.active,'running':running,'remaining':self._remaining(running)ifself.activeelse0}asyncdefcancel(self)->None:"""Stop the clock (service stop: the shells are closed by the cleanup that follows)."""ifself._taskisnotNone:self._task.cancel()try:awaitself._taskexceptasyncio.CancelledError:passdef_running(self)->bool:returnany(shell['running']forshellinself._manager.snapshot())def_remaining(self,running:bool)->int:"""Seconds to the moment the page should name: the limit while work runs."""returnmax(0,round((self._limitifrunningelseself._deadline)-time.monotonic()))def_due(self,running:bool,attached:bool)->bool:now=time.monotonic()ifself._forcedornow>=self._limit:returnTruereturnnotrunningand(now>=self._deadlineornotattached)asyncdef_run(self)->None:try:whileTrue:shells=[shellforshellinself._manager.snapshot()ifshell['alive']]ifnotshells:# none to warn, or the user already signed outbreakrunning=any(shell['running']forshellinshells)ifself._due(running,any(shell['attached']forshellinshells)):awaitself._manager.notify_all(json.dumps({'euler':'logout-end'}))awaitself._manager.close_all()breakawaitself._manager.notify_all(json.dumps({'euler':'logout','reason':self._reason,'running':running,'remaining':self._remaining(running)}))try:awaitasyncio.wait_for(self._wake.wait(),timeout=self._tick)exceptTimeoutError:passexceptException:# noqa: BLE001 — the key must still be droppedlog.exception('logout notice failed')self._on_end()