de-shell 0.2.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.
- de_shell/__init__.py +25 -0
- de_shell/actions/__init__.py +0 -0
- de_shell/actions/context.py +62 -0
- de_shell/actions/figure_registry.py +53 -0
- de_shell/actions/lifecycle.py +295 -0
- de_shell/actions/registry.py +141 -0
- de_shell/actions/wizard.py +115 -0
- de_shell/app.py +170 -0
- de_shell/compute.py +103 -0
- de_shell/debug_flags.py +69 -0
- de_shell/ipc.py +236 -0
- de_shell/js/__init__.py +38 -0
- de_shell/js/__main__.py +4 -0
- de_shell/js/main/backendProcess.test.ts +70 -0
- de_shell/js/main/backendProcess.ts +330 -0
- de_shell/js/main/config.ts +53 -0
- de_shell/js/main/dialogs.ts +62 -0
- de_shell/js/main/envProgress.ts +126 -0
- de_shell/js/main/errorReport.ts +261 -0
- de_shell/js/main/index.ts +57 -0
- de_shell/js/main/problemLog.ts +53 -0
- de_shell/js/main/pythonEnv.test.ts +125 -0
- de_shell/js/main/pythonEnv.ts +442 -0
- de_shell/js/main/sentryEnvelope.test.ts +94 -0
- de_shell/js/main/sentryEnvelope.ts +100 -0
- de_shell/js/main/updater.ts +322 -0
- de_shell/js/main/updaterErrors.test.ts +111 -0
- de_shell/js/main/updaterErrors.ts +65 -0
- de_shell/js/main/window.ts +141 -0
- de_shell/js/package.json +5 -0
- de_shell/js/preload/index.ts +130 -0
- de_shell/js/renderer/FigureFrame.tsx +88 -0
- de_shell/js/renderer/figureBridge.react.ts +58 -0
- de_shell/js/renderer/figureBridge.test.ts +184 -0
- de_shell/js/renderer/figureBridge.ts +169 -0
- de_shell/js/renderer/index.ts +34 -0
- de_shell/js/renderer/protocol.ts +164 -0
- de_shell/js/renderer/shellState.test.ts +193 -0
- de_shell/js/renderer/shellState.ts +310 -0
- de_shell/js/testing/harness.cjs +244 -0
- de_shell/js/testing/harness.test.cjs +73 -0
- de_shell/log_stream.py +185 -0
- de_shell/plotting/__init__.py +0 -0
- de_shell/plotting/colormaps.py +27 -0
- de_shell/plotting/figure.py +601 -0
- de_shell/plotting/selectors/__init__.py +0 -0
- de_shell/plotting/selectors/utils.py +29 -0
- de_shell/plotting/stream.py +172 -0
- de_shell/process_guard.py +190 -0
- de_shell/session.py +211 -0
- de_shell/testing/__init__.py +0 -0
- de_shell/timing.py +28 -0
- de_shell-0.2.0.dist-info/METADATA +196 -0
- de_shell-0.2.0.dist-info/RECORD +57 -0
- de_shell-0.2.0.dist-info/WHEEL +5 -0
- de_shell-0.2.0.dist-info/licenses/LICENSE +21 -0
- de_shell-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""
|
|
2
|
+
stream.py — refreshing a figure from work that finishes off the main thread.
|
|
3
|
+
|
|
4
|
+
Every shell app produces frames somewhere other than the asyncio main thread: a
|
|
5
|
+
camera thread, an acquisition runner, a worker computing a result, a background
|
|
6
|
+
fill writing into a buffer. All of them need the same three things, and all
|
|
7
|
+
three had been written separately in each app:
|
|
8
|
+
|
|
9
|
+
1. **Marshal to the main thread.** Figures may only be touched there.
|
|
10
|
+
2. **Newest wins, with ONE scheduled paint.** A producer faster than the
|
|
11
|
+
renderer must not queue a callback per frame — the main thread would fall
|
|
12
|
+
further and further behind while the queue grew, showing ever-staler frames.
|
|
13
|
+
A single pending slot means a superseded frame is dropped, not backlogged.
|
|
14
|
+
3. **Count what was dropped.** Dropping is correct here, but invisible dropping
|
|
15
|
+
is how "the display is laggy" becomes unfalsifiable.
|
|
16
|
+
|
|
17
|
+
There is deliberately **no dask**. A future is any object with
|
|
18
|
+
``add_done_callback`` / ``result`` / ``cancel`` — ``concurrent.futures.Future``
|
|
19
|
+
from ``de_shell.compute.ThreadCompute`` is the common case, and SpyDE's
|
|
20
|
+
distributed adapter satisfies the same protocol without this module knowing.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import logging
|
|
25
|
+
import threading
|
|
26
|
+
from typing import Any, Callable
|
|
27
|
+
|
|
28
|
+
import numpy as np
|
|
29
|
+
|
|
30
|
+
log = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class FrameStream:
|
|
34
|
+
"""Newest-wins painting into a :class:`~de_shell.plotting.figure.FigureView`.
|
|
35
|
+
|
|
36
|
+
Parameters
|
|
37
|
+
----------
|
|
38
|
+
view
|
|
39
|
+
The figure to paint. Anything with ``show(frame, clim=…) -> bool``.
|
|
40
|
+
dispatch
|
|
41
|
+
Schedules a callable on the main thread — ``SessionBase._dispatch_to_main``.
|
|
42
|
+
on_painted
|
|
43
|
+
Optional ``fn(frame)`` run on the MAIN thread after a successful paint,
|
|
44
|
+
for whatever the app hangs off a new frame (stats, counters, a status
|
|
45
|
+
line).
|
|
46
|
+
on_error
|
|
47
|
+
Optional ``fn(exc)`` for a paint or future that failed. Runs on the main
|
|
48
|
+
thread. Without one, failures are logged and swallowed — a display error
|
|
49
|
+
must never kill the producer.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(self, view, dispatch: Callable[[Callable[[], None]], None], *,
|
|
53
|
+
on_painted: Callable[[np.ndarray], None] | None = None,
|
|
54
|
+
on_error: Callable[[Exception], None] | None = None) -> None:
|
|
55
|
+
self._view = view
|
|
56
|
+
self._dispatch = dispatch
|
|
57
|
+
self._on_painted = on_painted
|
|
58
|
+
self._on_error = on_error
|
|
59
|
+
|
|
60
|
+
self._lock = threading.Lock()
|
|
61
|
+
self._pending: np.ndarray | None = None
|
|
62
|
+
self._pending_clim: tuple[float, float] | None = None
|
|
63
|
+
self._scheduled = False
|
|
64
|
+
self._closed = False
|
|
65
|
+
#: The future whose result we are still interested in. A newer submission
|
|
66
|
+
#: supersedes it; its callback then no-ops on this identity check.
|
|
67
|
+
self._future: Any = None
|
|
68
|
+
|
|
69
|
+
self.shown = 0
|
|
70
|
+
self.dropped = 0
|
|
71
|
+
|
|
72
|
+
# ── Submitting ────────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
def submit(self, frame: np.ndarray, *,
|
|
75
|
+
clim: tuple[float, float] | None = None) -> None:
|
|
76
|
+
"""Offer a frame from ANY thread. The newest one wins."""
|
|
77
|
+
if self._closed:
|
|
78
|
+
return
|
|
79
|
+
with self._lock:
|
|
80
|
+
if self._pending is not None:
|
|
81
|
+
self.dropped += 1
|
|
82
|
+
self._pending = frame
|
|
83
|
+
self._pending_clim = clim
|
|
84
|
+
if self._scheduled:
|
|
85
|
+
return # a paint is already on its way; it will take this
|
|
86
|
+
self._scheduled = True
|
|
87
|
+
self._dispatch(self._paint_pending)
|
|
88
|
+
|
|
89
|
+
def submit_future(self, future, *,
|
|
90
|
+
clim: tuple[float, float] | None = None) -> None:
|
|
91
|
+
"""Paint whatever *future* resolves to, when it resolves.
|
|
92
|
+
|
|
93
|
+
Supersedes any future still outstanding: the older one is cancelled, and
|
|
94
|
+
if it was already running its callback no-ops on an identity check
|
|
95
|
+
rather than painting a frame the user has moved past. That check is the
|
|
96
|
+
whole latest-wins guarantee for async work — a queued future cancels
|
|
97
|
+
cleanly, an in-flight one cannot be stopped and must instead be ignored.
|
|
98
|
+
"""
|
|
99
|
+
if self._closed:
|
|
100
|
+
return
|
|
101
|
+
with self._lock:
|
|
102
|
+
prev, self._future = self._future, future
|
|
103
|
+
if prev is not None and prev is not future:
|
|
104
|
+
try:
|
|
105
|
+
prev.cancel()
|
|
106
|
+
except Exception as e:
|
|
107
|
+
log.debug("cancelling superseded frame future failed: %s", e)
|
|
108
|
+
|
|
109
|
+
def _done(fut) -> None:
|
|
110
|
+
with self._lock:
|
|
111
|
+
if self._future is not fut:
|
|
112
|
+
return # superseded while running — drop it
|
|
113
|
+
self._future = None
|
|
114
|
+
try:
|
|
115
|
+
if fut.cancelled():
|
|
116
|
+
return
|
|
117
|
+
result = fut.result()
|
|
118
|
+
except Exception as e:
|
|
119
|
+
log.debug("frame future failed: %s", e)
|
|
120
|
+
self._fail(e)
|
|
121
|
+
return
|
|
122
|
+
if result is not None:
|
|
123
|
+
self.submit(np.asarray(result), clim=clim)
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
future.add_done_callback(_done)
|
|
127
|
+
except Exception as e:
|
|
128
|
+
log.debug("attaching frame-future callback failed: %s", e)
|
|
129
|
+
self._fail(e)
|
|
130
|
+
|
|
131
|
+
# ── Painting (main thread) ────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
def _paint_pending(self) -> None:
|
|
134
|
+
with self._lock:
|
|
135
|
+
frame, self._pending = self._pending, None
|
|
136
|
+
clim, self._pending_clim = self._pending_clim, None
|
|
137
|
+
self._scheduled = False
|
|
138
|
+
if frame is None or self._closed:
|
|
139
|
+
return
|
|
140
|
+
try:
|
|
141
|
+
if self._view.show(frame, clim=clim):
|
|
142
|
+
self.shown += 1
|
|
143
|
+
if self._on_painted is not None:
|
|
144
|
+
self._on_painted(frame)
|
|
145
|
+
except Exception as e:
|
|
146
|
+
log.exception("painting frame failed")
|
|
147
|
+
self._fail(e)
|
|
148
|
+
|
|
149
|
+
def _fail(self, exc: Exception) -> None:
|
|
150
|
+
if self._on_error is None:
|
|
151
|
+
return
|
|
152
|
+
# Errors surface on the MAIN thread like paints do, so a handler can
|
|
153
|
+
# touch UI without each caller having to remember to marshal.
|
|
154
|
+
self._dispatch(lambda: self._on_error(exc))
|
|
155
|
+
|
|
156
|
+
# ── Teardown ──────────────────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
def close(self) -> None:
|
|
159
|
+
"""Stop accepting and painting frames. Idempotent.
|
|
160
|
+
|
|
161
|
+
Cancels any outstanding future and drops the pending frame, so a
|
|
162
|
+
producer still winding down cannot paint into a closed window.
|
|
163
|
+
"""
|
|
164
|
+
with self._lock:
|
|
165
|
+
self._closed = True
|
|
166
|
+
self._pending = None
|
|
167
|
+
fut, self._future = self._future, None
|
|
168
|
+
if fut is not None:
|
|
169
|
+
try:
|
|
170
|
+
fut.cancel()
|
|
171
|
+
except Exception as e:
|
|
172
|
+
log.debug("cancelling frame future on close failed: %s", e)
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""
|
|
2
|
+
process_guard.py — guarantee Dask workers die with the backend process.
|
|
3
|
+
|
|
4
|
+
The Python backend is a subprocess of Electron and spawns a Dask LocalCluster
|
|
5
|
+
whose worker/nanny processes are *grandchildren*. If the backend is force-killed
|
|
6
|
+
(Task Manager), crashes, or Electron dies without sending a clean ``quit``, the
|
|
7
|
+
normal ``DaskManager.shutdown()`` never runs and those workers orphan — every
|
|
8
|
+
run leaks ``n_workers`` idle Python processes (observed: ~200 stale python.exe
|
|
9
|
+
holding tens of GB).
|
|
10
|
+
|
|
11
|
+
The OS-level fix on Windows is a **Job Object** with
|
|
12
|
+
``JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE``: assign this process to the job, and when
|
|
13
|
+
the last handle to the job closes (i.e. when THIS process dies, for ANY reason),
|
|
14
|
+
Windows terminates every process in the job — including all Dask workers spawned
|
|
15
|
+
afterwards, since child processes inherit the job by default.
|
|
16
|
+
|
|
17
|
+
On POSIX the equivalent is a new session/process-group plus ``prctl`` PDEATHSIG,
|
|
18
|
+
but Dask workers there are reaped by ``shutdown()`` reliably enough; this module
|
|
19
|
+
is a no-op off Windows (returns False) and callers keep the existing teardown.
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import logging
|
|
24
|
+
import sys
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def unthrottle_windows_timers() -> None:
|
|
30
|
+
"""Ask Windows for real timer interrupts for THIS process (no-op off
|
|
31
|
+
Windows; idempotent; best-effort).
|
|
32
|
+
|
|
33
|
+
MEASURED pathology in the Electron-spawned backend (probe
|
|
34
|
+
``_probe_fv_stall.spec.ts`` + ``repro_batch_stall.py``): OS timer waits do
|
|
35
|
+
not expire on schedule — a ``time.sleep(0.05)`` poll loop slept 15 s and
|
|
36
|
+
woke only when process I/O arrived; dask's timer-driven scheduler work
|
|
37
|
+
(task delivery to workers!) froze the same way, so distributed computes
|
|
38
|
+
only progressed when the user clicked something (stdin I/O). The SAME
|
|
39
|
+
code run from a console is healthy — Windows withholds timer interrupts
|
|
40
|
+
from the hidden Electron child (power throttling / timer coalescing).
|
|
41
|
+
|
|
42
|
+
Two knobs:
|
|
43
|
+
* ``SetProcessInformation(ProcessPowerThrottling)`` with EXECUTION_SPEED
|
|
44
|
+
and IGNORE_TIMER_RESOLUTION control bits CLEARED in the state mask —
|
|
45
|
+
explicitly opt this process OUT of EcoQoS speed throttling and OUT of
|
|
46
|
+
"ignore timer-resolution requests" (Win10 1709+).
|
|
47
|
+
* ``winmm.timeBeginPeriod(1)`` — request 1 ms timer interrupts, which the
|
|
48
|
+
opt-out above makes Windows actually honor.
|
|
49
|
+
|
|
50
|
+
Called at backend startup (app._main) and inside every Dask worker
|
|
51
|
+
process (dask_manager._WorkerTuningPlugin) — workers are children of this
|
|
52
|
+
hidden process and inherit the same throttling class.
|
|
53
|
+
"""
|
|
54
|
+
if sys.platform != "win32":
|
|
55
|
+
return
|
|
56
|
+
import ctypes
|
|
57
|
+
from ctypes import wintypes
|
|
58
|
+
try:
|
|
59
|
+
class PROCESS_POWER_THROTTLING_STATE(ctypes.Structure):
|
|
60
|
+
_fields_ = [("Version", wintypes.ULONG),
|
|
61
|
+
("ControlMask", wintypes.ULONG),
|
|
62
|
+
("StateMask", wintypes.ULONG)]
|
|
63
|
+
|
|
64
|
+
PPT_CURRENT_VERSION = 1
|
|
65
|
+
PPT_EXECUTION_SPEED = 0x1
|
|
66
|
+
PPT_IGNORE_TIMER_RESOLUTION = 0x4
|
|
67
|
+
ProcessPowerThrottling = 4
|
|
68
|
+
state = PROCESS_POWER_THROTTLING_STATE(
|
|
69
|
+
PPT_CURRENT_VERSION,
|
|
70
|
+
PPT_EXECUTION_SPEED | PPT_IGNORE_TIMER_RESOLUTION,
|
|
71
|
+
0, # both bits cleared = throttling OFF, honor timer resolution
|
|
72
|
+
)
|
|
73
|
+
ok = ctypes.windll.kernel32.SetProcessInformation(
|
|
74
|
+
ctypes.windll.kernel32.GetCurrentProcess(),
|
|
75
|
+
ProcessPowerThrottling, ctypes.byref(state), ctypes.sizeof(state))
|
|
76
|
+
logger.info("[timers] power-throttling opt-out: %s",
|
|
77
|
+
"ok" if ok else "failed")
|
|
78
|
+
except Exception as e:
|
|
79
|
+
logger.info("[timers] power-throttling opt-out unavailable: %s", e)
|
|
80
|
+
try:
|
|
81
|
+
res = ctypes.windll.winmm.timeBeginPeriod(1)
|
|
82
|
+
logger.info("[timers] timeBeginPeriod(1) -> %s (0=ok)", res)
|
|
83
|
+
except Exception as e:
|
|
84
|
+
logger.info("[timers] timeBeginPeriod unavailable: %s", e)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# Module-level so the job handle lives for the whole process lifetime. If it were
|
|
88
|
+
# a local it would be garbage-collected, closing the handle and (because
|
|
89
|
+
# KILL_ON_JOB_CLOSE) killing us immediately.
|
|
90
|
+
_job_handle = None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def install_kill_on_close() -> bool:
|
|
94
|
+
"""Assign the current process to a kill-on-close Job Object (Windows only).
|
|
95
|
+
|
|
96
|
+
Returns True if the guard is active, False otherwise (non-Windows, or any
|
|
97
|
+
failure — the caller should still keep its own ``shutdown()`` path).
|
|
98
|
+
"""
|
|
99
|
+
global _job_handle
|
|
100
|
+
if not sys.platform.startswith("win"):
|
|
101
|
+
return False
|
|
102
|
+
if _job_handle is not None:
|
|
103
|
+
return True
|
|
104
|
+
try:
|
|
105
|
+
import ctypes
|
|
106
|
+
from ctypes import wintypes
|
|
107
|
+
|
|
108
|
+
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
109
|
+
|
|
110
|
+
# --- function prototypes ---
|
|
111
|
+
kernel32.CreateJobObjectW.restype = wintypes.HANDLE
|
|
112
|
+
kernel32.CreateJobObjectW.argtypes = [wintypes.LPVOID, wintypes.LPCWSTR]
|
|
113
|
+
kernel32.SetInformationJobObject.restype = wintypes.BOOL
|
|
114
|
+
kernel32.SetInformationJobObject.argtypes = [
|
|
115
|
+
wintypes.HANDLE, ctypes.c_int, wintypes.LPVOID, wintypes.DWORD,
|
|
116
|
+
]
|
|
117
|
+
kernel32.AssignProcessToJobObject.restype = wintypes.BOOL
|
|
118
|
+
kernel32.AssignProcessToJobObject.argtypes = [wintypes.HANDLE, wintypes.HANDLE]
|
|
119
|
+
kernel32.GetCurrentProcess.restype = wintypes.HANDLE
|
|
120
|
+
|
|
121
|
+
# JOBOBJECT_EXTENDED_LIMIT_INFORMATION layout
|
|
122
|
+
class JOBOBJECT_BASIC_LIMIT_INFORMATION(ctypes.Structure):
|
|
123
|
+
_fields_ = [
|
|
124
|
+
("PerProcessUserTimeLimit", ctypes.c_int64),
|
|
125
|
+
("PerJobUserTimeLimit", ctypes.c_int64),
|
|
126
|
+
("LimitFlags", wintypes.DWORD),
|
|
127
|
+
("MinimumWorkingSetSize", ctypes.c_size_t),
|
|
128
|
+
("MaximumWorkingSetSize", ctypes.c_size_t),
|
|
129
|
+
("ActiveProcessLimit", wintypes.DWORD),
|
|
130
|
+
("Affinity", ctypes.c_size_t),
|
|
131
|
+
("PriorityClass", wintypes.DWORD),
|
|
132
|
+
("SchedulingClass", wintypes.DWORD),
|
|
133
|
+
]
|
|
134
|
+
|
|
135
|
+
class IO_COUNTERS(ctypes.Structure):
|
|
136
|
+
_fields_ = [
|
|
137
|
+
("ReadOperationCount", ctypes.c_uint64),
|
|
138
|
+
("WriteOperationCount", ctypes.c_uint64),
|
|
139
|
+
("OtherOperationCount", ctypes.c_uint64),
|
|
140
|
+
("ReadTransferCount", ctypes.c_uint64),
|
|
141
|
+
("WriteTransferCount", ctypes.c_uint64),
|
|
142
|
+
("OtherTransferCount", ctypes.c_uint64),
|
|
143
|
+
]
|
|
144
|
+
|
|
145
|
+
class JOBOBJECT_EXTENDED_LIMIT_INFORMATION(ctypes.Structure):
|
|
146
|
+
_fields_ = [
|
|
147
|
+
("BasicLimitInformation", JOBOBJECT_BASIC_LIMIT_INFORMATION),
|
|
148
|
+
("IoInfo", IO_COUNTERS),
|
|
149
|
+
("ProcessMemoryLimit", ctypes.c_size_t),
|
|
150
|
+
("JobMemoryLimit", ctypes.c_size_t),
|
|
151
|
+
("PeakProcessMemoryUsed", ctypes.c_size_t),
|
|
152
|
+
("PeakJobMemoryUsed", ctypes.c_size_t),
|
|
153
|
+
]
|
|
154
|
+
|
|
155
|
+
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000
|
|
156
|
+
JobObjectExtendedLimitInformation = 9 # JOBOBJECTINFOCLASS
|
|
157
|
+
|
|
158
|
+
job = kernel32.CreateJobObjectW(None, None)
|
|
159
|
+
if not job:
|
|
160
|
+
raise ctypes.WinError(ctypes.get_last_error())
|
|
161
|
+
|
|
162
|
+
info = JOBOBJECT_EXTENDED_LIMIT_INFORMATION()
|
|
163
|
+
info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
|
|
164
|
+
ok = kernel32.SetInformationJobObject(
|
|
165
|
+
job, JobObjectExtendedLimitInformation,
|
|
166
|
+
ctypes.byref(info), ctypes.sizeof(info),
|
|
167
|
+
)
|
|
168
|
+
if not ok:
|
|
169
|
+
raise ctypes.WinError(ctypes.get_last_error())
|
|
170
|
+
|
|
171
|
+
ok = kernel32.AssignProcessToJobObject(job, kernel32.GetCurrentProcess())
|
|
172
|
+
if not ok:
|
|
173
|
+
err = ctypes.get_last_error()
|
|
174
|
+
# ERROR_ACCESS_DENIED (5): the process is ALREADY in a job that
|
|
175
|
+
# disallows nesting (e.g. Electron already put us in one, or an older
|
|
176
|
+
# Windows without nested-job support). In that case the parent's job
|
|
177
|
+
# governs our lifetime anyway — treat as best-effort success.
|
|
178
|
+
if err == 5:
|
|
179
|
+
logger.info("process already in a job object; relying on parent "
|
|
180
|
+
"job for worker cleanup")
|
|
181
|
+
return False
|
|
182
|
+
raise ctypes.WinError(err)
|
|
183
|
+
|
|
184
|
+
_job_handle = job # keep alive for process lifetime
|
|
185
|
+
logger.info("Dask workers guarded by kill-on-close Job Object")
|
|
186
|
+
return True
|
|
187
|
+
except Exception as e:
|
|
188
|
+
logger.warning("could not install kill-on-close job object (%s); "
|
|
189
|
+
"relying on graceful shutdown for worker cleanup", e)
|
|
190
|
+
return False
|
de_shell/session.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""
|
|
2
|
+
session.py — the app-agnostic half of the backend coordinator.
|
|
3
|
+
|
|
4
|
+
Every shell app has exactly one Session: the object the frontend talks to
|
|
5
|
+
through IPC and that talks back through ``de_shell.ipc.emit``. Most of what such
|
|
6
|
+
an object does is the same in all three apps — hand out window ids, keep the
|
|
7
|
+
registry of open plots and window controllers, marshal worker results onto the
|
|
8
|
+
asyncio main thread, and persist a little JSON of user settings.
|
|
9
|
+
|
|
10
|
+
None of that knows what the data is. ``SessionBase`` owns that half; an app
|
|
11
|
+
subclasses it and adds its own (SpyDE: signal trees, the Dask cluster, file
|
|
12
|
+
I/O, the action mixins).
|
|
13
|
+
|
|
14
|
+
Two things deliberately stay the app's job:
|
|
15
|
+
|
|
16
|
+
* **Settings location.** Passed in as ``settings_dir``, not derived here. SpyDE
|
|
17
|
+
reads ``SPYDE_SETTINGS_DIR`` and falls back to ``~/.spyde``; another app has
|
|
18
|
+
its own directory and its own override variable, and baking a guess into the
|
|
19
|
+
shell would silently write one app's preferences into another's file.
|
|
20
|
+
* **Shutdown order.** ``shutdown()`` here tears down only what it owns and is
|
|
21
|
+
safe to call twice. Subclasses override, do their own work, and call
|
|
22
|
+
``super().shutdown()`` — ordering between an app's cluster, workers and
|
|
23
|
+
caches is app knowledge.
|
|
24
|
+
"""
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
import logging
|
|
29
|
+
import os
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
log = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class SessionBase:
|
|
36
|
+
"""Window registry, main-loop marshalling and the settings store."""
|
|
37
|
+
|
|
38
|
+
#: Cap on the persisted recent-files list.
|
|
39
|
+
MAX_RECENT = 20
|
|
40
|
+
|
|
41
|
+
def __init__(self, settings_dir: str) -> None:
|
|
42
|
+
# ── Window / plot registry ────────────────────────────────────────────
|
|
43
|
+
self._plots: list[Any] = [] # every open Plot
|
|
44
|
+
self._next_window_id = 0
|
|
45
|
+
self._active_window_id: int | None = None # focused window
|
|
46
|
+
# window_id -> controller for windows that are NOT registered Plots
|
|
47
|
+
# (bare `figure` emits). See the WindowController protocol in
|
|
48
|
+
# de_shell.actions.registry; _forget_window closes + evicts.
|
|
49
|
+
self._window_controllers: dict[int, object] = {}
|
|
50
|
+
|
|
51
|
+
# ── Main-thread marshalling ───────────────────────────────────────────
|
|
52
|
+
# Set by set_main_loop once the asyncio loop is running. Until then
|
|
53
|
+
# _dispatch_to_main runs inline, which is what makes a Session usable in
|
|
54
|
+
# a plain test with no loop at all.
|
|
55
|
+
self._main_loop = None
|
|
56
|
+
|
|
57
|
+
# Set by shutdown() so late work draining on a background thread can't
|
|
58
|
+
# resurrect anything after teardown.
|
|
59
|
+
self._closed = False
|
|
60
|
+
|
|
61
|
+
# ── Settings ──────────────────────────────────────────────────────────
|
|
62
|
+
self._settings_path = os.path.join(settings_dir, "settings.json")
|
|
63
|
+
self._settings: dict[str, Any] = self._load_settings()
|
|
64
|
+
self._recent_files: list[str] = []
|
|
65
|
+
try:
|
|
66
|
+
self._recent_files = list(
|
|
67
|
+
self._settings.get("recent_files", []))[:self.MAX_RECENT]
|
|
68
|
+
except Exception as e:
|
|
69
|
+
log.debug("restoring recent files from settings failed: %s", e)
|
|
70
|
+
self._update_channel: str = (
|
|
71
|
+
self._settings.get("update_channel")
|
|
72
|
+
if self._settings.get("update_channel") in ("stable", "beta")
|
|
73
|
+
else "stable"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
# ── Main-thread marshalling ───────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
def set_main_loop(self, loop) -> None:
|
|
79
|
+
"""Register the main asyncio loop so background workers can marshal their
|
|
80
|
+
result-apply onto this (main) thread. Call once the loop is running.
|
|
81
|
+
|
|
82
|
+
NB the process's frozen-timer pathology (waits only wake on process I/O
|
|
83
|
+
— see the backend tick in @de/shell-main's backendProcess) is healed by
|
|
84
|
+
Electron's 0.5 Hz stdin tick; an in-process wake ticker was tried and is
|
|
85
|
+
useless here because its own sleep freezes the same way.
|
|
86
|
+
"""
|
|
87
|
+
self._main_loop = loop
|
|
88
|
+
|
|
89
|
+
def _dispatch_to_main(self, fn) -> None:
|
|
90
|
+
"""Schedule ``fn()`` on the main asyncio thread.
|
|
91
|
+
|
|
92
|
+
Falls back to running inline when no loop is registered yet (early
|
|
93
|
+
startup, and tests that never start one) — so a worker callback is never
|
|
94
|
+
silently dropped just because the loop isn't up.
|
|
95
|
+
"""
|
|
96
|
+
loop = self._main_loop
|
|
97
|
+
if loop is not None:
|
|
98
|
+
try:
|
|
99
|
+
loop.call_soon_threadsafe(fn)
|
|
100
|
+
return
|
|
101
|
+
except Exception as e:
|
|
102
|
+
log.debug("dispatch_to_main failed, running inline: %s", e)
|
|
103
|
+
fn()
|
|
104
|
+
|
|
105
|
+
# ── Window / plot registry ────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
def next_window_id(self) -> int:
|
|
108
|
+
wid = self._next_window_id
|
|
109
|
+
self._next_window_id += 1
|
|
110
|
+
return wid
|
|
111
|
+
|
|
112
|
+
def register_plot(self, plot) -> None:
|
|
113
|
+
self._plots.append(plot)
|
|
114
|
+
|
|
115
|
+
def unregister_plot(self, plot) -> None:
|
|
116
|
+
# Identity-based, and removes every occurrence. Kept exactly as it was
|
|
117
|
+
# when lifted out of SpyDE: `register_plot` does not dedupe, so `remove`
|
|
118
|
+
# (first match, __eq__-based) would not be equivalent.
|
|
119
|
+
self._plots = [p for p in self._plots if p is not plot]
|
|
120
|
+
|
|
121
|
+
def _plot_by_window_id(self, window_id: int):
|
|
122
|
+
for p in self._plots:
|
|
123
|
+
if getattr(p, "window_id", None) == window_id:
|
|
124
|
+
return p
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
def register_window_controller(self, window_id: int, controller) -> None:
|
|
128
|
+
"""Give a non-Plot window (a bare `figure` emit) a dispatch + teardown
|
|
129
|
+
identity. See the WindowController protocol in de_shell.actions.registry;
|
|
130
|
+
the app's `_forget_window` pops the controller and calls its close()."""
|
|
131
|
+
self._window_controllers[window_id] = controller
|
|
132
|
+
|
|
133
|
+
def controller_by_window_id(self, window_id: int | None):
|
|
134
|
+
if window_id is None:
|
|
135
|
+
return None
|
|
136
|
+
return self._window_controllers.get(window_id)
|
|
137
|
+
|
|
138
|
+
# ── Settings & recent files ───────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
def _load_settings(self) -> dict:
|
|
141
|
+
try:
|
|
142
|
+
with open(self._settings_path, encoding="utf-8") as fh:
|
|
143
|
+
return json.load(fh)
|
|
144
|
+
except Exception:
|
|
145
|
+
return {}
|
|
146
|
+
|
|
147
|
+
def _save_settings(self) -> None:
|
|
148
|
+
os.makedirs(os.path.dirname(self._settings_path), exist_ok=True)
|
|
149
|
+
with open(self._settings_path, "w", encoding="utf-8") as fh:
|
|
150
|
+
json.dump(self._settings, fh, indent=2)
|
|
151
|
+
|
|
152
|
+
def _add_recent(self, path: str) -> None:
|
|
153
|
+
if path in self._recent_files:
|
|
154
|
+
self._recent_files.remove(path)
|
|
155
|
+
self._recent_files.insert(0, path)
|
|
156
|
+
self._settings["recent_files"] = self._recent_files[:self.MAX_RECENT]
|
|
157
|
+
try:
|
|
158
|
+
self._save_settings()
|
|
159
|
+
except Exception as e:
|
|
160
|
+
log.debug("saving recent-files settings failed: %s", e)
|
|
161
|
+
|
|
162
|
+
def get_recent_files(self) -> list[str]:
|
|
163
|
+
return list(self._recent_files[:self.MAX_RECENT])
|
|
164
|
+
|
|
165
|
+
def set_update_channel(self, channel: str) -> None:
|
|
166
|
+
"""Persist the update channel ('stable' or 'beta') to settings.json.
|
|
167
|
+
|
|
168
|
+
Mirrors the choice the Electron main process's autoUpdater actually acts
|
|
169
|
+
on — kept here too so the preference is visible from the Python side and
|
|
170
|
+
survives a settings.json inspection independent of Electron's storage.
|
|
171
|
+
"""
|
|
172
|
+
if channel not in ("stable", "beta"):
|
|
173
|
+
log.warning("ignoring invalid update_channel %r", channel)
|
|
174
|
+
return
|
|
175
|
+
self._update_channel = channel
|
|
176
|
+
self._settings["update_channel"] = channel
|
|
177
|
+
try:
|
|
178
|
+
self._save_settings()
|
|
179
|
+
except Exception as e:
|
|
180
|
+
log.debug("saving update_channel setting failed: %s", e)
|
|
181
|
+
|
|
182
|
+
# ── First-run welcome tour ────────────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
@property
|
|
185
|
+
def first_run(self) -> bool:
|
|
186
|
+
"""True until the welcome tour has been opened/dismissed once. Mirrors
|
|
187
|
+
the ``tutorial_seen`` settings key: absent (never set) => first run."""
|
|
188
|
+
return not bool(self._settings.get("tutorial_seen", False))
|
|
189
|
+
|
|
190
|
+
def mark_tutorial_seen(self) -> None:
|
|
191
|
+
"""Persist that the welcome tour has been shown, so it never auto-opens
|
|
192
|
+
again. Idempotent — the renderer calls it every time the tour opens."""
|
|
193
|
+
if self._settings.get("tutorial_seen") is True:
|
|
194
|
+
return
|
|
195
|
+
self._settings["tutorial_seen"] = True
|
|
196
|
+
try:
|
|
197
|
+
self._save_settings()
|
|
198
|
+
except Exception as e:
|
|
199
|
+
log.debug("saving tutorial_seen setting failed: %s", e)
|
|
200
|
+
|
|
201
|
+
# ── Shutdown ──────────────────────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
def shutdown(self) -> None:
|
|
204
|
+
"""Tear down what the base owns. Idempotent.
|
|
205
|
+
|
|
206
|
+
Subclasses override, shut their own things down first, then call
|
|
207
|
+
``super().shutdown()`` — the ordering between an app's cluster, worker
|
|
208
|
+
threads and caches is the app's knowledge, not the shell's.
|
|
209
|
+
"""
|
|
210
|
+
self._closed = True
|
|
211
|
+
self._window_controllers.clear()
|
|
File without changes
|
de_shell/timing.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""
|
|
2
|
+
timing.py — sleeping that actually wakes up in a shell backend.
|
|
3
|
+
|
|
4
|
+
`time.sleep` is not reliable in this process. The backend is a hidden child of
|
|
5
|
+
the Electron app, and the OS coalesces its timers hard enough that a
|
|
6
|
+
`time.sleep(0.05)` in a poll loop has been measured freezing for **15 seconds**,
|
|
7
|
+
waking only when process I/O arrived — while `threading.Event.wait(timeout)` in
|
|
8
|
+
the same process ticked exactly on schedule, 120 times out of 120.
|
|
9
|
+
|
|
10
|
+
So every poll loop in a shell app sleeps through `reliable_sleep`. This is the
|
|
11
|
+
same pathology the 0.5 Hz stdin tick in @de/shell-main's backendProcess exists
|
|
12
|
+
for, seen from the Python side.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import threading
|
|
17
|
+
|
|
18
|
+
# Never set. It exists purely because `Event.wait(timeout)` is scheduled
|
|
19
|
+
# reliably where `time.sleep(timeout)` is not.
|
|
20
|
+
_WAKE = threading.Event()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def reliable_sleep(seconds: float) -> None:
|
|
24
|
+
"""Sleep that keeps ticking on the throttled, Electron-spawned backend.
|
|
25
|
+
|
|
26
|
+
Use instead of ``time.sleep`` in any loop that must make progress.
|
|
27
|
+
"""
|
|
28
|
+
_WAKE.wait(seconds)
|