code-coordinator 0.5.331__py3-none-any.whl → 0.5.332__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.
- {code_coordinator-0.5.331.dist-info → code_coordinator-0.5.332.dist-info}/METADATA +1 -1
- {code_coordinator-0.5.331.dist-info → code_coordinator-0.5.332.dist-info}/RECORD +8 -7
- coord/machine_metrics.py +217 -0
- coord/serve_app.py +36 -1
- {code_coordinator-0.5.331.dist-info → code_coordinator-0.5.332.dist-info}/WHEEL +0 -0
- {code_coordinator-0.5.331.dist-info → code_coordinator-0.5.332.dist-info}/entry_points.txt +0 -0
- {code_coordinator-0.5.331.dist-info → code_coordinator-0.5.332.dist-info}/licenses/LICENSE +0 -0
- {code_coordinator-0.5.331.dist-info → code_coordinator-0.5.332.dist-info}/top_level.txt +0 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
code_coordinator-0.5.
|
|
1
|
+
code_coordinator-0.5.332.dist-info/licenses/LICENSE,sha256=851BVZZwf_pEv2ydSlMmcZrwnwSS5YJ98wdLze7lyL8,4212
|
|
2
2
|
coord/__init__.py,sha256=3FN2qrrxtW87xSsSD557YcMwHLDahnFK8LvqQI7Wlf4,9783
|
|
3
3
|
coord/_board_mapping.py,sha256=SGq8hC-gsyXHEZe2L58qy6pyOVQvswTpQ5FzPa_vZ8w,11012
|
|
4
4
|
coord/acceptance.py,sha256=v7k1e_shUIjWKolKDQ9oJv67o0O8L659El3grvcF8ns,75452
|
|
@@ -63,6 +63,7 @@ coord/interactive.py,sha256=AEK-5S2aMknpP6k3Xx-aBuTEulw0_c-gbYPEKTwUjRE,219083
|
|
|
63
63
|
coord/issue_store.py,sha256=abywea0vpXE_BZ5F3QHLDxLKYKDYF7iQYabZhppVYto,69451
|
|
64
64
|
coord/issues_sync_status.py,sha256=_6ucx7oW3TUGZZvs93wRbzR_SUQcNlz-7RptOzr4alo,9262
|
|
65
65
|
coord/liveness_auditor.py,sha256=sf_xBklt37DUnrIEeD4fVp4_gWPDaXbydZgfuWAPFRE,12478
|
|
66
|
+
coord/machine_metrics.py,sha256=mBEciin6gdYJaPw73gY7mrDnalm01WhX7kpzt8t0NEk,9396
|
|
66
67
|
coord/machine_onboard.py,sha256=BTUAC7bdERcDyaIvO2yKkQ8xPwsdR063yHUrOznmHWk,58555
|
|
67
68
|
coord/machine_pause.py,sha256=6s7Dtr5zLs1qB2NWI2-cfbMdPyigK62FTtLjzmY6slE,47521
|
|
68
69
|
coord/merge_queue.py,sha256=UdwyoyfE9DplS6Mia4_aY6TK5a-f933M04Ns5mVHnsw,392677
|
|
@@ -107,7 +108,7 @@ coord/revalidate.py,sha256=fQaYCvObqUawSZWMEOkVWFtYswz9PQQwSMApAyP5zeA,66266
|
|
|
107
108
|
coord/review.py,sha256=hsY6HUif44HL3pY52H7SoqNuMMZzA_9gfHfJKnC-fWE,183282
|
|
108
109
|
coord/scorecard.py,sha256=axe_doBJBBFWtN21JZDRSijwDI0xaZrt2oVYbwJPW7U,20211
|
|
109
110
|
coord/self_health.py,sha256=9PLVdaEReFLUY6y2QXMGVd6U1CdHAh9_8MzduwBOzAY,10651
|
|
110
|
-
coord/serve_app.py,sha256=
|
|
111
|
+
coord/serve_app.py,sha256=_vyNSXqAUWVfsQfYCjJRnqefczof7qKObrMnTysyUkw,509398
|
|
111
112
|
coord/smoke.py,sha256=INLh9Wu57WOcCoOY4CMOcbzArIhMxMolsV2D8hAW7zw,70481
|
|
112
113
|
coord/spend_ceiling.py,sha256=hfuiXlnD4or7Wqoqf6BHcOMT2CXd92FBZuVdnQVlI2M,13573
|
|
113
114
|
coord/split_work.py,sha256=Gp_cNtYBe7nDxBN7cDRs8CJG3V_Y0C1QUi35fHGcvnY,8502
|
|
@@ -248,8 +249,8 @@ coord/skills/release-now/SKILL.md,sha256=u5JY0Jm0IiZYs2JGjRSqNj265CUWcIA6pMaeulb
|
|
|
248
249
|
coord/skills/review-verdict-recovery/SKILL.md,sha256=Grg8GWkJLK20ct2YiigbTFfNOkit7tNrdRIhdocKGJk,3941
|
|
249
250
|
coord/skills/tui-quadraui-workflow/SKILL.md,sha256=4hUZza9wSSnPhf0eRAOh2r_MQZxK2pXYO5g-kxmJq-U,3832
|
|
250
251
|
coord/skills/update-issue/SKILL.md,sha256=VrPinEmLZrCSNDrnz96hQomyO403o5c4i0RdsnUnWA0,3642
|
|
251
|
-
code_coordinator-0.5.
|
|
252
|
-
code_coordinator-0.5.
|
|
253
|
-
code_coordinator-0.5.
|
|
254
|
-
code_coordinator-0.5.
|
|
255
|
-
code_coordinator-0.5.
|
|
252
|
+
code_coordinator-0.5.332.dist-info/METADATA,sha256=1zZHh_75sV2go8WtKq1cX-YI4TfozgKZnV3-wkhwgDk,27178
|
|
253
|
+
code_coordinator-0.5.332.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
254
|
+
code_coordinator-0.5.332.dist-info/entry_points.txt,sha256=o-Hq8pvG7ReGePvN_4iOKjLNqiq-MJ43HbPurqjNPZo,41
|
|
255
|
+
code_coordinator-0.5.332.dist-info/top_level.txt,sha256=w5sTrXesTCebhXyYv2qufOZGo0PoD1Lxgn0Q3MAXjh4,6
|
|
256
|
+
code_coordinator-0.5.332.dist-info/RECORD,,
|
coord/machine_metrics.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Coordinator-side machine metrics sampler + bounded ring buffer (#3020).
|
|
2
|
+
|
|
3
|
+
Part of milestone #71 (Fleet metrics + machines API) — the backend half of
|
|
4
|
+
the coord-web Machines panel.
|
|
5
|
+
|
|
6
|
+
``coord-tui`` renders CPU/mem sparklines by polling **each agent's own**
|
|
7
|
+
``/metrics`` endpoint directly on port 7433 (``coord.agent_app.metrics``, a
|
|
8
|
+
``psutil`` cpu/mem snapshot) and buffering samples in-process for as long as
|
|
9
|
+
the panel stays open (see ``coord-tui/src/app/data.rs``
|
|
10
|
+
``spawn_machine_metrics``). A browser can't do that — ``coord web`` is a
|
|
11
|
+
single-origin HTTP client of the dashboard API, not something that can fan
|
|
12
|
+
out to N agent hosts — and nothing anywhere persists a series; the TUI's
|
|
13
|
+
``VecDeque`` dies with the panel.
|
|
14
|
+
|
|
15
|
+
This module is the one missing thing: a coordinator-side sampler, owned by
|
|
16
|
+
the board daemon (``coord serve``) and driven off its existing periodic-loop
|
|
17
|
+
machinery (mirrors ``coord.health.fleet_snapshot.FleetHealthRefresher`` and
|
|
18
|
+
``coord.gate_snapshot.GateSnapshotRefresher`` — see ``coord.serve_app``'s
|
|
19
|
+
``_machine_metrics_loop``, isolated from ``_tick_loop`` for the identical
|
|
20
|
+
reason those two are: a fan-out HTTP GET per agent must never block, or be
|
|
21
|
+
blocked by, the reconcile/enqueue/drain steps).
|
|
22
|
+
|
|
23
|
+
**Bounded, not incidental (#3020).** Each machine gets its own
|
|
24
|
+
``collections.deque(maxlen=...)`` ring buffer sized for a ~6h window at the
|
|
25
|
+
default ~15s cadence (:data:`DEFAULT_MAX_SAMPLES` = 1440) — old samples are
|
|
26
|
+
evicted automatically as new ones arrive; the process can never accumulate
|
|
27
|
+
unbounded history no matter how long the daemon stays up.
|
|
28
|
+
|
|
29
|
+
**``unknown``, never a silent gap (#1485).** A failed, timed-out, or 503
|
|
30
|
+
(psutil not installed) poll still appends a sample — with
|
|
31
|
+
``status="unknown"`` — never nothing. A renderer building a sparkline from
|
|
32
|
+
this series must be able to tell "no data point" (a gap it filled in itself)
|
|
33
|
+
from "the daemon tried and the agent didn't answer" (an explicit unknown
|
|
34
|
+
sample); silently skipping the append would collapse that distinction back
|
|
35
|
+
into "looks the same as healthy", the exact failure mode
|
|
36
|
+
``coord.health.fleet_snapshot`` already guards against for machine health.
|
|
37
|
+
|
|
38
|
+
**No persistence.** This is purely in-memory; history is lost on daemon
|
|
39
|
+
restart by design (a sqlite time-series was considered and deliberately
|
|
40
|
+
rejected for this milestone — see the issue).
|
|
41
|
+
|
|
42
|
+
**Out of scope here.** The read endpoint for this series (#A2) and putting
|
|
43
|
+
it on ``/board`` (explicitly rejected — every TUI client polls ``/board`` on
|
|
44
|
+
a short interval and ``FleetHealthSnapshot`` already carries a 256 KiB cap
|
|
45
|
+
for exactly this reason) are not this module's job.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
from __future__ import annotations
|
|
49
|
+
|
|
50
|
+
import logging
|
|
51
|
+
import time
|
|
52
|
+
from collections import deque
|
|
53
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
54
|
+
from dataclasses import dataclass
|
|
55
|
+
|
|
56
|
+
import httpx
|
|
57
|
+
|
|
58
|
+
from coord.network import AGENT_PORT, DEFAULT_TIMEOUT, classify_error
|
|
59
|
+
|
|
60
|
+
log = logging.getLogger("coord.serve")
|
|
61
|
+
|
|
62
|
+
# ~6h of history at the default ~15s cadence. Explicit and enforced (each
|
|
63
|
+
# per-machine buffer is a `deque(maxlen=...)`, not a list someone forgot to
|
|
64
|
+
# trim) rather than incidental.
|
|
65
|
+
DEFAULT_MAX_SAMPLES = 1440
|
|
66
|
+
DEFAULT_CADENCE_SECONDS = 15.0
|
|
67
|
+
|
|
68
|
+
STATUS_OK = "ok"
|
|
69
|
+
STATUS_UNKNOWN = "unknown"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class MetricsSample:
|
|
74
|
+
"""One machine, one point in time. ``status="unknown"`` carries no
|
|
75
|
+
cpu/mem values — never fabricated zeros — only a human-readable
|
|
76
|
+
``reason`` for why the poll didn't produce a real reading.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
timestamp: float
|
|
80
|
+
status: str # STATUS_OK | STATUS_UNKNOWN
|
|
81
|
+
cpu_percent: float | None = None
|
|
82
|
+
mem_percent: float | None = None
|
|
83
|
+
mem_used_mb: float | None = None
|
|
84
|
+
mem_total_mb: float | None = None
|
|
85
|
+
reason: str = ""
|
|
86
|
+
|
|
87
|
+
def to_dict(self) -> dict:
|
|
88
|
+
return {
|
|
89
|
+
"timestamp": self.timestamp,
|
|
90
|
+
"status": self.status,
|
|
91
|
+
"cpu_percent": self.cpu_percent,
|
|
92
|
+
"mem_percent": self.mem_percent,
|
|
93
|
+
"mem_used_mb": self.mem_used_mb,
|
|
94
|
+
"mem_total_mb": self.mem_total_mb,
|
|
95
|
+
"reason": self.reason,
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _unknown(reason: str, *, now: float | None = None) -> MetricsSample:
|
|
100
|
+
return MetricsSample(
|
|
101
|
+
timestamp=now if now is not None else time.time(),
|
|
102
|
+
status=STATUS_UNKNOWN,
|
|
103
|
+
reason=reason,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def fetch_metrics(machine, timeout: float = DEFAULT_TIMEOUT) -> MetricsSample: # noqa: ANN001 — coord.models.Machine
|
|
108
|
+
"""GET one machine's agent ``/metrics`` and classify the result.
|
|
109
|
+
|
|
110
|
+
Mirrors ``coord.network.check_machine``'s error classification but
|
|
111
|
+
targets ``/metrics`` instead of ``/health``. Never raises: every failure
|
|
112
|
+
mode — connect/read timeout, DNS failure, connection refused, a non-200
|
|
113
|
+
status (including the documented 503 an agent returns when ``psutil``
|
|
114
|
+
isn't installed), and malformed/missing-field JSON — becomes an explicit
|
|
115
|
+
:data:`STATUS_UNKNOWN` sample rather than an exception or a dropped
|
|
116
|
+
point. The bounded ``timeout`` (default :data:`coord.network.
|
|
117
|
+
DEFAULT_TIMEOUT`, 3s) is what keeps one hanging agent from stalling this
|
|
118
|
+
call past that ceiling; :class:`MachineMetricsSampler.refresh` is what
|
|
119
|
+
keeps that bounded stall from serializing behind every other machine.
|
|
120
|
+
"""
|
|
121
|
+
url = f"http://{machine.host}:{AGENT_PORT}/metrics"
|
|
122
|
+
try:
|
|
123
|
+
resp = httpx.get(url, timeout=timeout)
|
|
124
|
+
except Exception as e: # noqa: BLE001 — classify every network error, never raise
|
|
125
|
+
_, reason = classify_error(e)
|
|
126
|
+
return _unknown(reason)
|
|
127
|
+
|
|
128
|
+
if resp.status_code == 503:
|
|
129
|
+
# #207's documented shape: psutil not installed on that agent.
|
|
130
|
+
return _unknown("psutil not installed on agent")
|
|
131
|
+
if resp.status_code != 200:
|
|
132
|
+
return _unknown(f"HTTP {resp.status_code}")
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
data = resp.json()
|
|
136
|
+
except ValueError:
|
|
137
|
+
return _unknown("invalid JSON from /metrics")
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
return MetricsSample(
|
|
141
|
+
timestamp=time.time(),
|
|
142
|
+
status=STATUS_OK,
|
|
143
|
+
cpu_percent=float(data["cpu_percent"]),
|
|
144
|
+
mem_percent=float(data["mem_percent"]),
|
|
145
|
+
mem_used_mb=float(data["mem_used_mb"]),
|
|
146
|
+
mem_total_mb=float(data["mem_total_mb"]),
|
|
147
|
+
)
|
|
148
|
+
except (KeyError, TypeError, ValueError):
|
|
149
|
+
return _unknown("malformed /metrics payload")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class MachineMetricsSampler:
|
|
153
|
+
"""Owns the bounded per-machine ring buffers; refreshed by the daemon tick.
|
|
154
|
+
|
|
155
|
+
``series()``/``all_series()`` are the read path — bare in-memory reads,
|
|
156
|
+
no I/O, safe to call from a request handler. ``refresh(config)`` is the
|
|
157
|
+
only method that talks to agents and must only ever run from the
|
|
158
|
+
daemon's tick machinery (or a test driving it explicitly), exactly the
|
|
159
|
+
same split as ``coord.health.fleet_snapshot.FleetHealthRefresher``.
|
|
160
|
+
"""
|
|
161
|
+
|
|
162
|
+
def __init__(
|
|
163
|
+
self,
|
|
164
|
+
*,
|
|
165
|
+
max_samples: int = DEFAULT_MAX_SAMPLES,
|
|
166
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
167
|
+
) -> None:
|
|
168
|
+
if max_samples <= 0:
|
|
169
|
+
raise ValueError("max_samples must be positive")
|
|
170
|
+
self._max_samples = max_samples
|
|
171
|
+
self._timeout = timeout
|
|
172
|
+
self._buffers: dict[str, deque] = {}
|
|
173
|
+
|
|
174
|
+
def series(self, machine_name: str) -> list[dict]:
|
|
175
|
+
"""This machine's samples, oldest first. ``[]`` if never polled."""
|
|
176
|
+
buf = self._buffers.get(machine_name)
|
|
177
|
+
return [s.to_dict() for s in buf] if buf else []
|
|
178
|
+
|
|
179
|
+
def all_series(self) -> dict[str, list[dict]]:
|
|
180
|
+
"""Every known machine's series, oldest-first, keyed by name."""
|
|
181
|
+
return {name: [s.to_dict() for s in buf] for name, buf in self._buffers.items()}
|
|
182
|
+
|
|
183
|
+
def refresh(self, config) -> None: # noqa: ANN001 — coord.config.Config
|
|
184
|
+
"""Poll every machine in ``config.machines`` once and append a sample
|
|
185
|
+
each — :data:`STATUS_OK` or :data:`STATUS_UNKNOWN`, never neither.
|
|
186
|
+
|
|
187
|
+
Polls concurrently (mirrors ``coord.network.check_all``): each call
|
|
188
|
+
is individually bounded by ``timeout``, and running them in a thread
|
|
189
|
+
pool rather than sequentially means one slow/hanging agent's bounded
|
|
190
|
+
stall does not serialize in front of the rest of the fleet's polls —
|
|
191
|
+
the whole ``refresh()`` call takes roughly one ``timeout``, not
|
|
192
|
+
``len(machines) * timeout``, no matter which machine is the slow
|
|
193
|
+
one. Called off the event loop (``run_in_threadpool`` from
|
|
194
|
+
``coord.serve_app``'s own loop, isolated from ``_tick_loop``) so
|
|
195
|
+
even that bound never touches board-read latency.
|
|
196
|
+
"""
|
|
197
|
+
machines = list(getattr(config, "machines", ()) or ())
|
|
198
|
+
if not machines:
|
|
199
|
+
return
|
|
200
|
+
|
|
201
|
+
workers = min(8, len(machines))
|
|
202
|
+
with ThreadPoolExecutor(max_workers=workers) as pool:
|
|
203
|
+
samples = list(pool.map(self._poll_one, machines))
|
|
204
|
+
|
|
205
|
+
for machine, sample in zip(machines, samples):
|
|
206
|
+
buf = self._buffers.get(machine.name)
|
|
207
|
+
if buf is None:
|
|
208
|
+
buf = deque(maxlen=self._max_samples)
|
|
209
|
+
self._buffers[machine.name] = buf
|
|
210
|
+
buf.append(sample)
|
|
211
|
+
|
|
212
|
+
def _poll_one(self, machine) -> MetricsSample: # noqa: ANN001
|
|
213
|
+
try:
|
|
214
|
+
return fetch_metrics(machine, timeout=self._timeout)
|
|
215
|
+
except Exception as exc: # noqa: BLE001 — one bad machine must never abort the refresh
|
|
216
|
+
log.warning("machine-metrics poll: %s raised %s", machine.name, exc)
|
|
217
|
+
return _unknown(f"poll raised: {exc}")
|
coord/serve_app.py
CHANGED
|
@@ -5340,6 +5340,15 @@ def build_app(store: CoordStore, config: Config, *, token: str | None = None) ->
|
|
|
5340
5340
|
|
|
5341
5341
|
_fleet_health_refresher = FleetHealthRefresher()
|
|
5342
5342
|
|
|
5343
|
+
# #3020: same shape again — polling every agent's own /metrics is real
|
|
5344
|
+
# per-machine I/O, so it runs on the tick loop's cadence
|
|
5345
|
+
# (`_machine_metrics_loop` below), never inline off a request handler.
|
|
5346
|
+
# Series live only in this sampler's bounded in-memory ring buffers; no
|
|
5347
|
+
# read endpoint exists yet (that's #A2) and nothing here touches /board.
|
|
5348
|
+
from coord.machine_metrics import MachineMetricsSampler # noqa: PLC0415
|
|
5349
|
+
|
|
5350
|
+
_machine_metrics_sampler = MachineMetricsSampler()
|
|
5351
|
+
|
|
5343
5352
|
# Short-TTL cache for the computed /board projection so burst polls from the
|
|
5344
5353
|
# TUI don't each pay the full board_projection + merge-plan + stage-projection
|
|
5345
5354
|
# recomputation (~465-issue load measured in the issue). Keyed to nothing
|
|
@@ -9452,6 +9461,26 @@ def build_app(store: CoordStore, config: Config, *, token: str | None = None) ->
|
|
|
9452
9461
|
except Exception: # noqa: BLE001 — keep serving the old snapshot
|
|
9453
9462
|
log.warning("fleet-health refresh failed", exc_info=True)
|
|
9454
9463
|
|
|
9464
|
+
# #3020: CPU/mem sampler for the coord-web Machines panel — default
|
|
9465
|
+
# 15s (env COORD_METRICS_POLL_INTERVAL; 0 disables). Own loop, not a
|
|
9466
|
+
# `_tick_loop` step, for the identical reason as `_health_refresh_loop`
|
|
9467
|
+
# just above: it fans out one HTTP GET per agent and must never be
|
|
9468
|
+
# held up behind, or hold up, the reconcile/enqueue/drain steps.
|
|
9469
|
+
try:
|
|
9470
|
+
metrics_poll_interval = float(
|
|
9471
|
+
os.environ.get("COORD_METRICS_POLL_INTERVAL", "15")
|
|
9472
|
+
)
|
|
9473
|
+
except ValueError:
|
|
9474
|
+
metrics_poll_interval = 15.0
|
|
9475
|
+
|
|
9476
|
+
async def _machine_metrics_loop() -> None:
|
|
9477
|
+
while True:
|
|
9478
|
+
await asyncio.sleep(metrics_poll_interval)
|
|
9479
|
+
try:
|
|
9480
|
+
await run_in_threadpool(_machine_metrics_sampler.refresh, config)
|
|
9481
|
+
except Exception: # noqa: BLE001 — a tick must never crash the daemon
|
|
9482
|
+
log.warning("machine-metrics refresh failed", exc_info=True)
|
|
9483
|
+
|
|
9455
9484
|
# #2570: the #2536 phantom-row auto-heal, run a SECOND time from
|
|
9456
9485
|
# inside this long-lived process rather than relying solely on
|
|
9457
9486
|
# `coord-notify.timer`'s oneshot `~/.coord-venv/bin/coord notify`
|
|
@@ -10218,12 +10247,18 @@ def build_app(store: CoordStore, config: Config, *, token: str | None = None) ->
|
|
|
10218
10247
|
else None,
|
|
10219
10248
|
"auto-revalidate",
|
|
10220
10249
|
)
|
|
10250
|
+
machine_metrics_task = _watch(
|
|
10251
|
+
asyncio.create_task(_machine_metrics_loop())
|
|
10252
|
+
if interval > 0 and metrics_poll_interval > 0
|
|
10253
|
+
else None,
|
|
10254
|
+
"machine-metrics",
|
|
10255
|
+
)
|
|
10221
10256
|
try:
|
|
10222
10257
|
yield
|
|
10223
10258
|
finally:
|
|
10224
10259
|
for t in (
|
|
10225
10260
|
task, gate_task, health_task, phantom_heal_task,
|
|
10226
|
-
auto_revalidate_task,
|
|
10261
|
+
auto_revalidate_task, machine_metrics_task,
|
|
10227
10262
|
):
|
|
10228
10263
|
if t is not None:
|
|
10229
10264
|
t.cancel()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|