devicectl-core 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.
- devicectl/__init__.py +18 -0
- devicectl/cli/__init__.py +1 -0
- devicectl/cli/command.py +95 -0
- devicectl/cli/exits.py +32 -0
- devicectl/cli/fanout.py +142 -0
- devicectl/cli/main.py +69 -0
- devicectl/cli/output.py +299 -0
- devicectl/cli/parser.py +80 -0
- devicectl/cli/report.py +86 -0
- devicectl/cli/target.py +26 -0
- devicectl/clock.py +57 -0
- devicectl/devtools/__init__.py +6 -0
- devicectl/devtools/frontlint.py +935 -0
- devicectl/devtools/htmcheck.py +396 -0
- devicectl/devtools/rendercheck.py +384 -0
- devicectl/doctor.py +112 -0
- devicectl/errors.py +68 -0
- devicectl/fields.py +564 -0
- devicectl/meta.py +64 -0
- devicectl/paths.py +40 -0
- devicectl/progress.py +77 -0
- devicectl/report.py +67 -0
- devicectl/testing.py +199 -0
- devicectl/trace.py +333 -0
- devicectl/web/__init__.py +1 -0
- devicectl/web/agents.py +94 -0
- devicectl/web/events.py +171 -0
- devicectl/web/http.py +243 -0
- devicectl/web/progress.py +101 -0
- devicectl/web/server.py +1013 -0
- devicectl/web/static/core.css +3034 -0
- devicectl/web/static/js/api.js +198 -0
- devicectl/web/static/js/band.js +640 -0
- devicectl/web/static/js/chart.js +400 -0
- devicectl/web/static/js/drafts.js +312 -0
- devicectl/web/static/js/notify.js +272 -0
- devicectl/web/static/js/panels.js +432 -0
- devicectl/web/static/js/shell.js +672 -0
- devicectl/web/static/js/trace.js +133 -0
- devicectl/web/static/js/ui.js +1139 -0
- devicectl/web/static/vendor/preact-htm.module.js +27 -0
- devicectl/web/worker.py +697 -0
- devicectl_core-0.1.0.dist-info/METADATA +131 -0
- devicectl_core-0.1.0.dist-info/RECORD +47 -0
- devicectl_core-0.1.0.dist-info/WHEEL +4 -0
- devicectl_core-0.1.0.dist-info/licenses/LICENSE +287 -0
- devicectl_core-0.1.0.dist-info/licenses/NOTICE +13 -0
devicectl/web/worker.py
ADDED
|
@@ -0,0 +1,697 @@
|
|
|
1
|
+
"""One thread owning the one connection, and everything queued behind it.
|
|
2
|
+
|
|
3
|
+
A device of this kind takes one conversation at a time -- a charger allows
|
|
4
|
+
a single session, a serial bus is a single wire -- so the web server cannot
|
|
5
|
+
let request threads talk to it directly. Instead every request hands a
|
|
6
|
+
callable to the worker, which owns the link on its own thread and runs them
|
|
7
|
+
one after another. A handler is then ordinary blocking code: it submits,
|
|
8
|
+
waits, and gets back what the callable returned or whatever it raised.
|
|
9
|
+
|
|
10
|
+
Three things fall out of that and are all here:
|
|
11
|
+
|
|
12
|
+
* **Jobs.** Work measured in minutes rather than milliseconds cannot hold a
|
|
13
|
+
request thread open, so it is queued as a :class:`Job` and followed on the
|
|
14
|
+
event stream instead.
|
|
15
|
+
* **A published state.** A page has to be able to say what the link is
|
|
16
|
+
doing and who else is waiting, so every transition is broadcast.
|
|
17
|
+
* **Idleness.** A device nobody is asking about should not be held. The
|
|
18
|
+
worker closes the link after a quiet spell, and a live refresh -- when the
|
|
19
|
+
page has asked for one -- is just a task it queues for itself.
|
|
20
|
+
|
|
21
|
+
What a link *is*, how it opens and what a task is handed are the program's
|
|
22
|
+
own: :class:`Worker` is generic over the link and calls back for those. Retry
|
|
23
|
+
deliberately is not here. One program retries at the authentication layer
|
|
24
|
+
(a 401 means log in again) and another at the protocol layer (a framing
|
|
25
|
+
fault is not a Modbus exception), and an abstraction over the two would
|
|
26
|
+
describe neither.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import itertools
|
|
32
|
+
import queue
|
|
33
|
+
import threading
|
|
34
|
+
import time
|
|
35
|
+
from collections import deque
|
|
36
|
+
from dataclasses import dataclass, field
|
|
37
|
+
from typing import Any, Callable, Generic, TypeVar
|
|
38
|
+
|
|
39
|
+
from devicectl.errors import DeviceError, describe
|
|
40
|
+
from devicectl.web.events import Broadcaster
|
|
41
|
+
|
|
42
|
+
LinkT = TypeVar("LinkT")
|
|
43
|
+
|
|
44
|
+
# How often the live refresh reads, and the range a page may ask for.
|
|
45
|
+
DEFAULT_POLL_INTERVAL_S = 3.0
|
|
46
|
+
MIN_POLL_INTERVAL_S = 1.0
|
|
47
|
+
MAX_POLL_INTERVAL_S = 60.0
|
|
48
|
+
|
|
49
|
+
# How long the link is held after the last thing anybody asked for. Long
|
|
50
|
+
# enough that clicking around a page does not reconnect between clicks,
|
|
51
|
+
# short enough that a forgotten tab gives the device back.
|
|
52
|
+
DEFAULT_IDLE_TIMEOUT_S = 45.0
|
|
53
|
+
|
|
54
|
+
# How long a request thread waits for its turn before giving up on it.
|
|
55
|
+
DEFAULT_TASK_TIMEOUT_S = 180.0
|
|
56
|
+
|
|
57
|
+
# How often the loop looks up when there is nothing queued.
|
|
58
|
+
LOOP_TICK_S = 0.25
|
|
59
|
+
|
|
60
|
+
# How many finished operations the activity ticker remembers.
|
|
61
|
+
ACTIVITY_HISTORY = 12
|
|
62
|
+
|
|
63
|
+
# How long a finished job stays answerable, for a page that was not looking.
|
|
64
|
+
JOB_RETENTION_S = 900.0
|
|
65
|
+
|
|
66
|
+
# The fastest a job's progress is published: more often than this is more
|
|
67
|
+
# often than a browser can usefully redraw.
|
|
68
|
+
JOB_NOTIFY_INTERVAL_S = 0.25
|
|
69
|
+
|
|
70
|
+
# How long `stop` waits for the thread to notice before leaving it to the
|
|
71
|
+
# interpreter. It is a daemon thread with nothing to write down.
|
|
72
|
+
STOP_JOIN_S = 0.1
|
|
73
|
+
|
|
74
|
+
_job_ids = itertools.count(1)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass
|
|
78
|
+
class Job:
|
|
79
|
+
"""A long-running operation the UI follows rather than waits for."""
|
|
80
|
+
|
|
81
|
+
id: int
|
|
82
|
+
name: str
|
|
83
|
+
state: str = "queued" # queued | running | done | failed
|
|
84
|
+
progress: float | None = None
|
|
85
|
+
message: str = ""
|
|
86
|
+
error: str = ""
|
|
87
|
+
started: float = 0.0
|
|
88
|
+
finished: float = 0.0
|
|
89
|
+
result: dict[str, Any] = field(default_factory=dict)
|
|
90
|
+
"""What the job produced, for the page to read once it is done.
|
|
91
|
+
|
|
92
|
+
An object rather than a bare value, because the page reads fields off
|
|
93
|
+
it and a job usually has more than one thing to say. A callable either
|
|
94
|
+
returns it or fills it in as it goes.
|
|
95
|
+
"""
|
|
96
|
+
notify: Callable[["Job"], None] | None = field(default=None, repr=False)
|
|
97
|
+
_last_notify: float = field(default=0.0, repr=False)
|
|
98
|
+
|
|
99
|
+
def report(
|
|
100
|
+
self,
|
|
101
|
+
progress: float | None = None,
|
|
102
|
+
message: str | None = None,
|
|
103
|
+
*,
|
|
104
|
+
force: bool = False,
|
|
105
|
+
) -> None:
|
|
106
|
+
"""Update the job's progress and tell the browsers, at most 4x a second.
|
|
107
|
+
|
|
108
|
+
``force`` is for a new phase, which is worth an immediate event. A
|
|
109
|
+
changed message is not enough on its own: a transfer that counts what
|
|
110
|
+
it has sent changes its message every block, and forcing on that
|
|
111
|
+
would publish an event per block and defeat the rate limit entirely.
|
|
112
|
+
A throttled message is not lost -- it goes out with the next event.
|
|
113
|
+
"""
|
|
114
|
+
if progress is not None:
|
|
115
|
+
self.progress = max(0.0, min(1.0, float(progress)))
|
|
116
|
+
if message is not None:
|
|
117
|
+
self.message = message
|
|
118
|
+
now = time.monotonic()
|
|
119
|
+
if not force and now - self._last_notify < JOB_NOTIFY_INTERVAL_S:
|
|
120
|
+
return
|
|
121
|
+
self._last_notify = now
|
|
122
|
+
if self.notify is not None:
|
|
123
|
+
self.notify(self)
|
|
124
|
+
|
|
125
|
+
def as_dict(self) -> dict[str, Any]:
|
|
126
|
+
"""Render the job for the UI."""
|
|
127
|
+
return {
|
|
128
|
+
"id": self.id,
|
|
129
|
+
"name": self.name,
|
|
130
|
+
"state": self.state,
|
|
131
|
+
"progress": self.progress,
|
|
132
|
+
"message": self.message,
|
|
133
|
+
"error": self.error,
|
|
134
|
+
"elapsed": (self.finished or time.time()) - self.started
|
|
135
|
+
if self.started
|
|
136
|
+
else 0.0,
|
|
137
|
+
} | ({"result": self.result} if self.result else {})
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class Task:
|
|
141
|
+
"""One unit of work, queued for the worker thread."""
|
|
142
|
+
|
|
143
|
+
def __init__(
|
|
144
|
+
self,
|
|
145
|
+
name: str,
|
|
146
|
+
fn: Callable[..., Any],
|
|
147
|
+
*,
|
|
148
|
+
job: Job | None = None,
|
|
149
|
+
quiet: bool = False,
|
|
150
|
+
exclusive: bool = False,
|
|
151
|
+
) -> None:
|
|
152
|
+
"""Prepare a task; ``quiet`` keeps polls out of the activity ticker."""
|
|
153
|
+
self.name = name
|
|
154
|
+
self.fn = fn
|
|
155
|
+
self.job = job
|
|
156
|
+
self.quiet = quiet
|
|
157
|
+
self.exclusive = exclusive
|
|
158
|
+
self.done = threading.Event()
|
|
159
|
+
self.result: Any = None
|
|
160
|
+
self.error: BaseException | None = None
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class Worker(Generic[LinkT]):
|
|
164
|
+
"""Owns the one link to the device and runs every request against it.
|
|
165
|
+
|
|
166
|
+
Subclass it and answer four questions: how a link is opened
|
|
167
|
+
(:meth:`_open_link`), how it is closed (:meth:`_close_link`), whether
|
|
168
|
+
there is anything to open one to (:meth:`_check_ready`), and what a task
|
|
169
|
+
is handed when it runs (:meth:`_invoke`, which by default is the link
|
|
170
|
+
itself). :meth:`_details` adds whatever else the page needs to draw the
|
|
171
|
+
header.
|
|
172
|
+
"""
|
|
173
|
+
|
|
174
|
+
# The published names for the states the base moves through. They are
|
|
175
|
+
# class attributes because a program's page and stylesheet are written
|
|
176
|
+
# against its own words for them.
|
|
177
|
+
RELEASED = "released"
|
|
178
|
+
OPENING = "opening"
|
|
179
|
+
IDLE = "idle"
|
|
180
|
+
BUSY = "busy"
|
|
181
|
+
ERROR = "error"
|
|
182
|
+
|
|
183
|
+
thread_name = "device-worker"
|
|
184
|
+
task_timeout_s = DEFAULT_TASK_TIMEOUT_S
|
|
185
|
+
|
|
186
|
+
# What the page calls the live refresh in the link pill. A program's own
|
|
187
|
+
# word for it: the frontend checks these strings against the panels that
|
|
188
|
+
# wait on them, so it is not free to change.
|
|
189
|
+
poll_task_name = "Refreshing"
|
|
190
|
+
|
|
191
|
+
def __init__(
|
|
192
|
+
self,
|
|
193
|
+
events: Broadcaster,
|
|
194
|
+
*,
|
|
195
|
+
poll_interval: float = DEFAULT_POLL_INTERVAL_S,
|
|
196
|
+
idle_timeout: float = DEFAULT_IDLE_TIMEOUT_S,
|
|
197
|
+
) -> None:
|
|
198
|
+
"""Set up the worker; it opens nothing until it has work."""
|
|
199
|
+
self.events = events
|
|
200
|
+
self.poll_interval = poll_interval
|
|
201
|
+
self.idle_timeout = idle_timeout
|
|
202
|
+
self._link: LinkT | None = None
|
|
203
|
+
self._queue: queue.Queue[Task | None] = queue.Queue()
|
|
204
|
+
self._lock = threading.Lock()
|
|
205
|
+
self._thread: threading.Thread | None = None
|
|
206
|
+
self._stopping = threading.Event()
|
|
207
|
+
self._live = False
|
|
208
|
+
self._exclusive = False
|
|
209
|
+
self._release_requested = False
|
|
210
|
+
self._last_used = time.monotonic()
|
|
211
|
+
self._last_poll = 0.0
|
|
212
|
+
self._last_tick = 0
|
|
213
|
+
self._state = self.RELEASED
|
|
214
|
+
self._op = ""
|
|
215
|
+
self._op_quiet = False
|
|
216
|
+
self._op_progress: float | None = None
|
|
217
|
+
self._op_note = ""
|
|
218
|
+
self._last_progress = 0.0
|
|
219
|
+
self._error = ""
|
|
220
|
+
self._since = time.time()
|
|
221
|
+
self._activity: deque[dict[str, Any]] = deque(maxlen=ACTIVITY_HISTORY)
|
|
222
|
+
self._jobs: dict[int, Job] = {}
|
|
223
|
+
self._poll_fn: Callable[..., Any] | None = None
|
|
224
|
+
|
|
225
|
+
# --- what a program answers ---------------------------------------------------
|
|
226
|
+
|
|
227
|
+
def _open_link(self) -> LinkT:
|
|
228
|
+
"""Open the link to the device and return it."""
|
|
229
|
+
raise NotImplementedError
|
|
230
|
+
|
|
231
|
+
def _close_link(self, link: LinkT) -> None:
|
|
232
|
+
"""Hand the link back. Must not raise."""
|
|
233
|
+
raise NotImplementedError
|
|
234
|
+
|
|
235
|
+
def _check_ready(self) -> None:
|
|
236
|
+
"""Raise the program's own error if there is nothing to talk to yet."""
|
|
237
|
+
|
|
238
|
+
def _invoke(self, task: Task, link: LinkT) -> Any:
|
|
239
|
+
"""Run one task's callable. By default it is handed the link."""
|
|
240
|
+
if task.job is not None:
|
|
241
|
+
return task.fn(link, task.job)
|
|
242
|
+
return task.fn(link)
|
|
243
|
+
|
|
244
|
+
def _details(self) -> dict[str, Any]:
|
|
245
|
+
"""Whatever else the page needs, on top of the shared link state."""
|
|
246
|
+
return {}
|
|
247
|
+
|
|
248
|
+
def _busy_state(self, task: Task) -> str:
|
|
249
|
+
"""Return the state to publish while this task runs.
|
|
250
|
+
|
|
251
|
+
Almost always :attr:`BUSY`. A program that lets a task own the link
|
|
252
|
+
outright can say so instead, which is a different thing for a page to
|
|
253
|
+
draw: nothing else will get a turn until it finishes.
|
|
254
|
+
"""
|
|
255
|
+
return self.BUSY
|
|
256
|
+
|
|
257
|
+
def _after_failure(self, exc: BaseException) -> None:
|
|
258
|
+
"""React to a task that raised, before the link state is republished."""
|
|
259
|
+
|
|
260
|
+
# --- lifecycle ----------------------------------------------------------------
|
|
261
|
+
|
|
262
|
+
def start(self) -> None:
|
|
263
|
+
"""Start the worker thread."""
|
|
264
|
+
self._thread = threading.Thread(
|
|
265
|
+
target=self._loop, name=self.thread_name, daemon=True
|
|
266
|
+
)
|
|
267
|
+
self._thread.start()
|
|
268
|
+
self._publish_link()
|
|
269
|
+
|
|
270
|
+
def stop(self, timeout: float | None = None) -> None:
|
|
271
|
+
"""Stop the worker and drop the link.
|
|
272
|
+
|
|
273
|
+
Returning quickly matters more than unwinding tidily: this is what
|
|
274
|
+
Ctrl+C waits for, and the worker can be in the middle of a round
|
|
275
|
+
trip to a device on the far end of a slow link. Closing the link
|
|
276
|
+
from here does not interrupt a blocked read -- a socket closed under
|
|
277
|
+
a thread parked in ``recv`` on Linux stays parked -- so the thread is
|
|
278
|
+
asked to stop, given ``timeout`` to notice (:data:`STOP_JOIN_S` when
|
|
279
|
+
none is named), and then left to the interpreter, which is entitled
|
|
280
|
+
to do that because it is a daemon thread with nothing to write down.
|
|
281
|
+
|
|
282
|
+
``timeout=0`` does not wait at all, which is what a teardown running
|
|
283
|
+
off a signal handler wants: blocking there is how a process hangs on
|
|
284
|
+
Ctrl+C instead of stopping on it.
|
|
285
|
+
"""
|
|
286
|
+
wait = STOP_JOIN_S if timeout is None else timeout
|
|
287
|
+
self._stopping.set()
|
|
288
|
+
self._queue.put(None)
|
|
289
|
+
thread = self._thread
|
|
290
|
+
if thread is not None and wait > 0:
|
|
291
|
+
thread.join(timeout=wait)
|
|
292
|
+
|
|
293
|
+
@property
|
|
294
|
+
def stopping(self) -> bool:
|
|
295
|
+
"""Whether the worker has been told to stand down."""
|
|
296
|
+
return self._stopping.is_set()
|
|
297
|
+
|
|
298
|
+
@property
|
|
299
|
+
def live(self) -> bool:
|
|
300
|
+
"""Whether the live refresh is running."""
|
|
301
|
+
return self._live
|
|
302
|
+
|
|
303
|
+
def set_poll(
|
|
304
|
+
self, *, live: bool | None = None, interval: float | None = None
|
|
305
|
+
) -> None:
|
|
306
|
+
"""Turn the live refresh on or off, and set its interval."""
|
|
307
|
+
if interval is not None:
|
|
308
|
+
self.poll_interval = max(
|
|
309
|
+
MIN_POLL_INTERVAL_S, min(MAX_POLL_INTERVAL_S, float(interval))
|
|
310
|
+
)
|
|
311
|
+
if live is not None:
|
|
312
|
+
self._live = bool(live)
|
|
313
|
+
if live:
|
|
314
|
+
self._last_poll = 0.0 # refresh at once rather than on the beat
|
|
315
|
+
self._publish_link()
|
|
316
|
+
|
|
317
|
+
def set_poll_fn(self, fn: Callable[..., Any] | None) -> None:
|
|
318
|
+
"""Install what a live refresh actually reads (set by the API layer).
|
|
319
|
+
|
|
320
|
+
It is run as an ordinary task, so it is handed whatever
|
|
321
|
+
:meth:`_invoke` hands one.
|
|
322
|
+
"""
|
|
323
|
+
self._poll_fn = fn
|
|
324
|
+
|
|
325
|
+
def release(self) -> None:
|
|
326
|
+
"""Ask the worker to hand the link back as soon as it is free."""
|
|
327
|
+
self._release_requested = True
|
|
328
|
+
|
|
329
|
+
# --- submitting work ----------------------------------------------------------
|
|
330
|
+
|
|
331
|
+
def run(
|
|
332
|
+
self,
|
|
333
|
+
name: str,
|
|
334
|
+
fn: Callable[..., Any],
|
|
335
|
+
*,
|
|
336
|
+
timeout: float | None = None,
|
|
337
|
+
quiet: bool = False,
|
|
338
|
+
) -> Any:
|
|
339
|
+
"""Run ``fn`` against the device and return its result.
|
|
340
|
+
|
|
341
|
+
Blocks the calling (request) thread until the worker gets to it.
|
|
342
|
+
Raises whatever ``fn`` raised, or :class:`~devicectl.errors.DeviceError`
|
|
343
|
+
via :meth:`_busy_error` if the queue did not reach it in time.
|
|
344
|
+
"""
|
|
345
|
+
self._check_ready()
|
|
346
|
+
wait = self.task_timeout_s if timeout is None else timeout
|
|
347
|
+
task = Task(name, fn, quiet=quiet)
|
|
348
|
+
self._queue.put(task)
|
|
349
|
+
if not task.done.wait(wait):
|
|
350
|
+
raise self._busy_error(name, wait)
|
|
351
|
+
if task.error is not None:
|
|
352
|
+
raise task.error
|
|
353
|
+
return task.result
|
|
354
|
+
|
|
355
|
+
def run_soon(self, fn: Callable[..., Any], *, name: str = "") -> None:
|
|
356
|
+
"""Queue work and do not wait for it.
|
|
357
|
+
|
|
358
|
+
Unnamed by default, so it stays out of the activity ticker: this is
|
|
359
|
+
for housekeeping the user did not ask for by name.
|
|
360
|
+
"""
|
|
361
|
+
self._queue.put(Task(name, fn, quiet=True))
|
|
362
|
+
|
|
363
|
+
def start_job(
|
|
364
|
+
self, name: str, fn: Callable[..., Any], *, exclusive: bool = False
|
|
365
|
+
) -> Job:
|
|
366
|
+
"""Queue a long operation and return its :class:`Job` immediately.
|
|
367
|
+
|
|
368
|
+
``exclusive`` is for work that owns the link outright -- a firmware
|
|
369
|
+
transfer, after which the device is not speaking its usual protocol
|
|
370
|
+
any more, so a live refresh in the gap would put the wrong bytes on
|
|
371
|
+
the wire. The poll timer and the idle timer both stand down for it.
|
|
372
|
+
"""
|
|
373
|
+
self._check_ready()
|
|
374
|
+
job = Job(id=next(_job_ids), name=name, notify=self._on_job_progress)
|
|
375
|
+
with self._lock:
|
|
376
|
+
self._jobs[job.id] = job
|
|
377
|
+
self._forget_old_jobs()
|
|
378
|
+
self._publish_job(job)
|
|
379
|
+
if exclusive:
|
|
380
|
+
with self._lock:
|
|
381
|
+
self._exclusive = True
|
|
382
|
+
self._queue.put(Task(name, fn, job=job, exclusive=exclusive))
|
|
383
|
+
return job
|
|
384
|
+
|
|
385
|
+
def discard_pending(self, why: str) -> int:
|
|
386
|
+
"""Drop everything queued but not started, and tell whoever waits on it.
|
|
387
|
+
|
|
388
|
+
Pointing the worker at a different device -- or giving up on the one
|
|
389
|
+
it was pointed at -- leaves a queue full of work meant for the old
|
|
390
|
+
one. Running it against the new one would be wrong, and leaving it
|
|
391
|
+
to time out makes the first two minutes on the new device two
|
|
392
|
+
minutes of failures arriving from the last. Dropped work fails at
|
|
393
|
+
once, with ``why`` as its reason.
|
|
394
|
+
|
|
395
|
+
The task already running is not touched: it holds the link, and the
|
|
396
|
+
worker has no way to interrupt a thread parked on a read. Returns
|
|
397
|
+
how many were dropped.
|
|
398
|
+
"""
|
|
399
|
+
dropped = 0
|
|
400
|
+
while True:
|
|
401
|
+
try:
|
|
402
|
+
task = self._queue.get_nowait()
|
|
403
|
+
except queue.Empty:
|
|
404
|
+
break
|
|
405
|
+
if task is None: # the stop sentinel; leave it for the loop
|
|
406
|
+
self._queue.put(None)
|
|
407
|
+
break
|
|
408
|
+
task.error = DeviceError(f"{task.name or 'the request'}: {why}")
|
|
409
|
+
if task.exclusive:
|
|
410
|
+
with self._lock:
|
|
411
|
+
self._exclusive = False
|
|
412
|
+
self._finish_job(task.job, failure=task.error)
|
|
413
|
+
task.done.set()
|
|
414
|
+
dropped += 1
|
|
415
|
+
if dropped:
|
|
416
|
+
self._publish_link()
|
|
417
|
+
return dropped
|
|
418
|
+
|
|
419
|
+
def _busy_error(self, name: str, timeout: float) -> BaseException:
|
|
420
|
+
"""Return the error a caller gets when it never got its turn."""
|
|
421
|
+
return TimeoutError(
|
|
422
|
+
f"the device is busy ({self._op or 'another operation'}); "
|
|
423
|
+
f"'{name}' did not get its turn within {timeout:.0f}s"
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
def job(self, job_id: int) -> Job | None:
|
|
427
|
+
"""Look up one job by id."""
|
|
428
|
+
with self._lock:
|
|
429
|
+
return self._jobs.get(job_id)
|
|
430
|
+
|
|
431
|
+
def jobs(self) -> list[dict[str, Any]]:
|
|
432
|
+
"""Every job the worker still remembers, oldest first."""
|
|
433
|
+
with self._lock:
|
|
434
|
+
return [job.as_dict() for job in sorted(self._jobs.values(), key=_job_key)]
|
|
435
|
+
|
|
436
|
+
def _forget_old_jobs(self) -> None:
|
|
437
|
+
"""Drop finished jobs nobody is going to ask about any more."""
|
|
438
|
+
cutoff = time.time() - JOB_RETENTION_S
|
|
439
|
+
for job_id, job in list(self._jobs.items()):
|
|
440
|
+
if job.finished and job.finished < cutoff:
|
|
441
|
+
del self._jobs[job_id]
|
|
442
|
+
|
|
443
|
+
# --- state reporting ----------------------------------------------------------
|
|
444
|
+
|
|
445
|
+
def link_state(self) -> dict[str, Any]:
|
|
446
|
+
"""Build the link snapshot published to every browser."""
|
|
447
|
+
with self._lock:
|
|
448
|
+
countdown = None
|
|
449
|
+
if self._link is not None and not self._live:
|
|
450
|
+
left = self.idle_timeout - (time.monotonic() - self._last_used)
|
|
451
|
+
countdown = max(0.0, round(left, 1))
|
|
452
|
+
state = {
|
|
453
|
+
"state": self._state,
|
|
454
|
+
"op": self._op,
|
|
455
|
+
# Whether the link is busy with something nobody asked for.
|
|
456
|
+
# The live refresh runs every few seconds and holds it for as
|
|
457
|
+
# long as it takes; a page that treated that the same as a
|
|
458
|
+
# firmware upload would blink every control on it on the beat.
|
|
459
|
+
"quiet": self._op_quiet,
|
|
460
|
+
"progress": self._op_progress,
|
|
461
|
+
# What the task running right now has got through, in its own
|
|
462
|
+
# words -- "412 records, page 5". Some long reads have no
|
|
463
|
+
# honest denominator to make a percentage out of, and a made-up
|
|
464
|
+
# one is worse than a count that is true.
|
|
465
|
+
"note": self._op_note,
|
|
466
|
+
"queued": max(0, self._queue.qsize()),
|
|
467
|
+
"since": self._since,
|
|
468
|
+
"error": self._error,
|
|
469
|
+
"live": self._live,
|
|
470
|
+
"pollInterval": self.poll_interval,
|
|
471
|
+
"releaseIn": countdown,
|
|
472
|
+
"idleTimeout": self.idle_timeout,
|
|
473
|
+
"activity": list(self._activity),
|
|
474
|
+
"clients": self.events.subscriber_count,
|
|
475
|
+
}
|
|
476
|
+
return state | self._details()
|
|
477
|
+
|
|
478
|
+
def _publish_link(self) -> None:
|
|
479
|
+
"""Tell every browser what the link is doing."""
|
|
480
|
+
self.events.publish("link", self.link_state(), sticky=True)
|
|
481
|
+
|
|
482
|
+
def _on_job_progress(self, job: Job) -> None:
|
|
483
|
+
"""Mirror a running job's progress onto the link, and publish both."""
|
|
484
|
+
with self._lock:
|
|
485
|
+
self._op_progress = job.progress
|
|
486
|
+
self._publish_job(job)
|
|
487
|
+
self._publish_link()
|
|
488
|
+
|
|
489
|
+
def _publish_job(self, job: Job) -> None:
|
|
490
|
+
"""Tell every browser about one job's progress."""
|
|
491
|
+
self.events.publish("job", job.as_dict())
|
|
492
|
+
|
|
493
|
+
def _set_state(
|
|
494
|
+
self,
|
|
495
|
+
state: str,
|
|
496
|
+
op: str = "",
|
|
497
|
+
*,
|
|
498
|
+
error: str = "",
|
|
499
|
+
progress: float | None = None,
|
|
500
|
+
quiet: bool = False,
|
|
501
|
+
) -> None:
|
|
502
|
+
"""Move the link to a new state and publish it."""
|
|
503
|
+
with self._lock:
|
|
504
|
+
self._state = state
|
|
505
|
+
self._op = op
|
|
506
|
+
self._op_quiet = quiet
|
|
507
|
+
self._op_progress = progress
|
|
508
|
+
self._op_note = ""
|
|
509
|
+
self._error = error
|
|
510
|
+
self._since = time.time()
|
|
511
|
+
self._publish_link()
|
|
512
|
+
|
|
513
|
+
def progress(self, fraction: float | None = None, note: str = "") -> None:
|
|
514
|
+
"""Say how the task running right now is getting on.
|
|
515
|
+
|
|
516
|
+
The counterpart to :meth:`Job.report` for work that is *not* a job:
|
|
517
|
+
a read the browser is waiting on, which holds the link for seconds
|
|
518
|
+
while it pages a device. Without this the page has only the pill's
|
|
519
|
+
"busy", which is the same thing it says for a write that lands in
|
|
520
|
+
150 ms -- so a long walk is indistinguishable from a hang.
|
|
521
|
+
|
|
522
|
+
``fraction`` is ``None`` where there is no honest denominator, and
|
|
523
|
+
``note`` then carries what there *is* to say. Called from the worker
|
|
524
|
+
thread, from inside the task it describes, and throttled.
|
|
525
|
+
"""
|
|
526
|
+
now = time.monotonic()
|
|
527
|
+
if now - self._last_progress < JOB_NOTIFY_INTERVAL_S:
|
|
528
|
+
return
|
|
529
|
+
self._last_progress = now
|
|
530
|
+
with self._lock:
|
|
531
|
+
self._op_progress = fraction
|
|
532
|
+
self._op_note = note
|
|
533
|
+
self._publish_link()
|
|
534
|
+
|
|
535
|
+
def _note_activity(self, name: str, seconds: float, ok: bool, detail: str) -> None:
|
|
536
|
+
"""Add one finished operation to the ticker."""
|
|
537
|
+
with self._lock:
|
|
538
|
+
self._activity.appendleft(
|
|
539
|
+
{
|
|
540
|
+
"op": name,
|
|
541
|
+
"seconds": round(seconds, 3),
|
|
542
|
+
"ok": ok,
|
|
543
|
+
"at": time.time(),
|
|
544
|
+
"detail": detail,
|
|
545
|
+
}
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
# --- the worker thread --------------------------------------------------------
|
|
549
|
+
|
|
550
|
+
def _loop(self) -> None:
|
|
551
|
+
"""Run tasks one at a time, polling and releasing in the gaps."""
|
|
552
|
+
while not self._stopping.is_set():
|
|
553
|
+
try:
|
|
554
|
+
task = self._queue.get(timeout=LOOP_TICK_S)
|
|
555
|
+
except queue.Empty:
|
|
556
|
+
task = None
|
|
557
|
+
if task is None:
|
|
558
|
+
if self._stopping.is_set():
|
|
559
|
+
break
|
|
560
|
+
self._idle_work()
|
|
561
|
+
continue
|
|
562
|
+
self._run_task(task)
|
|
563
|
+
self._close("shutting down")
|
|
564
|
+
|
|
565
|
+
def _idle_work(self) -> None:
|
|
566
|
+
"""Between tasks: refresh the live view, or hand the link back."""
|
|
567
|
+
if self._exclusive:
|
|
568
|
+
return # something owns the link outright; do not interrupt it
|
|
569
|
+
if self._release_requested:
|
|
570
|
+
self._release_requested = False
|
|
571
|
+
self._close("released")
|
|
572
|
+
return
|
|
573
|
+
now = time.monotonic()
|
|
574
|
+
# Polling with nothing selected would fail on every beat and turn the
|
|
575
|
+
# link red for something the user has not asked for yet.
|
|
576
|
+
if self._live and self._poll_fn is not None and self._can_poll():
|
|
577
|
+
if now - self._last_poll >= self.poll_interval:
|
|
578
|
+
self._last_poll = now
|
|
579
|
+
self._run_task(Task(self.poll_task_name, self._poll_fn, quiet=True))
|
|
580
|
+
return
|
|
581
|
+
if self._link is not None and now - self._last_used >= self.idle_timeout:
|
|
582
|
+
self._close("idle")
|
|
583
|
+
elif self._link is not None and int(now) != self._last_tick:
|
|
584
|
+
self._last_tick = int(now)
|
|
585
|
+
self._publish_link() # keep the release countdown moving
|
|
586
|
+
with self._lock:
|
|
587
|
+
self._forget_old_jobs()
|
|
588
|
+
|
|
589
|
+
def _can_poll(self) -> bool:
|
|
590
|
+
"""Whether a live refresh has anything to read from."""
|
|
591
|
+
try:
|
|
592
|
+
self._check_ready()
|
|
593
|
+
except Exception: # noqa: BLE001 - "not yet" is an answer, not a fault
|
|
594
|
+
return False
|
|
595
|
+
return True
|
|
596
|
+
|
|
597
|
+
def _run_task(self, task: Task) -> None:
|
|
598
|
+
"""Run one task, reporting the link state around it."""
|
|
599
|
+
if self._release_requested and not task.exclusive:
|
|
600
|
+
self._release_requested = False
|
|
601
|
+
self._close("released")
|
|
602
|
+
started = time.monotonic()
|
|
603
|
+
job = task.job
|
|
604
|
+
if job is not None:
|
|
605
|
+
job.state = "running"
|
|
606
|
+
job.started = time.time()
|
|
607
|
+
self._publish_job(job)
|
|
608
|
+
try:
|
|
609
|
+
link = self._require_link(quiet=task.quiet)
|
|
610
|
+
self._set_state(self._busy_state(task), task.name, quiet=task.quiet)
|
|
611
|
+
task.result = self._invoke(task, link)
|
|
612
|
+
except BaseException as exc: # noqa: BLE001 - reported, never swallowed
|
|
613
|
+
task.error = exc
|
|
614
|
+
self._after_failure(exc)
|
|
615
|
+
self._finish_job(job, failure=exc)
|
|
616
|
+
if not task.quiet:
|
|
617
|
+
self._note_activity(
|
|
618
|
+
task.name, time.monotonic() - started, False, describe(exc)
|
|
619
|
+
)
|
|
620
|
+
else:
|
|
621
|
+
self._finish_job(job, result=task.result)
|
|
622
|
+
if not task.quiet:
|
|
623
|
+
self._note_activity(task.name, time.monotonic() - started, True, "")
|
|
624
|
+
finally:
|
|
625
|
+
task.done.set()
|
|
626
|
+
self._last_used = time.monotonic()
|
|
627
|
+
with self._lock:
|
|
628
|
+
self._exclusive = False
|
|
629
|
+
if self._link is not None:
|
|
630
|
+
self._set_state(self.IDLE)
|
|
631
|
+
|
|
632
|
+
def _finish_job(
|
|
633
|
+
self,
|
|
634
|
+
job: Job | None,
|
|
635
|
+
*,
|
|
636
|
+
result: Any = None,
|
|
637
|
+
failure: BaseException | None = None,
|
|
638
|
+
) -> None:
|
|
639
|
+
"""Close a job's record and tell the page, if this task had one."""
|
|
640
|
+
if job is None:
|
|
641
|
+
return
|
|
642
|
+
job.finished = time.time()
|
|
643
|
+
if failure is not None:
|
|
644
|
+
job.state = "failed"
|
|
645
|
+
job.error = describe(failure)
|
|
646
|
+
else:
|
|
647
|
+
# A callable either returns its result or has been filling it in
|
|
648
|
+
# as it went; anything else means the latter.
|
|
649
|
+
if isinstance(result, dict):
|
|
650
|
+
job.result = result
|
|
651
|
+
job.state = "done"
|
|
652
|
+
job.progress = 1.0
|
|
653
|
+
self._publish_job(job)
|
|
654
|
+
|
|
655
|
+
def _require_link(self, *, quiet: bool = False) -> LinkT:
|
|
656
|
+
"""Return the live link, opening it if it is not held."""
|
|
657
|
+
if self._link is not None:
|
|
658
|
+
return self._link
|
|
659
|
+
self._check_ready()
|
|
660
|
+
self._set_state(self.OPENING, self._opening_note(), quiet=quiet)
|
|
661
|
+
self._link = self._open_link()
|
|
662
|
+
return self._link
|
|
663
|
+
|
|
664
|
+
def _opening_note(self) -> str:
|
|
665
|
+
"""Return what the page says while the link is being opened."""
|
|
666
|
+
return "Connecting"
|
|
667
|
+
|
|
668
|
+
def _close(self, why: str, *, quiet: bool = False) -> None:
|
|
669
|
+
"""Hand the link back, if one is held."""
|
|
670
|
+
link = self._link
|
|
671
|
+
self._link = None
|
|
672
|
+
if link is not None:
|
|
673
|
+
self._close_link(link)
|
|
674
|
+
if not quiet:
|
|
675
|
+
self._set_state(self.RELEASED, why if link is not None else "")
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def _job_key(job: Job) -> tuple[float, int]:
|
|
679
|
+
"""Sort jobs by start time, then id (queued ones have no start time)."""
|
|
680
|
+
return (job.started or float("inf"), job.id)
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
__all__ = [
|
|
684
|
+
"ACTIVITY_HISTORY",
|
|
685
|
+
"DEFAULT_IDLE_TIMEOUT_S",
|
|
686
|
+
"DEFAULT_POLL_INTERVAL_S",
|
|
687
|
+
"DEFAULT_TASK_TIMEOUT_S",
|
|
688
|
+
"JOB_NOTIFY_INTERVAL_S",
|
|
689
|
+
"JOB_RETENTION_S",
|
|
690
|
+
"LOOP_TICK_S",
|
|
691
|
+
"MAX_POLL_INTERVAL_S",
|
|
692
|
+
"MIN_POLL_INTERVAL_S",
|
|
693
|
+
"STOP_JOIN_S",
|
|
694
|
+
"Job",
|
|
695
|
+
"Task",
|
|
696
|
+
"Worker",
|
|
697
|
+
]
|