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.
Files changed (57) hide show
  1. de_shell/__init__.py +25 -0
  2. de_shell/actions/__init__.py +0 -0
  3. de_shell/actions/context.py +62 -0
  4. de_shell/actions/figure_registry.py +53 -0
  5. de_shell/actions/lifecycle.py +295 -0
  6. de_shell/actions/registry.py +141 -0
  7. de_shell/actions/wizard.py +115 -0
  8. de_shell/app.py +170 -0
  9. de_shell/compute.py +103 -0
  10. de_shell/debug_flags.py +69 -0
  11. de_shell/ipc.py +236 -0
  12. de_shell/js/__init__.py +38 -0
  13. de_shell/js/__main__.py +4 -0
  14. de_shell/js/main/backendProcess.test.ts +70 -0
  15. de_shell/js/main/backendProcess.ts +330 -0
  16. de_shell/js/main/config.ts +53 -0
  17. de_shell/js/main/dialogs.ts +62 -0
  18. de_shell/js/main/envProgress.ts +126 -0
  19. de_shell/js/main/errorReport.ts +261 -0
  20. de_shell/js/main/index.ts +57 -0
  21. de_shell/js/main/problemLog.ts +53 -0
  22. de_shell/js/main/pythonEnv.test.ts +125 -0
  23. de_shell/js/main/pythonEnv.ts +442 -0
  24. de_shell/js/main/sentryEnvelope.test.ts +94 -0
  25. de_shell/js/main/sentryEnvelope.ts +100 -0
  26. de_shell/js/main/updater.ts +322 -0
  27. de_shell/js/main/updaterErrors.test.ts +111 -0
  28. de_shell/js/main/updaterErrors.ts +65 -0
  29. de_shell/js/main/window.ts +141 -0
  30. de_shell/js/package.json +5 -0
  31. de_shell/js/preload/index.ts +130 -0
  32. de_shell/js/renderer/FigureFrame.tsx +88 -0
  33. de_shell/js/renderer/figureBridge.react.ts +58 -0
  34. de_shell/js/renderer/figureBridge.test.ts +184 -0
  35. de_shell/js/renderer/figureBridge.ts +169 -0
  36. de_shell/js/renderer/index.ts +34 -0
  37. de_shell/js/renderer/protocol.ts +164 -0
  38. de_shell/js/renderer/shellState.test.ts +193 -0
  39. de_shell/js/renderer/shellState.ts +310 -0
  40. de_shell/js/testing/harness.cjs +244 -0
  41. de_shell/js/testing/harness.test.cjs +73 -0
  42. de_shell/log_stream.py +185 -0
  43. de_shell/plotting/__init__.py +0 -0
  44. de_shell/plotting/colormaps.py +27 -0
  45. de_shell/plotting/figure.py +601 -0
  46. de_shell/plotting/selectors/__init__.py +0 -0
  47. de_shell/plotting/selectors/utils.py +29 -0
  48. de_shell/plotting/stream.py +172 -0
  49. de_shell/process_guard.py +190 -0
  50. de_shell/session.py +211 -0
  51. de_shell/testing/__init__.py +0 -0
  52. de_shell/timing.py +28 -0
  53. de_shell-0.2.0.dist-info/METADATA +196 -0
  54. de_shell-0.2.0.dist-info/RECORD +57 -0
  55. de_shell-0.2.0.dist-info/WHEEL +5 -0
  56. de_shell-0.2.0.dist-info/licenses/LICENSE +21 -0
  57. 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)