diff --git a/condor/code_runner.py b/condor/code_runner.py index 35a86ce05..d7ac08c50 100644 --- a/condor/code_runner.py +++ b/condor/code_runner.py @@ -29,6 +29,12 @@ ``condor/code_runs.py`` and the action-log row (:func:`condor.runtime.danger.is_code_execution_call`) are the whole containment; unattended dry-run sessions refuse it outright in ``condor/agents/risk.py``. + +Containment against *accidents* is separate and lives in +:mod:`condor.runtime.code_guard`: the run's timeout can interrupt synchronous +code (a snippet that loops or walks the filesystem on the main thread used to +freeze all of Condor), and ``exit()``, ``input()`` and a few other ways to stop +or stall the process fail on their own line instead. """ from __future__ import annotations @@ -46,6 +52,7 @@ import condor.reports as reports from condor.code_runs import CodeRun, get_code_run_store, new_run_id from condor.memory.paths import CHAT_SLUG +from condor.runtime import code_guard logger = logging.getLogger(__name__) @@ -86,6 +93,14 @@ def flush(self): if buffer is None: self._stream.flush() + def isatty(self): + # A captured run is never a terminal. Forwarding this to a real tty is + # how ``help()`` decided to start an interactive pager on the main + # thread and wait for a keypress that would never come. + if _CAPTURE.get() is not None: + return False + return self._stream.isatty() + def __getattr__(self, name): return getattr(self._stream, name) @@ -142,6 +157,7 @@ async def _run_in_task( source: str, out: dict, owner_id: int = 0, + scope: code_guard.GuardScope | None = None, ): """The snippet itself, in its own task context. @@ -153,6 +169,8 @@ async def _run_in_task( reads/deletes against it (SEC-196). ``bind_context`` publishes the same context object the snippet sees, so a ``call_routine`` inside it inherits this run's server and user instead of an ambient default (FEAT-052). + ``scope`` is the run's guard (deadline and policy); it is entered last and + left first, so the harness's own bookkeeping never runs under it. """ from condor.primitives import bind_context @@ -164,9 +182,13 @@ async def _run_in_task( bind_context(namespace.get("context")), ): try: - outcome = eval(compiled, namespace) # noqa: S307 - this IS the feature - if inspect.isawaitable(outcome): - await outcome + with ( + code_guard.guarded(scope or code_guard.GuardScope(source), compiled), + code_guard.contain_exit(), + ): + outcome = eval(compiled, namespace) # noqa: S307 - this IS the feature + if inspect.isawaitable(outcome): + await outcome finally: out["report_id"] = reports.get_last_report_id() or "" @@ -217,6 +239,14 @@ async def execute_code( get_code_run_store().save(run) return run.to_dict() + refusal = code_guard.check_source(ast.parse(code, CODE_FILENAME)) + if refusal: + run.status = "error" + run.error = f"Refused: {refusal}" + run.duration_ms = int((time.monotonic() - started) * 1000) + get_code_run_store().save(run) + return run.to_dict() + # The exact context a routine gets, so the two paths cannot diverge — the # active server travels as a preference, which is how get_client resolves it. context = WebRoutineContext( @@ -238,6 +268,11 @@ async def execute_code( _install_stdout_proxy() out: dict = {"report_id": ""} + # Started here, not with ``started``: resolving the client above is the + # harness's time, not the snippet's. + scope = code_guard.GuardScope( + label=run_id, deadline=time.monotonic() + timeout, budget=timeout + ) task = asyncio.create_task( _run_in_task( compiled, @@ -247,6 +282,7 @@ async def execute_code( label or run_id, out, owner_id=chat_id, + scope=scope, ) ) try: @@ -255,6 +291,15 @@ async def execute_code( except (asyncio.TimeoutError, TimeoutError): run.status = "timeout" run.error = f"Timed out after {timeout:g}s" + except code_guard.CodeRunDeadline as e: + # The same outcome as above, reached by the guard instead of wait_for: + # the snippet was busy on the main thread and never yielded to be + # cancelled. The traceback is the useful part — it points at the line. + run.status = "timeout" + run.error = f"Timed out after {timeout:g}s ({e})" + run.traceback = _trim_traceback( + "".join(traceback.format_exception(type(e), e, e.__traceback__)) + ) except asyncio.CancelledError: run.status = "timeout" run.error = "Cancelled" @@ -266,6 +311,10 @@ async def execute_code( "".join(traceback.format_exception(type(e), e, e.__traceback__)) ) finally: + # A task the snippet spawned and never awaited still carries the scope; + # expiring it cuts that straggler at its next line instead of letting it + # run on, unowned, after its run was reported finished. + scope.expire() run.duration_ms = int((time.monotonic() - started) * 1000) run.stdout = buffer.getvalue() run.result = _render_result(namespace) diff --git a/condor/fetchers/bot_performance.py b/condor/fetchers/bot_performance.py index c03fc0b97..3eeda8541 100644 --- a/condor/fetchers/bot_performance.py +++ b/condor/fetchers/bot_performance.py @@ -975,11 +975,43 @@ def _warn_truncated() -> None: float(perf.get("cum_fees_quote", 0) or 0), ) return { - cid: (quotes.get(cid, ""), sorted(samples.items())) + cid: (quotes.get(cid, ""), _drop_dead_readings(sorted(samples.items()))) for cid, samples in by_controller.items() } +_DEAD_READING = (0.0, 0.0, 0.0, 0.0) + + +def _drop_dead_readings( + samples: list[tuple[float, tuple[float, float, float, float]]], +) -> list[tuple[float, tuple[float, float, float, float]]]: + """Drop all-zero readings that follow a real one in a controller's series. + + A controller whose strategy died while its bot stayed listed keeps + reporting, and reports 0/0: its cumulative appears to fall back to zero. + Every session figure is a difference of cumulatives (:func:`slice_history`), + so that fall read as negative volume and invented PnL for a bot adopted + mid-life, and erased a bot born and killed inside one session (0 − 0). + These figures are cumulative within an instance — a restart is a new + instance name — so a real reading followed by zeros is never the truth. + Dropping the zeros lets the forward-carry in + :func:`merge_controller_series` hold the last real reading, the same rule + the hackathon race monitor's ledger applies. Zeros *before* the first real + reading are a controller that has not traded yet, and are kept. + """ + out: list[tuple[float, tuple[float, float, float, float]]] = [] + traded = False + for epoch, values in samples: + if values == _DEAD_READING: + if traded: + continue + else: + traded = True + out.append((epoch, values)) + return out + + def merge_controller_series( by_controller: dict[str, ControllerSeries], usd_rates: Mapping[str, float] | None = None, diff --git a/condor/notifications.py b/condor/notifications.py index 012f65b38..d9928ce4b 100644 --- a/condor/notifications.py +++ b/condor/notifications.py @@ -222,6 +222,27 @@ def mark_read(user_id: int, ids: list[str] | None = None) -> int: return sum(1 for raw in items if isinstance(raw, dict) and not raw.get("read")) +async def clear(user_id: int) -> None: + """Delete every notification in ``user_id``'s bucket. + + Takes the write lock so a notice recorded mid-clear is not resurrected (or + lost) by the two writes racing. Scoped like :func:`mark_read`: other users' + buckets are left untouched. + + A failed write raises rather than being swallowed: unlike a read flag, a + clear the user was told succeeded would quietly come back on the next poll. + """ + if not user_id: + return + key = str(int(user_id)) + async with _write_lock: + data = _read_all() + if key not in data: + return + del data[key] + _write_all(data) + + def user_for_chat(chat_id: Any) -> int | None: """The dashboard user behind a Telegram chat id, or None if there is none. diff --git a/condor/routine_store.py b/condor/routine_store.py index f70e8ff86..bb58cb856 100644 --- a/condor/routine_store.py +++ b/condor/routine_store.py @@ -43,6 +43,7 @@ iter_agent_slugs, shared_routines_roots, ) +from condor.runtime import code_guard from condor.telemetry import taps as telemetry_taps from routines.base import ( RoutineResult, @@ -764,12 +765,24 @@ async def _execute_and_record( source_name=base_name, ), primitives.bind_context(ctx), + # Routines are agent-written code on the main loop too: the + # same policy as run_code, and exit() is an error, not a + # shutdown (condor/runtime/code_guard.py). + code_guard.guarded(code_guard.routine_scope(instance_id)), + code_guard.contain_exit(), ): cfg = routine.config_class(**config) raw = await routine.run_fn(cfg, ctx) result = normalize_result(raw) except asyncio.CancelledError: result = RoutineResult(text="Stopped by user") + except code_guard.CodeRunDeadline as e: + # A routine called under a snippet's deadline ran past it. Recorded + # like any failure: a BaseException passing through here would + # leave the instance "running" with no result, forever. + logger.warning(f"Routine {routine.name}[{instance_id}] interrupted: {e}") + result = RoutineResult(text=f"Error: CodeRunDeadline: {e}") + failed = True except Exception as e: tb = traceback.format_exc() logger.error( @@ -879,10 +892,13 @@ async def execute( on_complete=on_complete, ) + # Detached: a background run started from a snippet must not inherit + # that snippet's deadline and die with it (code_guard). task = asyncio.create_task( self._run_oneshot( instance_id, routine, config, server_name, user_id, agent=agent - ) + ), + context=code_guard.detached_context(), ) self._tasks[instance_id] = task return instance_id @@ -939,10 +955,13 @@ async def start_continuous( session_key=session_key, ) + # Detached: a background run started from a snippet must not inherit + # that snippet's deadline and die with it (code_guard). task = asyncio.create_task( self._run_continuous( instance_id, routine, config, server_name, user_id, agent=agent - ) + ), + context=code_guard.detached_context(), ) self._tasks[instance_id] = task return instance_id @@ -995,10 +1014,13 @@ async def schedule( schedule={"type": "interval", "interval_sec": interval_sec}, ) + # Detached: a background run started from a snippet must not inherit + # that snippet's deadline and die with it (code_guard). task = asyncio.create_task( self._run_scheduled( instance_id, routine, config, server_name, interval_sec, user_id - ) + ), + context=code_guard.detached_context(), ) self._tasks[instance_id] = task return instance_id diff --git a/condor/runtime/code_guard.py b/condor/runtime/code_guard.py new file mode 100644 index 000000000..d788aa8dd --- /dev/null +++ b/condor/runtime/code_guard.py @@ -0,0 +1,511 @@ +"""Keep agent-written code from taking the main process down with it. + +``run_code`` snippets and routines run on Condor's own event loop — that is the +feature (see ``condor/code_runner.py``): a snippet reaches exactly what a +routine reaches. The cost is that synchronous code in them runs *on* the loop, +so ``asyncio.wait_for`` cannot stop it, and the 2026-10-07 freeze was exactly +that: a snippet's ``glob.glob("/**/x.py", recursive=True)`` walked ``/sys`` +symlink cycles on the main thread for hours, and the dashboard, every agent and +Condor's own SIGTERM handler waited behind it. + +This is not a sandbox and does not try to be: the callers are models making +mistakes, not adversaries, and anything here can be stepped around on purpose. +What it does is turn the mistakes we have actually seen into an error on the +offending line instead of a dead process: + +* **A deadline that can interrupt synchronous code.** Two hooks raise + :class:`CodeRunDeadline` once a guarded run is past its budget: + ``sys.monitoring`` on the snippet's own code objects (a ``while True`` loop, + however tight) and an audit hook on everything the snippet calls (any + syscall: file I/O, ``scandir``, sockets). It is a ``BaseException`` so + ``except Exception`` in the snippet cannot swallow it. Code that is handling + an exception — a ``finally``, an ``except``, a ``with`` exit — may run for up + to :data:`INTERRUPT_GRACE` seconds, so cleanup can release what it holds; + everything else is interrupted again, so a loop that swallows it still ends. +* **A short policy list**, enforced through the same audit hook: no listing of + ``/``, ``/proc``, ``/sys`` or ``/dev``; no ``input()`` (there is no stdin); + no signalling Condor's own process; no ``time.sleep`` longer than + :data:`MAX_BLOCKING_SLEEP` (via a wrapper: ``time.sleep`` has no audit event + before Python 3.13). +* **A static check** (:func:`check_source`) for the exits with no audit event + at all — ``os._exit``, ``os.abort``, ``signal.raise_signal``. +* **``SystemExit`` / ``KeyboardInterrupt`` containment** (:func:`contain_exit`). + asyncio re-raises both out of the event loop, so a bare ``exit()`` in a + snippet used to shut Condor down with exit code 0. + +A run Condor starts on a snippet's behalf to outlive it — a background routine +from ``start_routine`` — must be spawned in :func:`detached_context`; otherwise +its task inherits the snippet's scope and is cut when the snippet's run ends. + +Everything is scoped by a :class:`ContextVar`, so only code running in a guarded +run's context — its own task and any task it spawned — is affected; the rest of +Condor never pays more than one ``ContextVar.get()`` per audit event. + +What it cannot do: interrupt a single long call into C (a huge ``sorted``, a +``subprocess.run`` waiting on its child) or cap memory. Those need the snippet +in another process; until then :mod:`condor.runtime.loop_watchdog` at least +logs where the loop is stuck. +""" + +from __future__ import annotations + +import ast +import contextlib +import contextvars +import dis +import functools +import logging +import os +import sys +import time +from dataclasses import dataclass, field +from types import CodeType +from typing import Iterator + +logger = logging.getLogger(__name__) + +# How long code handling a deadline interruption (a ``finally``, an ``except``, +# a ``with`` exit) may run before it is interrupted too. Long enough to release +# a lock or close a file; short enough that a cleanup that hangs is still cut. +INTERRUPT_GRACE = 2.0 + +# The longest ``time.sleep`` a guarded run may make. Every second of it is a +# second the whole event loop — dashboard, agents, Telegram — stands still. +MAX_BLOCKING_SLEEP = 1.0 + +# Directories no agent has a reason to list, and the root, which is where every +# "find this file" search that ran for hours started. ``/proc`` and ``/sys`` are +# the ones with cycles; ``/dev`` has entries that block on open. +_FORBIDDEN_TREES = ("/proc", "/sys", "/dev") + +# ``sys.monitoring`` tool ids 0-2 and 5 are claimed by convention (debugger, +# coverage, profiler, optimizer). Take a free one of the rest. +_TOOL_IDS = (4, 3) +_TOOL_NAME = "condor-code-guard" + + +class CodeRunDeadline(BaseException): + """A guarded run went past its budget. A ``BaseException`` on purpose.""" + + +class GuardViolation(Exception): + """A guarded run tried something that would take the main process down.""" + + +@dataclass +class GuardScope: + """One guarded run: who it is and when (if ever) it must stop. + + ``deadline`` is a ``time.monotonic()`` value, or ``None`` for code with no + budget (a continuous routine). It is mutable so :meth:`expire` can end a + finished run's stragglers: a task the snippet spawned and never awaited + still carries this scope in its context, and is cut at its next line. + """ + + label: str + deadline: float | None = None + budget: float | None = field(default=None, repr=False) + _interrupted_at: float | None = field(default=None, repr=False) + + def expire(self) -> None: + self.deadline = time.monotonic() + + def should_interrupt(self) -> bool: + """True when the run is past its deadline and is not cleaning up. + + Raising on every line after the deadline meant a ``finally`` never got + to run its first line, and a lock it held stayed held for everyone else. + So code that is handling the interruption is spared — for a while. The + test is the exception being handled, not a timer: the first raise can + land in C code that swallows it, and a timer started then would spend + the cleanup window on the very loop it was meant to stop. + """ + if not self.interrupt_due(): + return False + if self._interrupted_at is None: + self._interrupted_at = time.monotonic() + return True + + def interrupt_due(self) -> bool: + """:meth:`should_interrupt` without recording that it happened.""" + if self.deadline is None: + return False + now = time.monotonic() + if now <= self.deadline: + return False + # Handling *any* exception counts: the interruption itself, the + # CancelledError of a snippet that timed out while awaiting (wait_for + # cut it, not this guard), and the GeneratorExit a suspended + # generator's ``finally`` runs under. + if sys.exception() is None: + return True + # The grace runs from this guard's own interruption, or from the + # deadline when something else (wait_for) did the interrupting. + since = self._interrupted_at or self.deadline + return now - since >= INTERRUPT_GRACE + + +_SCOPE: contextvars.ContextVar[GuardScope | None] = contextvars.ContextVar( + "condor_code_guard_scope", default=None +) + +_installed = False +_tool_id: int | None = None + + +def current_scope() -> GuardScope | None: + return _SCOPE.get() + + +# ── Runtime hooks ── + + +def _deadline(scope: GuardScope, where: str) -> CodeRunDeadline: + budget = f" of {scope.budget:g}s" if scope.budget else "" + return CodeRunDeadline(f"interrupted {where} after its budget{budget} ran out") + + +def _as_path(value) -> str | None: + """An audit-event path argument as an absolute, normalized ``str``. + + Pure string work only: anything that could itself raise an audit event + (``os.getcwd`` inside ``abspath``) would re-enter the hook. Relative paths, + file descriptors and ``None`` (the cwd) are never the root or a system tree, + so they come back ``None`` and are let through. + """ + if isinstance(value, bytes): + value = value.decode(errors="replace") + elif not isinstance(value, str): + fspath = getattr(value, "__fspath__", None) + if fspath is None: + return None + try: + return _as_path(fspath()) + except Exception: + return None + if not value.startswith("/"): + return None + return os.path.normpath(value) + + +def _check_listing(args) -> None: + path = _as_path(args[0]) if args else None + if path is None: + return + if path == "/" or any( + path == tree or path.startswith(tree + "/") for tree in _FORBIDDEN_TREES + ): + raise GuardViolation( + f"listing {path!r} is not allowed here: a search from the filesystem " + "root (or through /proc, /sys, /dev) can run for hours and freezes " + "Condor while it does. Open the exact path you need instead." + ) + + +def _check_kill(args) -> None: + pid = args[0] if args else None + if pid in (0, -1, os.getpid(), -os.getpgrp()): + raise GuardViolation("signalling Condor's own process is not allowed here") + + +def _check_killpg(args) -> None: + if args and args[0] in (0, os.getpgrp()): + raise GuardViolation( + "signalling Condor's own process group is not allowed here" + ) + + +def _check_sleep(secs) -> None: + if isinstance(secs, (int, float)) and secs > MAX_BLOCKING_SLEEP: + raise GuardViolation( + f"time.sleep({secs:g}) would freeze all of Condor for {secs:g}s; " + f"use `await asyncio.sleep({secs:g})` instead" + ) + + +def _deny(message: str): + def check(_args) -> None: + raise GuardViolation(message) + + return check + + +_POLICY = { + "os.scandir": _check_listing, + "os.listdir": _check_listing, + "os.kill": _check_kill, + "os.killpg": _check_killpg, + "signal.pthread_kill": _deny("signalling Condor's threads is not allowed here"), + "builtins.input": _deny( + "input() is not available here: there is no one at stdin, and waiting " + "for it freezes Condor" + ), +} + + +def _audit(event: str, args) -> None: + scope = _SCOPE.get() + if scope is None: + return + if scope.should_interrupt(): + raise _deadline(scope, f"at {event}") + check = _POLICY.get(event) + if check is not None: + check(args) + + +_original_sleep = time.sleep + + +@functools.wraps(_original_sleep) +def _guarded_sleep(secs): + scope = _SCOPE.get() + if scope is not None: + if scope.should_interrupt(): + raise _deadline(scope, "at time.sleep") + _check_sleep(secs) + return _original_sleep(secs) + + +_WATCHED = 0 # set by install(): LINE | JUMP + +# Where an exception enters a handler. The exception is not yet marked as being +# handled there, so an interruption at this instruction would look like one +# outside any cleanup and bypass the handler — a ``with`` block's ``__exit__`` +# never ran, and the lock it would have released stayed held. +_HANDLER_ENTRY = dis.opmap["PUSH_EXC_INFO"] + + +def _arm(code: CodeType) -> None: + sys.monitoring.set_local_events( + _tool_id, code, _WATCHED | sys.monitoring.events.INSTRUCTION + ) + + +def _on_line(code: CodeType, _line: int) -> None: + """Arm :func:`_on_instruction` for ``code`` when an interruption is due. + + Neither this nor :func:`_on_jump` raises. Snippet code is only ever + interrupted from :func:`_on_instruction`, the one place that knows exactly + which instruction is next — and on CPython 3.12 that matters: raised from a + JUMP callback, on a compiler-generated instruction or on a handler's first + instruction, the exception skips the ``finally``/``except``/``__exit__`` it + should have run. + """ + scope = _SCOPE.get() + if scope is not None and scope.interrupt_due(): + _arm(code) + + +def _on_jump(code: CodeType, _src: int, _dst: int) -> None: + """As :func:`_on_line`. Needed because LINE does not reliably fire inside a + one-line loop, and a backward jump always does.""" + scope = _SCOPE.get() + if scope is not None and scope.interrupt_due(): + _arm(code) + + +_unsafe: dict[CodeType, frozenset[int]] = {} + + +def _safe_to_raise(code: CodeType, offset: int) -> bool: + """Whether raising at ``offset`` lands inside the frame's exception table. + + Not at a compiler-generated instruction (no source line — a loop's + back-edge, the shuffling around a handler) and not at a handler's entry. + """ + unsafe = _unsafe.get(code) + if unsafe is None: + lineless = { + off + for start, end, line in code.co_lines() + if line is None + for off in range(start, end, 2) + } + raw = code.co_code + entries = {off for off in range(0, len(raw), 2) if raw[off] == _HANDLER_ENTRY} + unsafe = frozenset(lineless | entries) + if len(_unsafe) > 256: # snippets are throwaway; so is this cache + _unsafe.clear() + _unsafe[code] = unsafe + return offset not in unsafe + + +def _on_instruction(code: CodeType, offset: int) -> None: + """Interrupt at the first instruction where it is safe to, then disarm. + + Stays armed while the interruption is not due — the run is cleaning up — + so the moment cleanup ends, or its grace runs out, is not missed in a + tight loop. Disarmed before raising, so it cannot fire again on the + handler the exception is about to enter. + """ + scope = _SCOPE.get() + if scope is None or not scope.interrupt_due(): + if scope is None: + sys.monitoring.set_local_events(_tool_id, code, _WATCHED) + return + if not _safe_to_raise(code, offset): + return + sys.monitoring.set_local_events(_tool_id, code, _WATCHED) + scope.should_interrupt() # records the interruption + raise _deadline(scope, f"in {code.co_name}") + + +def install() -> None: + """Install the audit hook and claim a ``sys.monitoring`` tool. Idempotent. + + An audit hook can never be removed, which is why :func:`_audit` returns on + its first line for everything outside a guarded run. + """ + global _installed, _tool_id, _WATCHED + if _installed: + return + sys.addaudithook(_audit) + # Process-wide, but a pass-through for everything outside a guarded run — + # including every other thread, whose context never carries a scope. + time.sleep = _guarded_sleep + monitoring = getattr(sys, "monitoring", None) + if monitoring is not None: + for tool_id in _TOOL_IDS: + if monitoring.get_tool(tool_id) is None: + monitoring.use_tool_id(tool_id, _TOOL_NAME) + events = monitoring.events + monitoring.register_callback(tool_id, events.LINE, _on_line) + monitoring.register_callback(tool_id, events.JUMP, _on_jump) + monitoring.register_callback( + tool_id, events.INSTRUCTION, _on_instruction + ) + _WATCHED = events.LINE | events.JUMP + _tool_id = tool_id + break + else: + logger.warning( + "No free sys.monitoring tool id; run_code deadlines will only " + "interrupt snippets at their next syscall, not in pure-CPU loops" + ) + _installed = True + + +def _code_objects(code: CodeType) -> Iterator[CodeType]: + yield code + for const in code.co_consts: + if isinstance(const, CodeType): + yield from _code_objects(const) + + +def _watch_lines(code: CodeType) -> None: + """Watch every line and jump of ``code`` for a passed deadline. + + Only the snippet's own code objects — the functions, lambdas and + comprehensions it defines included — so library code it calls runs at full + speed. INSTRUCTION events are armed per code object only once an + interruption is due (see :func:`_on_jump`), never before. + """ + if _tool_id is None: + return + for obj in _code_objects(code): + sys.monitoring.set_local_events(_tool_id, obj, _WATCHED) + + +@contextlib.contextmanager +def guarded(scope: GuardScope, code: CodeType | None = None) -> Iterator[GuardScope]: + """Run the body (and every task it spawns) under ``scope``. + + ``code`` is the compiled snippet; passing it lets the deadline interrupt + pure-CPU loops in it. Without it the deadline still fires at the next + syscall. + """ + install() + if code is not None and scope.deadline is not None: + _watch_lines(code) + token = _SCOPE.set(scope) + try: + yield scope + finally: + _SCOPE.reset(token) + + +def routine_scope(label: str) -> GuardScope: + """The scope a routine runs under: the policy, and its caller's deadline. + + A routine has no budget of its own (continuous ones run until stopped), but + one called from a snippet via ``call_routine`` must not outlive the snippet + that is waiting on it. + """ + parent = _SCOPE.get() + if parent is not None: + return GuardScope(label=label, deadline=parent.deadline, budget=parent.budget) + return GuardScope(label=label) + + +def detached_context() -> contextvars.Context: + """A copy of the current context with no guard scope in it. + + For ``asyncio.create_task(..., context=detached_context())`` wherever Condor + starts a run meant to outlive its caller. Without it, a background routine + started from a snippet carried that snippet's deadline and was cut — at its + next I/O, past ``except Exception`` — once the snippet finished. + """ + context = contextvars.copy_context() + context.run(_SCOPE.set, None) + return context + + +@contextlib.contextmanager +def contain_exit() -> Iterator[None]: + """Turn ``SystemExit`` / ``KeyboardInterrupt`` into an ordinary error. + + asyncio's task step re-raises both straight out of the event loop, which + ends ``asyncio.run`` and with it Condor — so ``exit()`` in a snippet was a + clean shutdown with exit code 0 and nothing in the log. + """ + try: + yield + except (SystemExit, KeyboardInterrupt) as e: + raise GuardViolation( + f"{type(e).__name__} is not allowed here: it would stop Condor. " + "Set `result` and let the code finish instead." + ) from None + + +# ── Static check ── + +# Calls that end or crash the process and raise no audit event first, so the +# hook never sees them. Matched by (module, attribute) on ``module.attr(...)`` +# and on ``from module import attr``. +_PROCESS_ENDERS = { + ("os", "_exit"), + ("os", "abort"), + ("posix", "_exit"), + ("posix", "abort"), + ("signal", "raise_signal"), + ("faulthandler", "_sigsegv"), + ("faulthandler", "_sigabrt"), +} + + +def check_source(tree: ast.AST) -> str | None: + """A reason to refuse this code before it runs, or ``None``. + + Deliberately narrow: only calls that end the process outright. Everything + else is the runtime hooks' job, where the error lands on the real line. + """ + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom) and node.module: + for alias in node.names: + if (node.module, alias.name) in _PROCESS_ENDERS: + return _ender_message(node.module, alias.name, node.lineno) + elif ( + isinstance(node, ast.Attribute) + and isinstance(node.value, ast.Name) + and (node.value.id, node.attr) in _PROCESS_ENDERS + ): + return _ender_message(node.value.id, node.attr, node.lineno) + return None + + +def _ender_message(module: str, name: str, lineno: int) -> str: + return ( + f"line {lineno}: {module}.{name} is not allowed: it would stop Condor's " + "main process (the dashboard and every agent with it)" + ) diff --git a/condor/runtime/loop_watchdog.py b/condor/runtime/loop_watchdog.py new file mode 100644 index 000000000..47b3e96c2 --- /dev/null +++ b/condor/runtime/loop_watchdog.py @@ -0,0 +1,94 @@ +"""Say where the event loop is stuck, while it is stuck. + +Condor runs the dashboard, Telegram, every agent and every routine on one +asyncio loop, so anything that blocks it freezes all of them at once — and +because the loop is also where log lines are produced, a freeze used to look +like silence. Diagnosing the 2026-10-07 freeze took ``sudo py-spy dump``. + +This is that dump, done automatically: a heartbeat scheduled on the loop, and a +daemon thread that, once the heartbeat is :data:`STALL_AFTER` seconds late, logs +the loop thread's current stack — once per stall, and again when it recovers. + +It only observes. :mod:`condor.runtime.code_guard` is what interrupts agent +code; this covers everything it cannot (a long call into C, a blocking call in +Condor's own code) by at least naming the line. +""" + +from __future__ import annotations + +import asyncio +import logging +import sys +import threading +import time +import traceback + +logger = logging.getLogger(__name__) + +STALL_AFTER = 10.0 +_BEAT = 1.0 + + +class LoopWatchdog: + def __init__( + self, loop: asyncio.AbstractEventLoop, stall_after: float = STALL_AFTER + ): + self._loop = loop + self._stall_after = stall_after + # Fine enough to see a stall of ``stall_after``, never busier than 1s. + self._interval = min(_BEAT, stall_after / 4) + self._loop_thread = threading.get_ident() + self._last_beat = time.monotonic() + self._stop = threading.Event() + self._handle: asyncio.TimerHandle | None = None + self._thread = threading.Thread( + target=self._watch, name="condor-loop-watchdog", daemon=True + ) + + def start(self) -> None: + self._beat() + self._thread.start() + + def stop(self) -> None: + self._stop.set() + if self._handle is not None: + self._handle.cancel() + + def _beat(self) -> None: + self._last_beat = time.monotonic() + if not self._stop.is_set(): + self._handle = self._loop.call_later(self._interval, self._beat) + + def _stack(self) -> str: + frame = sys._current_frames().get(self._loop_thread) + if frame is None: + return " (loop thread not found)\n" + return "".join(traceback.format_stack(frame)) + + def _watch(self) -> None: + stalled_since: float | None = None + while not self._stop.wait(self._interval): + lag = time.monotonic() - self._last_beat + if lag >= self._stall_after and stalled_since is None: + stalled_since = self._last_beat + logger.error( + "Event loop blocked for %.0fs — dashboard, agents and Telegram " + "are frozen until it returns. Loop thread is at:\n%s", + lag, + self._stack(), + ) + elif lag < self._interval * 2 and stalled_since is not None: + logger.warning( + "Event loop unblocked after %.0fs", + time.monotonic() - stalled_since, + ) + stalled_since = None + + +def start( + loop: asyncio.AbstractEventLoop | None = None, stall_after: float = STALL_AFTER +) -> LoopWatchdog: + """Start watching ``loop`` (default: the running one). Call from its thread.""" + watchdog = LoopWatchdog(loop or asyncio.get_running_loop(), stall_after) + watchdog.start() + return watchdog diff --git a/condor/web/routes/notifications.py b/condor/web/routes/notifications.py index 61e5d7ee6..db7393cb3 100644 --- a/condor/web/routes/notifications.py +++ b/condor/web/routes/notifications.py @@ -11,13 +11,17 @@ from __future__ import annotations -from fastapi import APIRouter, Depends, Query +import logging + +from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel from condor import notifications from condor.web.auth import get_current_user from condor.web.models import WebUser +log = logging.getLogger(__name__) + router = APIRouter(prefix="/notifications", tags=["notifications"]) @@ -46,3 +50,14 @@ async def mark_notifications_read( ): """Mark some (or all) of this user's notifications read.""" return {"unread": notifications.mark_read(user.id, body.ids)} + + +@router.delete("") +async def clear_notifications(user: WebUser = Depends(get_current_user)): + """Delete all of this user's notifications.""" + try: + await notifications.clear(user.id) + except Exception: # noqa: BLE001 - the path belongs in the log, not the response + log.exception("Could not clear notifications for %s", user.id) + raise HTTPException(status_code=500, detail="Could not clear notifications") + return {"unread": 0} diff --git a/frontend/src/components/layout/NotificationBell.test.tsx b/frontend/src/components/layout/NotificationBell.test.tsx new file mode 100644 index 000000000..432338473 --- /dev/null +++ b/frontend/src/components/layout/NotificationBell.test.tsx @@ -0,0 +1,189 @@ +/** + * The bell's "Clear all" (PR #281). + * + * Clearing deletes the history for good, so what is pinned here is that it + * only ever happens on a deliberate second click, that a failure says so, and + * that the list shown afterwards is the store's and not a guess. + * + * Needs a DOM, so this file overrides vitest's default `node` environment. + * + * @vitest-environment jsdom + */ + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, type ReactNode } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { MemoryRouter } from "react-router-dom"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { AppNotification, NotificationsResponse } from "@/lib/api"; + +const getNotifications = vi.fn(); +const markNotificationsRead = vi.fn(); +const clearNotifications = vi.fn(); + +vi.mock("@/lib/api", () => ({ + api: { + getNotifications: (...a: unknown[]) => getNotifications(...a), + markNotificationsRead: (...a: unknown[]) => markNotificationsRead(...a), + clearNotifications: (...a: unknown[]) => clearNotifications(...a), + }, +})); + +vi.mock("@/lib/auth", () => ({ useAuth: () => ({ token: "t" }) })); +vi.mock("@/lib/appBadge", () => ({ setAppBadge: () => {} })); +vi.mock("@/lib/push", () => ({ onPushNavigate: () => () => {} })); +// The socket hook is a whole subsystem; the bell only needs its cache key. +vi.mock("@/hooks/useChatSocket", () => ({ NOTIFICATIONS_KEY: ["notifications"] })); +// Positioning is AnchoredMenu's own concern (and its own tests'); here it only +// has to show its children while open. +vi.mock("@/components/ui/AnchoredMenu", () => ({ + AnchoredMenu: ({ open, children }: { open: boolean; children: ReactNode }) => + open ?