glab-cli-extras 0.1.0__py3-none-any.whl

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.
Files changed (44) hide show
  1. glab_cli_extras/__init__.py +3 -0
  2. glab_cli_extras/artifacts/__init__.py +0 -0
  3. glab_cli_extras/artifacts/job_logs.py +95 -0
  4. glab_cli_extras/artifacts/threads.py +147 -0
  5. glab_cli_extras/cli/__init__.py +0 -0
  6. glab_cli_extras/cli/watch.py +426 -0
  7. glab_cli_extras/daemon/__init__.py +0 -0
  8. glab_cli_extras/daemon/__main__.py +10 -0
  9. glab_cli_extras/daemon/engine.py +491 -0
  10. glab_cli_extras/daemon/lifecycle.py +186 -0
  11. glab_cli_extras/daemon/locks.py +85 -0
  12. glab_cli_extras/daemon/scheduler.py +52 -0
  13. glab_cli_extras/daemon/server.py +79 -0
  14. glab_cli_extras/daemon/transport.py +185 -0
  15. glab_cli_extras/events.py +165 -0
  16. glab_cli_extras/gitlab/__init__.py +0 -0
  17. glab_cli_extras/gitlab/budget.py +135 -0
  18. glab_cli_extras/gitlab/client.py +225 -0
  19. glab_cli_extras/gitlab/glab.py +175 -0
  20. glab_cli_extras/gitlab/queries/__init__.py +11 -0
  21. glab_cli_extras/gitlab/queries/branch_pipelines.graphql +8 -0
  22. glab_cli_extras/gitlab/queries/mr.graphql +42 -0
  23. glab_cli_extras/gitlab/queries/pipeline.graphql +38 -0
  24. glab_cli_extras/gitlab/refs.py +151 -0
  25. glab_cli_extras/paths.py +79 -0
  26. glab_cli_extras/py.typed +0 -0
  27. glab_cli_extras/resources/__init__.py +19 -0
  28. glab_cli_extras/resources/base.py +146 -0
  29. glab_cli_extras/resources/branch.py +265 -0
  30. glab_cli_extras/resources/mr.py +445 -0
  31. glab_cli_extras/resources/mr_list.py +150 -0
  32. glab_cli_extras/resources/pipeline.py +319 -0
  33. glab_cli_extras/serde.py +75 -0
  34. glab_cli_extras/watch/__init__.py +0 -0
  35. glab_cli_extras/watch/changelog.py +86 -0
  36. glab_cli_extras/watch/claude.py +193 -0
  37. glab_cli_extras/watch/client.py +316 -0
  38. glab_cli_extras/watch/sinks.py +83 -0
  39. glab_cli_extras/watch/streams.py +80 -0
  40. glab_cli_extras-0.1.0.dist-info/METADATA +230 -0
  41. glab_cli_extras-0.1.0.dist-info/RECORD +44 -0
  42. glab_cli_extras-0.1.0.dist-info/WHEEL +4 -0
  43. glab_cli_extras-0.1.0.dist-info/entry_points.txt +2 -0
  44. glab_cli_extras-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,491 @@
