File size: 4,640 Bytes
a37e1b9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
"""Shared concurrency helpers for plugin authors.

The most common plugin footgun is the lazy process-wide singleton:

    _client = None

    def get_client():
        global _client
        if _client is not None:
            return _client
        _client = ExpensiveClient(...)   # <-- TOCTOU: two threads both run this
        return _client

When two threads call ``get_client()`` before the singleton is set, both pass
the ``is not None`` guard, both run the expensive initialization, and the
second write clobbers the first — leaking whatever resource the first client
opened (connections, file handles, background threads).

Multi-threaded agent sessions share one process (delegated tool calls,
background workers, the self-improvement fork), so this race is reachable in
practice. Rather than make every plugin author remember to hand-roll
double-checked locking, this module gives them two thread-safe primitives:

* :func:`lazy_singleton` — decorator for the zero-arg accessor case.
* :class:`SingletonSlot` — manual slot for accessors that build different
  instances depending on a config/key argument.

Both are import-light (stdlib ``threading`` only) so any plugin can import
them without dragging in heavyweight host modules.
"""

from __future__ import annotations

import functools
import threading
from typing import Callable, Generic, Optional, TypeVar

__all__ = ["lazy_singleton", "SingletonSlot"]

T = TypeVar("T")


def lazy_singleton(factory: Callable[[], T]) -> Callable[[], T]:
    """Wrap a zero-argument factory into a thread-safe lazy singleton accessor.

    The wrapped callable returns the same instance on every call; the factory
    runs exactly once even under concurrent first calls, using double-checked
    locking. A ``.reset()`` attribute is attached for tests/teardown.

    Example::

        @lazy_singleton
        def get_client():
            return ExpensiveClient(load_config())

        client = get_client()   # built once, safe across threads
        get_client.reset()      # drop the instance (next call rebuilds)

    Note: if the factory raises, no instance is cached and the next call
    retries (the lock is released either way).
    """
    lock = threading.Lock()
    box: list = []  # one-element [instance]; empty == not yet built

    @functools.wraps(factory)
    def accessor() -> T:
        if box:
            return box[0]
        with lock:
            if box:  # re-check inside the lock
                return box[0]
            instance = factory()
            box.append(instance)
            return instance

    def reset() -> None:
        with lock:
            box.clear()

    accessor.reset = reset  # type: ignore[attr-defined]
    return accessor


class SingletonSlot(Generic[T]):
    """Thread-safe lazy slot for accessors that take a build argument.

    Use this when the cached instance depends on a config/key passed to the
    accessor (so a bare zero-arg :func:`lazy_singleton` doesn't fit). The slot
    caches the first successfully-built instance and ignores the argument on
    subsequent calls — matching the established "first config wins" singleton
    semantics most plugins already rely on.

    Example::

        _slot: SingletonSlot[Honcho] = SingletonSlot()

        def get_honcho_client(config=None):
            return _slot.get(lambda: Honcho(**resolve(config)))

        def reset_honcho_client():
            _slot.reset()

    The factory runs at most once even under concurrent first calls. If the
    factory raises, nothing is cached and the next call retries.
    """

    __slots__ = ("_lock", "_value", "_set")

    def __init__(self) -> None:
        self._lock = threading.Lock()
        self._value: Optional[T] = None
        self._set = False

    def get(self, factory: Callable[[], T]) -> T:
        # Fast path: already built, no lock needed (a set bool + ref read is
        # atomic under CPython's GIL).
        if self._set:
            return self._value  # type: ignore[return-value]
        with self._lock:
            if self._set:  # re-check inside the lock
                return self._value  # type: ignore[return-value]
            value = factory()
            self._value = value
            self._set = True
            return value

    def peek(self) -> Optional[T]:
        """Return the cached instance without building it (None if unset)."""
        return self._value if self._set else None

    def reset(self) -> None:
        """Drop the cached instance so the next ``get()`` rebuilds it."""
        with self._lock:
            self._value = None
            self._set = False