cctally 1.64.0 → 1.65.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,180 @@
1
+ """Opt-in backend phase-instrumentation collector (issue #276, Session A).
2
+
3
+ Stdlib-only. A thread-local nested-phase timing collector, gated on the
4
+ CCTALLY_PERF_TRACE env var. Near-noop when off: phase() returns a shared
5
+ _NULL_PHASE singleton — no allocation, no perf_counter, no stack push.
6
+
7
+ Two renderers sit on the same phase tree:
8
+ * flush_stderr(root) — CLI indented outline (stdout stays byte-identical).
9
+ * stash_last(root) — the dashboard freezes the completed tree (to_dict)
10
+ into a process-global slot for the loopback
11
+ /api/debug/backend endpoint to read.
12
+
13
+ This surface is a diagnostic, NOT a consumer contract: phase names, nesting,
14
+ and fields may change without a version bump.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ import sys
20
+ import threading
21
+ import time
22
+
23
+ _FALSEY = {"", "0", "false", "no", "off"}
24
+ _ENABLED = os.environ.get("CCTALLY_PERF_TRACE", "").strip().lower() not in _FALSEY
25
+
26
+
27
+ def enabled() -> bool:
28
+ return _ENABLED
29
+
30
+
31
+ def set_enabled(value: bool) -> None:
32
+ """Flip tracing at runtime (tests; not used by the dashboard, which reads
33
+ the env at import time)."""
34
+ global _ENABLED
35
+ _ENABLED = bool(value)
36
+
37
+
38
+ _tls = threading.local()
39
+
40
+
41
+ def _stack():
42
+ s = getattr(_tls, "stack", None)
43
+ if s is None:
44
+ s = []
45
+ _tls.stack = s
46
+ return s
47
+
48
+
49
+ class Phase:
50
+ __slots__ = ("name", "elapsed_ms", "count", "meta", "children", "_start", "_stack")
51
+
52
+ def __init__(self, name, stack):
53
+ self.name = name
54
+ self.elapsed_ms = 0.0
55
+ self.count = None
56
+ self.meta = None
57
+ self.children = []
58
+ self._start = 0.0
59
+ self._stack = stack
60
+
61
+ def set_count(self, n):
62
+ self.count = int(n)
63
+
64
+ def set_meta(self, **kw):
65
+ if self.meta is None:
66
+ self.meta = {}
67
+ self.meta.update(kw)
68
+
69
+ def __enter__(self):
70
+ self._start = time.perf_counter()
71
+ self._stack.append(self)
72
+ return self
73
+
74
+ def __exit__(self, *exc):
75
+ self.elapsed_ms = (time.perf_counter() - self._start) * 1000.0
76
+ # Identity-aware unwind. If a nested phase leaked (its __exit__ was
77
+ # skipped — e.g. an exception escaped a manually CM-bracketed region),
78
+ # drop the leaked frames sitting above us so we never append a phase to
79
+ # its OWN children (which would make to_dict() self-recurse) and never
80
+ # strand a stack that hides the real root. Pop down to and including
81
+ # self; if self is not on the stack (double __exit__, or an ancestor
82
+ # already unwound us), do nothing.
83
+ stack = self._stack
84
+ if self not in stack:
85
+ return False
86
+ while stack and stack[-1] is not self:
87
+ stack.pop() # discard a leaked descendant frame
88
+ stack.pop() # pop self
89
+ if stack:
90
+ stack[-1].children.append(self)
91
+ else:
92
+ _tls.root = self # outermost phase closed -> the build root
93
+ return False
94
+
95
+ def to_dict(self):
96
+ d = {"name": self.name, "elapsed_ms": round(self.elapsed_ms, 3)}
97
+ if self.count is not None:
98
+ d["count"] = self.count
99
+ if self.meta:
100
+ d["meta"] = dict(self.meta)
101
+ if self.children:
102
+ d["children"] = [c.to_dict() for c in self.children]
103
+ return d
104
+
105
+
106
+ class _NullPhase:
107
+ """Shared no-op returned when tracing is off. No allocation per phase()."""
108
+
109
+ def set_count(self, n):
110
+ pass
111
+
112
+ def set_meta(self, **kw):
113
+ pass
114
+
115
+ def __enter__(self):
116
+ return self
117
+
118
+ def __exit__(self, *exc):
119
+ return False
120
+
121
+
122
+ _NULL_PHASE = _NullPhase()
123
+
124
+
125
+ def phase(name):
126
+ if not _ENABLED:
127
+ return _NULL_PHASE
128
+ return Phase(name, _stack())
129
+
130
+
131
+ def current_root():
132
+ return getattr(_tls, "root", None)
133
+
134
+
135
+ def reset_thread():
136
+ _tls.stack = []
137
+ _tls.root = None
138
+
139
+
140
+ def flush_stderr(root):
141
+ if root is None:
142
+ return
143
+ lines = []
144
+
145
+ def walk(p, depth):
146
+ indent = " " * depth
147
+ extra = ""
148
+ if p.count is not None:
149
+ extra += f" (count={p.count})"
150
+ if p.meta:
151
+ extra += " " + " ".join(f"{k}={v}" for k, v in p.meta.items())
152
+ lines.append(f"{indent}{p.name} {p.elapsed_ms:.1f}ms{extra}")
153
+ for c in p.children:
154
+ walk(c, depth + 1)
155
+
156
+ walk(root, 0)
157
+ sys.stderr.write("backend-perf:\n" + "\n".join(lines) + "\n")
158
+
159
+
160
+ # ── process-global last-completed-tree slot (dashboard -> endpoint) ──────────
161
+ # The writer freezes the tree with to_dict() then binds the module global in
162
+ # ONE statement; once bound the dict is never mutated (the next build binds a
163
+ # fresh dict). Assignment is atomic under the GIL, so the HTTP reader thread
164
+ # always sees a whole, immutable "last completed build".
165
+ _LAST_BACKEND_PERF = None
166
+
167
+
168
+ def stash_last(root, *, generation=None, generated_at=None):
169
+ global _LAST_BACKEND_PERF
170
+ if root is None:
171
+ return
172
+ _LAST_BACKEND_PERF = {
173
+ "generated_at": generated_at,
174
+ "generation": generation,
175
+ "phases": root.to_dict(),
176
+ }
177
+
178
+
179
+ def last_backend_perf():
180
+ return _LAST_BACKEND_PERF
@@ -55,6 +55,29 @@ def is_loopback(host: str) -> bool:
55
55
  return False
56
56
 
57
57
 
58
+ def debug_backend_allowed(peer_ip, host) -> bool:
59
+ """Gate for the loopback-only ``/api/debug/backend`` diagnostic endpoint
60
+ (issue #276, Session A).
61
+
62
+ STRICTER than the transcript gate — it never opens to the LAN. Two checks:
63
+
64
+ * PRIMARY: the TCP peer (``client_address[0]``) must be a loopback
65
+ literal. This is the unspoofable signal — the dashboard can bind
66
+ ``0.0.0.0`` (``dashboard.bind = lan``), and a ``Host``-header-only
67
+ check would let a LAN client connect to the LAN socket while sending
68
+ ``Host: 127.0.0.1``. The peer address cannot be spoofed that way.
69
+ * DEFENSE-IN-DEPTH: the ``Host`` authority must ALSO be an IP-literal
70
+ loopback (anti-DNS-rebinding) — a hostname ``Host`` (a rebinding
71
+ vector) is rejected even from a loopback peer.
72
+
73
+ ``dashboard.expose_transcripts`` is NEVER consulted — this surface is
74
+ loopback-only, ALWAYS. Fail closed on a missing/empty peer or Host.
75
+ """
76
+ if not is_loopback(peer_ip):
77
+ return False
78
+ return is_loopback(authority_host(host))
79
+
80
+
58
81
  def transcripts_allowed(bind_host, expose: bool) -> bool:
59
82
  """Are transcripts served AT ALL on this bind? Loopback bind always; a
60
83
  non-loopback (LAN) bind only under the explicit ``expose`` opt-in."""