1
+ """The polling engine: subscriptions with leases, refcounted resources, per-resource event logs, and the loop that
2
+ polls resources under each host's budget.
3
+
4
+ The daemon serves the engine over its socket. `--no-daemon` runs the same engine inside the client process.
5
+
6
+ - **Resources** are shared: two subscriptions to the same resource use one poller, which does the union of the work
7
+ their filters need. A resource can also be held by a parent resource (an MR holds its head pipeline); the parent
8
+ then receives the child's events in its own log.
9
+ - **Event log:** each resource keeps its last 500 events with sequence numbers. A new subscriber starts at the head of
10
+ the log and gets a private `watch_started` with the current snapshot, never the backlog.
11
+ - **Leases:** every client poll renews all of that client's subscriptions. A subscription not renewed within the lease
12
+ timeout is dropped, so a killed client never keeps a poller alive.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ import contextlib
19
+ import logging
20
+ import time
21
+ import uuid
22
+ from collections import deque
23
+ from collections.abc import Callable, Mapping
24
+ from dataclasses import dataclass, field
25
+ from pathlib import Path
26
+ from typing import Any
27
+
28
+ from glab_cli_extras.artifacts.job_logs import LOGS_MODES, LogsMode, merge_logs_modes
29
+ from glab_cli_extras.daemon.scheduler import Scheduler
30
+ from glab_cli_extras.events import (
31
+ LIFECYCLE_KINDS,
32
+ WATCH_DEGRADED,
33
+ WATCH_ENDED,
34
+ WATCH_STARTED,
35
+ Event,
36
+ RawEvent,
37
+ utc_now_iso,
38
+ )
39
+ from glab_cli_extras.gitlab.client import (
40
+ ApiError,
41
+ GitLabClient,
42
+ NotFound,
43
+ RateLimited,
44
+ TransportError,
45
+ Unauthorized,
46
+ )
47
+ from glab_cli_extras.gitlab.glab import GlabNotFoundError, GlabRunner, run_glab
48
+ from glab_cli_extras.gitlab.refs import ResourceRef
49
+ from glab_cli_extras.resources import POLLERS
50
+ from glab_cli_extras.resources.base import PollContext, Poller, PollerOptions, PushFeedCache
51
+
52
+ log = logging.getLogger(__name__)
53
+
54
+ EVENT_LOG_SIZE = 500
55
+ DEFAULT_LEASE_TIMEOUT = 20.0
56
+ DEFAULT_CLIENT_POLL_MS = 5000
57
+ MAX_INFLIGHT_PER_HOST = 4
58
+ MAX_ERROR_BACKOFF = 300.0
59
+
60
+
61
+ class EngineError(Exception):
62
+ pass
63
+
64
+
65
+ @dataclass
66
+ class Subscription:
67
+ id: str
68
+ client_id: str
69
+ ref: ResourceRef
70
+ events: frozenset[str]
71
+ logs: LogsMode
72
+ renewed_at: float
73
+ started: bool = False
74
+ private: list[Event] = field(default_factory=list)
75
+ sent_snapshot_hash: str | None = None
76
+
77
+
78
+ @dataclass
79
+ class Resource:
80
+ ref: ResourceRef
81
+ poller: Poller[Any]
82
+ log: deque[Event] = field(default_factory=lambda: deque(maxlen=EVENT_LOG_SIZE))
83
+ seq: int = 0
84
+ subscriptions: set[str] = field(default_factory=set)
85
+ parents: set[str] = field(default_factory=set)
86
+ children: set[str] = field(default_factory=set)
87
+ polled: bool = False
88
+ polling: bool = False
89
+ ended: bool = False
90
+ end_event: Event | None = None
91
+ degraded: str | None = None
92
+ failures: int = 0
93
+ polls: int = 0
94
+ last_poll_at: float | None = None
95
+ last_error: str | None = None
96
+
97
+ @property
98
+ def key(self) -> str:
99
+ return self.ref.key
100
+
101
+ @property
102
+ def held(self) -> bool:
103
+ return bool(self.subscriptions or self.parents)
104
+
105
+
106
+ class Engine:
107
+ def __init__(
108
+ self,
109
+ *,
110
+ cache_root: Path,
111
+ runner: GlabRunner = run_glab,
112
+ clock: Callable[[], float] = time.time,
113
+ lease_timeout: float = DEFAULT_LEASE_TIMEOUT,
114
+ client_poll_ms: int = DEFAULT_CLIENT_POLL_MS,
115
+ ) -> None:
116
+ self.cache_root = cache_root
117
+ self.clock = clock
118
+ self.lease_timeout = lease_timeout
119
+ self.client_poll_ms = client_poll_ms
120
+ self.started_at = clock()
121
+ self.resources: dict[str, Resource] = {}
122
+ self.subscriptions: dict[str, Subscription] = {}
123
+ self.scheduler = Scheduler()
124
+ self._runner = runner
125
+ self._clients: dict[str, GitLabClient] = {}
126
+ self._push_feeds = PushFeedCache()
127
+ self._inflight: dict[str, int] = {}
128
+ self._tasks: set[asyncio.Task[None]] = set()
129
+ self._wake: asyncio.Event | None = None
130
+ self._idle_since: float | None = clock()
131
+
132
+ # ----------------------------------------------------------------------------------------------------------
133
+ # Client-facing operations
134
+
135
+ def client_for(self, host: str) -> GitLabClient:
136
+ client = self._clients.get(host)
137
+ if client is None:
138
+ client = GitLabClient(host, runner=self._runner, clock=self.clock)
139
+ self._clients[host] = client
140
+ return client
141
+
142
+ def subscribe(
143
+ self,
144
+ client_id: str,
145
+ ref: ResourceRef,
146
+ events: frozenset[str],
147
+ logs: LogsMode = "failed",
148
+ baseline: Any = None,
149
+ ) -> dict[str, Any]:
150
+ if logs not in LOGS_MODES:
151
+ raise EngineError(f"unknown logs mode {logs!r}")
152
+ now = self.clock()
153
+ res = self._acquire(ref)
154
+ if baseline is not None and not res.polled:
155
+ res.poller.seed(baseline)
156
+ sub = Subscription(uuid.uuid4().hex, client_id, ref, events | frozenset(LIFECYCLE_KINDS), logs, now)
157
+ self.subscriptions[sub.id] = sub
158
+ res.subscriptions.add(sub.id)
159
+ self._idle_since = None
160
+ cursor = res.seq
161
+ if res.polled:
162
+ sub.private.append(self._started_event(res))
163
+ sub.started = True
164
+ if res.end_event is not None:
165
+ sub.private.append(res.end_event)
166
+ self._recompute_options(res)
167
+ log.info("subscribe %s client=%s sub=%s", ref.key, client_id[:8], sub.id[:8])
168
+ self._kick()
169
+ return {"subscription_id": sub.id, "cursor": cursor}
170
+
171
+ def poll(self, client_id: str, cursors: Mapping[str, int]) -> dict[str, Any]:
172
+ now = self.clock()
173
+ for owned in self.subscriptions.values():
174
+ if owned.client_id == client_id:
175
+ owned.renewed_at = now
176
+ events: dict[str, list[dict[str, Any]]] = {}
177
+ snapshots: dict[str, dict[str, Any]] = {}
178
+ unknown: list[str] = []
179
+ for sid, cursor in cursors.items():
180
+ sub = self.subscriptions.get(sid)
181
+ if sub is None or sub.client_id != client_id:
182
+ unknown.append(sid)
183
+ continue
184
+ res = self.resources.get(sub.ref.key)
185
+ if res is None:
186
+ unknown.append(sid)
187
+ continue
188
+ batch = list(sub.private)
189
+ sub.private.clear()
190
+ batch.extend(e for e in res.log if e.seq > cursor and e.kind in sub.events)
191
+ if batch:
192
+ events[sid] = [e.to_json() for e in batch]
193
+ snapshot_hash = res.poller.snapshot_hash()
194
+ if snapshot_hash is not None and snapshot_hash != sub.sent_snapshot_hash:
195
+ snapshots[sid] = {"hash": snapshot_hash, "data": res.poller.snapshot_json()}
196
+ sub.sent_snapshot_hash = snapshot_hash
197
+ return {"events": events, "snapshots": snapshots, "unknown": unknown, "next_poll_ms": self.client_poll_ms}
198
+
199
+ def unsubscribe(self, client_id: str, subscription_id: str) -> bool:
200
+ sub = self.subscriptions.get(subscription_id)
201
+ if sub is None or sub.client_id != client_id:
202
+ return False
203
+ self._drop_subscription(sub, "unsubscribed")
204
+ return True
205
+
206
+ def idle_for(self, now: float) -> float:
207
+ """Seconds without any subscription, 0 while there is one."""
208
+ if self.subscriptions or self._idle_since is None:
209
+ return 0.0
210
+ return now - self._idle_since
211
+
212
+ def reap(self, now: float) -> None:
213
+ expired = [s for s in self.subscriptions.values() if now - s.renewed_at > self.lease_timeout]
214
+ for sub in expired:
215
+ self._drop_subscription(sub, "lease expired")
216
+
217
+ def status(self) -> dict[str, Any]:
218
+ now = self.clock()
219
+ return {
220
+ "subscriptions": [
221
+ {
222
+ "id": s.id,
223
+ "client_id": s.client_id,
224
+ "resource": s.ref.key,
225
+ "idle_s": round(now - s.renewed_at, 1),
226
+ "logs": s.logs,
227
+ "events": sorted(s.events - frozenset(LIFECYCLE_KINDS)),
228
+ }
229
+ for s in self.subscriptions.values()
230
+ ],
231
+ "resources": [
232
+ {
233
+ "resource": r.key,
234
+ "subscriptions": len(r.subscriptions),
235
+ "parents": sorted(r.parents),
236
+ "children": sorted(r.children),
237
+ "seq": r.seq,
238
+ "polls": r.polls,
239
+ "ended": r.ended,
240
+ "degraded": r.degraded,
241
+ "last_error": r.last_error,
242
+ "next_poll_in_s": round(d - now, 1) if (d := self.scheduler.due_at(r.key)) is not None else None,
243
+ }
244
+ for r in self.resources.values()
245
+ ],
246
+ "hosts": {
247
+ host: {"budget": c.budget.describe(now), "requests": c.stats.describe()}
248
+ for host, c in sorted(self._clients.items())
249
+ },
250
+ }
251
+
252
+ # ----------------------------------------------------------------------------------------------------------
253
+ # Resources
254
+
255
+ def _acquire(self, ref: ResourceRef) -> Resource:
256
+ res = self.resources.get(ref.key)
257
+ if res is None:
258
+ ctx = PollContext(self.client_for(ref.host), self.cache_root, self.clock, self._push_feeds)
259
+ poller = POLLERS[ref.kind](ref, ctx, PollerOptions(frozenset(), "none"))
260
+ res = Resource(ref, poller)
261
+ self.resources[ref.key] = res
262
+ self.scheduler.schedule(ref.key, self.clock(), urgent=True)
263
+ log.info("start polling %s", ref.key)
264
+ return res
265
+
266
+ def _release(self, res: Resource) -> None:
267
+ if res.held or res.key not in self.resources:
268
+ return
269
+ del self.resources[res.key]
270
+ self.scheduler.forget(res.key)
271
+ log.info("stop polling %s", res.key)
272
+ for child_key in list(res.children):
273
+ self._unlink_child(res, child_key)
274
+
275
+ def _unlink_child(self, parent: Resource, child_key: str) -> None:
276
+ parent.children.discard(child_key)
277
+ child = self.resources.get(child_key)
278
+ if child is None:
279
+ return
280
+ child.parents.discard(parent.key)
281
+ self._recompute_options(child)
282
+ self._release(child)
283
+
284
+ def _drop_subscription(self, sub: Subscription, why: str) -> None:
285
+ self.subscriptions.pop(sub.id, None)
286
+ log.info("drop subscription %s on %s: %s", sub.id[:8], sub.ref.key, why)
287
+ res = self.resources.get(sub.ref.key)
288
+ if res is not None:
289
+ res.subscriptions.discard(sub.id)
290
+ self._recompute_options(res)
291
+ self._release(res)
292
+ if not self.subscriptions:
293
+ self._idle_since = self.clock()
294
+
295
+ def _recompute_options(self, res: Resource) -> None:
296
+ events: set[str] = set()
297
+ modes: list[LogsMode] = []
298
+ for sid in res.subscriptions:
299
+ sub = self.subscriptions[sid]
300
+ events |= sub.events
301
+ modes.append(sub.logs)
302
+ for parent_key in res.parents:
303
+ parent = self.resources.get(parent_key)
304
+ options = parent.poller.child_options(res.ref) if parent is not None else None
305
+ if options is not None:
306
+ events |= options.events
307
+ modes.append(options.logs)
308
+ new = PollerOptions(frozenset(events), merge_logs_modes(modes))
309
+ if new == res.poller.options:
310
+ return
311
+ grew = not new.events <= res.poller.options.events
312
+ res.poller.set_options(new)
313
+ if grew and res.polled and not res.ended and not res.polling:
314
+ self.scheduler.schedule(res.key, self.clock(), urgent=True)
315
+ self._kick()
316
+ for child_key in list(res.children):
317
+ child = self.resources.get(child_key)
318
+ if child is not None:
319
+ self._recompute_options(child)
320
+
321
+ def _reconcile_children(self, res: Resource) -> None:
322
+ wanted = {ref.key: ref for ref in res.poller.children()} if not res.ended else {}
323
+ for child_key in list(res.children):
324
+ if child_key not in wanted:
325
+ self._unlink_child(res, child_key)
326
+ for key, ref in wanted.items():
327
+ if key in res.children:
328
+ continue
329
+ child = self._acquire(ref)
330
+ child.parents.add(res.key)
331
+ res.children.add(key)
332
+ self._recompute_options(child)
333
+
334
+ # ----------------------------------------------------------------------------------------------------------
335
+ # Events
336
+
337
+ def _started_event(self, res: Resource) -> Event:
338
+ data = {"summary": res.poller.summary(), "snapshot_hash": res.poller.snapshot_hash()}
339
+ return Event(WATCH_STARTED, res.key, res.seq, utc_now_iso(), data)
340
+
341
+ def _append(self, res: Resource, raw: RawEvent) -> None:
342
+ res.seq += 1
343
+ event = Event(raw.kind, res.key, res.seq, utc_now_iso(), dict(raw.data), raw.artifacts)
344
+ res.log.append(event)
345
+ if raw.kind in LIFECYCLE_KINDS:
346
+ return
347
+ for parent_key in res.parents:
348
+ parent = self.resources.get(parent_key)
349
+ if parent is not None and not parent.ended:
350
+ self._append(parent, raw)
351
+
352
+ def _end(self, res: Resource, reason: str, data: Mapping[str, Any]) -> None:
353
+ self._append(res, RawEvent(WATCH_ENDED, {"reason": reason, **data}))
354
+ res.ended = True
355
+ res.end_event = res.log[-1]
356
+ self.scheduler.remove(res.key)
357
+ for child_key in list(res.children):
358
+ self._unlink_child(res, child_key)
359
+ log.info("ended %s: %s", res.key, reason)
360
+
361
+ def _degrade(self, res: Resource, reason: str, message: str) -> None:
362
+ res.last_error = message
363
+ if res.degraded == reason:
364
+ return
365
+ res.degraded = reason
366
+ self._append(res, RawEvent(WATCH_DEGRADED, {"reason": reason, "message": message}))
367
+ log.warning("degraded %s: %s: %s", res.key, reason, message)
368
+
369
+ # ----------------------------------------------------------------------------------------------------------
370
+ # Polling
371
+
372
+ def _kick(self) -> None:
373
+ if self._wake is not None:
374
+ self._wake.set()
375
+
376
+ def _stretch(self, host: str, now: float) -> float:
377
+ client = self._clients[host]
378
+ demand = sum(
379
+ r.poller.expected_cost() / max(r.poller.next_delay(), 0.5)
380
+ for r in self.resources.values()
381
+ if r.ref.host == host and not r.ended
382
+ )
383
+ return client.budget.stretch_factor(demand, now)
384
+
385
+ async def _poll(self, res: Resource) -> None:
386
+ host = res.ref.host
387
+ client = self.client_for(host)
388
+ delay: float | None = None
389
+ try:
390
+ raws = await res.poller.poll()
391
+ except NotFound as exc:
392
+ if not res.polled:
393
+ self._end(res, "not_found", {"message": str(exc)})
394
+ else:
395
+ self._degrade(res, "not_found", str(exc))
396
+ delay = self._error_delay(res)
397
+ except RateLimited as exc:
398
+ self._degrade(res, "rate_limited", str(exc))
399
+ delay = max(self._error_delay(res), client.budget.blocked_until - self.clock())
400
+ except Unauthorized as exc:
401
+ self._degrade(res, "auth", str(exc))
402
+ delay = self._error_delay(res)
403
+ except (TransportError, ApiError, GlabNotFoundError) as exc:
404
+ self._degrade(res, "error", str(exc))
405
+ delay = self._error_delay(res)
406
+ except Exception as exc:
407
+ log.exception("poll of %s failed", res.key)
408
+ self._degrade(res, "error", f"{type(exc).__name__}: {exc}")
409
+ delay = self._error_delay(res)
410
+ else:
411
+ res.failures = 0
412
+ res.degraded = None
413
+ res.last_error = None
414
+ first = not res.polled
415
+ res.polled = True
416
+ for raw in raws:
417
+ self._append(res, raw)
418
+ if first:
419
+ for sid in res.subscriptions:
420
+ sub = self.subscriptions[sid]
421
+ if not sub.started:
422
+ sub.private.append(self._started_event(res))
423
+ sub.started = True
424
+ if res.poller.terminal:
425
+ self._end(res, res.poller.terminal_reason or "finished", res.poller.terminal_data)
426
+ else:
427
+ self._reconcile_children(res)
428
+ now = self.clock()
429
+ delay = res.poller.next_delay() * self._stretch(host, now)
430
+ finally:
431
+ res.polling = False
432
+ res.polls += 1
433
+ res.last_poll_at = self.clock()
434
+ self._inflight[host] = self._inflight.get(host, 1) - 1
435
+ if delay is not None and not res.ended and res.key in self.resources:
436
+ self.scheduler.schedule(res.key, self.clock() + delay)
437
+ self._kick()
438
+
439
+ def _error_delay(self, res: Resource) -> float:
440
+ res.failures += 1
441
+ return float(min(res.poller.next_delay() * (2 ** min(res.failures, 8)), MAX_ERROR_BACKOFF))
442
+
443
+ def _start_due(self, now: float) -> float:
444
+ """Start every due poll the budgets allow. Return seconds until the loop should look again."""
445
+ wait = 1.0
446
+ for key in self.scheduler.ready(now):
447
+ res = self.resources.get(key)
448
+ if res is None:
449
+ self.scheduler.forget(key)
450
+ continue
451
+ host = res.ref.host
452
+ if self._inflight.get(host, 0) >= MAX_INFLIGHT_PER_HOST:
453
+ continue
454
+ budget = self.client_for(host).budget
455
+ cost = res.poller.expected_cost()
456
+ if not budget.can_spend(cost, now):
457
+ wait = min(wait, max(0.05, budget.wait_time(cost, now)))
458
+ continue
459
+ self.scheduler.mark_served(key, now)
460
+ res.polling = True
461
+ self._inflight[host] = self._inflight.get(host, 0) + 1
462
+ task = asyncio.create_task(self._poll(res))
463
+ self._tasks.add(task)
464
+ task.add_done_callback(self._tasks.discard)
465
+ next_due = self.scheduler.next_due()
466
+ if next_due is not None:
467
+ wait = min(wait, max(0.05, next_due - now))
468
+ return wait
469
+
470
+ async def poll_due_once(self) -> None:
471
+ """Start every due poll and wait for all of them: one deterministic step, for tests and debugging."""
472
+ self.reap(self.clock())
473
+ self._start_due(self.clock())
474
+ while self._tasks:
475
+ await asyncio.gather(*list(self._tasks))
476
+
477
+ async def run(self, stop: asyncio.Event) -> None:
478
+ self._wake = asyncio.Event()
479
+ try:
480
+ while not stop.is_set():
481
+ now = self.clock()
482
+ self.reap(now)
483
+ wait = self._start_due(now)
484
+ self._wake.clear()
485
+ with contextlib.suppress(TimeoutError):
486
+ await asyncio.wait_for(self._wake.wait(), wait)
487
+ finally:
488
+ for task in list(self._tasks):
489
+ task.cancel()
490
+ if self._tasks:
491
+ await asyncio.gather(*self._tasks, return_exceptions=True)
@@ -0,0 +1,186 @@
1
+ """Daemon lifecycle: the daemon's own main loop, and the client side that finds or starts it.
2
+
3
+ There is one daemon per user and wire-protocol version. It holds an exclusive lock on `daemon-v<N>.lock` for its
4
+ whole life, and exits after `idle_timeout` seconds without subscriptions. Clients start it on demand; the spawn lock
5
+ makes concurrent clients start one daemon, not one each.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import contextlib
12
+ import logging
13
+ import os
14
+ import signal
15
+ import subprocess
16
+ import sys
17
+ import time
18
+ from typing import Any
19
+
20
+ from glab_cli_extras import __version__
21
+ from glab_cli_extras.daemon.engine import DEFAULT_CLIENT_POLL_MS, DEFAULT_LEASE_TIMEOUT, Engine
22
+ from glab_cli_extras.daemon.locks import FileLock
23
+ from glab_cli_extras.daemon.server import handle
24
+ from glab_cli_extras.daemon.transport import DaemonFiles, DaemonUnavailable, request, start_server, use_tcp
25
+ from glab_cli_extras.paths import ensure_private_dir, user_cache_dir
26
+
27
+ log = logging.getLogger("glab_cli_extras.daemon")
28
+
29
+ DEFAULT_IDLE_TIMEOUT = 30.0
30
+ SPAWN_TIMEOUT = 10.0
31
+ MAX_LOG_BYTES = 5 * 1024 * 1024
32
+ IDLE_TIMEOUT_ENV = "GLAB_CLI_EXTRAS_IDLE_TIMEOUT"
33
+ LEASE_TIMEOUT_ENV = "GLAB_CLI_EXTRAS_LEASE_TIMEOUT"
34
+ CLIENT_POLL_MS_ENV = "GLAB_CLI_EXTRAS_CLIENT_POLL_MS"
35
+
36
+
37
+ def _env_float(name: str, default: float) -> float:
38
+ try:
39
+ return float(os.environ.get(name, default))
40
+ except ValueError:
41
+ return default
42
+
43
+
44
+ async def serve(files: DaemonFiles, engine: Engine, idle_timeout: float) -> None:
45
+ stop = asyncio.Event()
46
+ loop = asyncio.get_running_loop()
47
+ if os.name != "nt":
48
+ for sig in (signal.SIGTERM, signal.SIGINT):
49
+ loop.add_signal_handler(sig, stop.set)
50
+
51
+ async def on_request(req: dict[str, Any]) -> dict[str, Any]:
52
+ return handle(engine, req, stop)
53
+
54
+ server, cleanup = await start_server(files, on_request)
55
+ log.info(
56
+ "daemon %s started, pid %d, protocol endpoint %s, idle timeout %.0f s",
57
+ __version__,
58
+ os.getpid(),
59
+ files.describe_endpoint(),
60
+ idle_timeout,
61
+ )
62
+ engine_task = asyncio.create_task(engine.run(stop))
63
+ try:
64
+ while not stop.is_set():
65
+ with contextlib.suppress(TimeoutError):
66
+ await asyncio.wait_for(stop.wait(), 1.0)
67
+ if engine.idle_for(engine.clock()) >= idle_timeout:
68
+ log.info("no subscriptions for %.0f s, exiting", idle_timeout)
69
+ stop.set()
70
+ finally:
71
+ # Remove the endpoint first, so a client that connects from now on starts a new daemon.
72
+ cleanup()
73
+ server.close()
74
+ await server.wait_closed()
75
+ stop.set()
76
+ await engine_task
77
+ log.info("daemon stopped")
78
+
79
+
80
+ def run_daemon() -> int:
81
+ """The daemon process: take the lock or exit, then serve until idle or stopped."""
82
+ files = DaemonFiles.default()
83
+ ensure_private_dir(files.state_dir)
84
+ logging.basicConfig(
85
+ level=logging.DEBUG if os.environ.get("GLAB_CLI_EXTRAS_DEBUG") else logging.INFO,
86
+ format="%(asctime)s %(levelname)s %(name)s: %(message)s",
87
+ stream=sys.stderr,
88
+ )
89
+ lock = FileLock(files.lock)
90
+ if not lock.try_acquire():
91
+ log.info("another daemon holds %s, exiting", files.lock)
92
+ return 0
93
+ try:
94
+ engine = Engine(
95
+ cache_root=user_cache_dir(),
96
+ lease_timeout=_env_float(LEASE_TIMEOUT_ENV, DEFAULT_LEASE_TIMEOUT),
97
+ client_poll_ms=int(_env_float(CLIENT_POLL_MS_ENV, DEFAULT_CLIENT_POLL_MS)),
98
+ )
99
+ asyncio.run(serve(files, engine, _env_float(IDLE_TIMEOUT_ENV, DEFAULT_IDLE_TIMEOUT)))
100
+ finally:
101
+ lock.release()
102
+ return 0
103
+
104
+
105
+ def ping(files: DaemonFiles, timeout: float = 2.0) -> dict[str, Any] | None:
106
+ try:
107
+ response = request(files, {"op": "hello"}, timeout=timeout)
108
+ except (DaemonUnavailable, ValueError):
109
+ return None
110
+ return response if response.get("ok") else None
111
+
112
+
113
+ def _rotate_log(files: DaemonFiles) -> None:
114
+ with contextlib.suppress(OSError):
115
+ if files.log.stat().st_size > MAX_LOG_BYTES:
116
+ os.replace(files.log, files.log.with_suffix(".log.1"))
117
+
118
+
119
+ def spawn_daemon(files: DaemonFiles) -> subprocess.Popen[bytes]:
120
+ """Start a detached daemon process with its output in the log file."""
121
+ ensure_private_dir(files.state_dir)
122
+ _rotate_log(files)
123
+ with open(files.log, "ab") as log_file:
124
+ kwargs: dict[str, Any] = {
125
+ "stdin": subprocess.DEVNULL,
126
+ "stdout": log_file,
127
+ "stderr": log_file,
128
+ "cwd": str(files.state_dir),
129
+ "close_fds": True,
130
+ }
131
+ if sys.platform == "win32":
132
+ kwargs["creationflags"] = subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP
133
+ else:
134
+ kwargs["start_new_session"] = True
135
+ return subprocess.Popen([sys.executable, "-m", "glab_cli_extras.daemon"], **kwargs)
136
+
137
+
138
+ def ensure_daemon(files: DaemonFiles, timeout: float = SPAWN_TIMEOUT) -> dict[str, Any]:
139
+ """Return the hello response of a running daemon, starting one first if needed."""
140
+ hello = ping(files)
141
+ if hello is not None:
142
+ return hello
143
+ with FileLock(files.spawn_lock) as spawn_lock:
144
+ spawn_lock.acquire(timeout)
145
+ deadline = time.monotonic() + timeout
146
+ spawned: subprocess.Popen[bytes] | None = None
147
+ while time.monotonic() < deadline:
148
+ hello = ping(files, timeout=1.0)
149
+ if hello is not None:
150
+ return hello
151
+ # A daemon that is exiting on idle releases its lock last; spawn again once it is gone.
152
+ if (spawned is None or spawned.poll() is not None) and not daemon_running(files):
153
+ # Nothing holds the daemon lock, so no daemon is alive: any endpoint file left is stale.
154
+ for stale in (files.socket, files.endpoint):
155
+ with contextlib.suppress(OSError):
156
+ stale.unlink()
157
+ spawned = spawn_daemon(files)
158
+ time.sleep(0.05)
159
+ raise DaemonUnavailable(f"the daemon did not start within {timeout:.0f} s; see {files.log}")
160
+
161
+
162
+ def daemon_running(files: DaemonFiles) -> bool:
163
+ """Whether a daemon process holds the lock, answering or not."""
164
+ probe = FileLock(files.lock)
165
+ if probe.try_acquire():
166
+ probe.release()
167
+ return False
168
+ return True
169
+
170
+
171
+ def stop_daemon(files: DaemonFiles, timeout: float = 10.0) -> bool:
172
+ """Ask the daemon to stop and wait until it released its lock. Return whether one was running."""
173
+ if not daemon_running(files):
174
+ return False
175
+ with contextlib.suppress(DaemonUnavailable, ValueError):
176
+ request(files, {"op": "stop"}, timeout=2.0)
177
+ deadline = time.monotonic() + timeout
178
+ while time.monotonic() < deadline:
179
+ if not daemon_running(files):
180
+ return True
181
+ time.sleep(0.05)
182
+ raise DaemonUnavailable(f"the daemon did not stop within {timeout:.0f} s; see {files.log}")
183
+
184
+
185
+ def transport_name() -> str:
186
+ return "tcp" if use_tcp() else "unix"