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
de_shell/ipc.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ipc.py — stdin/stdout JSON-lines protocol between the Python backend and Electron.
|
|
3
|
+
|
|
4
|
+
All messages from Python to Electron are prefixed with "PLOTAPP:" and contain
|
|
5
|
+
JSON on a single line, matching the protocol anyplotlib._electron already uses.
|
|
6
|
+
|
|
7
|
+
Messages from Electron arrive on stdin as JSON lines (no prefix).
|
|
8
|
+
|
|
9
|
+
Usage
|
|
10
|
+
-----
|
|
11
|
+
from de_shell.ipc import emit, read_messages
|
|
12
|
+
|
|
13
|
+
# send a message to Electron
|
|
14
|
+
emit({"type": "status", "text": "Cluster ready"})
|
|
15
|
+
|
|
16
|
+
# read messages (async, called from the asyncio event loop)
|
|
17
|
+
async for msg in read_messages(loop):
|
|
18
|
+
handle(msg)
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import asyncio
|
|
23
|
+
import json
|
|
24
|
+
import logging
|
|
25
|
+
import math
|
|
26
|
+
import sys
|
|
27
|
+
import threading
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _sanitize_nonfinite(o: Any) -> Any:
|
|
32
|
+
"""Replace NaN/Inf floats with None, recursively. Slow path only."""
|
|
33
|
+
if isinstance(o, float):
|
|
34
|
+
return o if math.isfinite(o) else None
|
|
35
|
+
if isinstance(o, dict):
|
|
36
|
+
return {k: _sanitize_nonfinite(v) for k, v in o.items()}
|
|
37
|
+
if isinstance(o, (list, tuple)):
|
|
38
|
+
return [_sanitize_nonfinite(v) for v in o]
|
|
39
|
+
return o
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _dumps(obj: Any) -> str:
|
|
43
|
+
"""json.dumps that never emits bare ``NaN``/``Infinity`` tokens.
|
|
44
|
+
|
|
45
|
+
Python's json happily writes ``NaN``, which is NOT JSON: the Electron
|
|
46
|
+
demuxer's ``JSON.parse`` throws and the runner drops the whole message on
|
|
47
|
+
the floor (``catch { /* malformed line — ignore */ }``) — one non-finite
|
|
48
|
+
fit value or detector temperature silently swallowed an entire state
|
|
49
|
+
update. Fast path is plain dumps with ``allow_nan=False``; only a payload
|
|
50
|
+
that actually contains a non-finite float pays for the recursive
|
|
51
|
+
sanitize (→ null, which the renderer already renders as "—").
|
|
52
|
+
"""
|
|
53
|
+
try:
|
|
54
|
+
return json.dumps(obj, default=str, allow_nan=False)
|
|
55
|
+
except ValueError:
|
|
56
|
+
try:
|
|
57
|
+
return json.dumps(_sanitize_nonfinite(obj), default=str,
|
|
58
|
+
allow_nan=False)
|
|
59
|
+
except ValueError:
|
|
60
|
+
# A non-finite the sanitizer could not reach: keep the message
|
|
61
|
+
# rather than crash the emitter (old behavior, dropped downstream).
|
|
62
|
+
return json.dumps(obj, default=str)
|
|
63
|
+
|
|
64
|
+
# Logging goes to stderr (default), never the PLOTAPP stdout protocol channel.
|
|
65
|
+
log = logging.getLogger(__name__)
|
|
66
|
+
|
|
67
|
+
_stdout_lock = threading.Lock()
|
|
68
|
+
|
|
69
|
+
# Capture the *real* stdout at import time. This is the dedicated protocol
|
|
70
|
+
# channel; emit() always writes here even after stray prints are redirected.
|
|
71
|
+
_PROTOCOL_OUT = sys.stdout
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _write_line(line: str) -> None:
|
|
75
|
+
"""Write one protocol line, flushed immediately (thread-safe)."""
|
|
76
|
+
with _stdout_lock:
|
|
77
|
+
_PROTOCOL_OUT.write(line)
|
|
78
|
+
_PROTOCOL_OUT.flush()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _write_binary(frame: bytes) -> None:
|
|
82
|
+
"""Write one raw binary frame to the protocol channel's BINARY stream, under
|
|
83
|
+
the same lock as text lines so a PLOTBIN frame never interleaves with a
|
|
84
|
+
PLOTAPP: line. Uses ``_PROTOCOL_OUT.buffer`` (the real stdout's binary fd) so
|
|
85
|
+
the bytes are NOT newline-translated / re-encoded (Windows text mode would
|
|
86
|
+
corrupt binary — the '\\n' bytes in the pixels would become '\\r\\n')."""
|
|
87
|
+
buf = getattr(_PROTOCOL_OUT, "buffer", None)
|
|
88
|
+
if buf is None:
|
|
89
|
+
return
|
|
90
|
+
with _stdout_lock:
|
|
91
|
+
_PROTOCOL_OUT.flush() # flush any pending text first (ordering)
|
|
92
|
+
buf.write(frame)
|
|
93
|
+
buf.flush()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def redirect_stray_stdout() -> None:
|
|
97
|
+
"""Send all `print()` output to stderr so it can never interleave with the
|
|
98
|
+
PLOTAPP protocol on stdout, while keeping BOTH protocol emitters — the shell's
|
|
99
|
+
own ``emit`` and anyplotlib's ``_electron.emit`` — pointed at the real
|
|
100
|
+
stdout protocol channel.
|
|
101
|
+
|
|
102
|
+
anyplotlib._electron.emit writes to ``sys.stdout`` dynamically, so simply
|
|
103
|
+
redirecting sys.stdout would send its state_update/event_json messages to
|
|
104
|
+
stderr (where the runner never parses them). We therefore monkeypatch
|
|
105
|
+
anyplotlib's emit to share the shell's locked protocol channel, then redirect
|
|
106
|
+
sys.stdout so stray prints go to stderr.
|
|
107
|
+
|
|
108
|
+
Call once at backend startup, after _PROTOCOL_OUT is captured.
|
|
109
|
+
"""
|
|
110
|
+
try:
|
|
111
|
+
import anyplotlib._electron as _ael
|
|
112
|
+
|
|
113
|
+
def _shared_emit(obj: dict) -> None:
|
|
114
|
+
_write_line("PLOTAPP:" + _dumps(obj) + "\n")
|
|
115
|
+
|
|
116
|
+
_ael.emit = _shared_emit
|
|
117
|
+
|
|
118
|
+
# anyplotlib's emit_binary writes raw PLOTBIN frames to sys.stdout.buffer,
|
|
119
|
+
# but after the redirect below sys.stdout IS stderr — so without this the
|
|
120
|
+
# binary pixels would spew to the terminal (garbled) and never reach the
|
|
121
|
+
# Electron demuxer. Route it through the locked BINARY protocol channel.
|
|
122
|
+
if hasattr(_ael, "emit_binary"):
|
|
123
|
+
from anyplotlib._binary_frame import encode_frame as _encode_frame
|
|
124
|
+
|
|
125
|
+
_bin_logged = [False]
|
|
126
|
+
|
|
127
|
+
def _shared_emit_binary(fig_id, key, header, payload) -> None:
|
|
128
|
+
if not _bin_logged[0]:
|
|
129
|
+
_bin_logged[0] = True
|
|
130
|
+
log.info("binary transport active: PLOTBIN %s %d bytes",
|
|
131
|
+
key, len(payload))
|
|
132
|
+
_write_binary(_encode_frame(fig_id, key, header, payload))
|
|
133
|
+
|
|
134
|
+
_ael.emit_binary = _shared_emit_binary
|
|
135
|
+
except Exception as e:
|
|
136
|
+
log.debug("redirecting anyplotlib emit to shared protocol channel failed: %s", e)
|
|
137
|
+
|
|
138
|
+
sys.stdout = sys.stderr
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def emit(obj: dict[str, Any]) -> None:
|
|
142
|
+
"""Write a PLOTAPP: message to the protocol channel (thread-safe, flushed
|
|
143
|
+
immediately from any thread)."""
|
|
144
|
+
_write_line("PLOTAPP:" + _dumps(obj) + "\n")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def emit_status(text: str) -> None:
|
|
148
|
+
emit({"type": "status", "text": text})
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def emit_error(text: str) -> None:
|
|
152
|
+
emit({"type": "error", "text": text})
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def emit_progress(done: int, total: int, label: str = "") -> None:
|
|
156
|
+
emit({"type": "progress", "done": done, "total": total, "label": label})
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def emit_window_computing(window_id: int | None, computing: bool) -> None:
|
|
160
|
+
"""Per-window compute-lifecycle marker — drives the renderer's floating
|
|
161
|
+
"Calculating…" overlay (centered, translucent, pointer-events:none) on the
|
|
162
|
+
plot window while a long compute (e.g. the progressive navigator fill, a
|
|
163
|
+
streamed virtual image) is filling it in.
|
|
164
|
+
|
|
165
|
+
``window_id`` is None-guarded (a plot may not have a window yet, e.g. very
|
|
166
|
+
early in construction) — silently a no-op rather than sending a malformed
|
|
167
|
+
message. Callers MUST pair every ``True`` with a matching ``False`` in a
|
|
168
|
+
``finally`` block so a cancelled/failed compute still clears the overlay;
|
|
169
|
+
see ``de_shell.actions.lifecycle.window_computing`` for the context-manager
|
|
170
|
+
helper that guarantees this.
|
|
171
|
+
"""
|
|
172
|
+
if window_id is None:
|
|
173
|
+
return
|
|
174
|
+
emit({"type": "window_computing", "window_id": window_id, "computing": bool(computing)})
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
async def read_messages(loop: asyncio.AbstractEventLoop | None = None):
|
|
178
|
+
"""
|
|
179
|
+
Async generator that yields parsed JSON dicts from stdin.
|
|
180
|
+
Each line on stdin must be a valid JSON object.
|
|
181
|
+
Exits when stdin closes.
|
|
182
|
+
|
|
183
|
+
Implementation note (cross-platform): stdin is read on a dedicated daemon
|
|
184
|
+
thread that pushes raw lines into an ``asyncio.Queue``, rather than via
|
|
185
|
+
``loop.connect_read_pipe(sys.stdin)`` — the latter raises
|
|
186
|
+
``OSError: [WinError 6] The handle is invalid`` under Windows'
|
|
187
|
+
``ProactorEventLoop`` (it can't register a console/pipe stdin handle with the
|
|
188
|
+
IOCP), which silently broke every Electron→backend message on Windows. A
|
|
189
|
+
blocking ``readline`` on a thread works identically on Windows, macOS, Linux.
|
|
190
|
+
|
|
191
|
+
Encoding note: we read the BINARY stream (``sys.stdin.buffer``) and decode
|
|
192
|
+
each line as UTF-8 ourselves. ``sys.stdin`` (the text layer) decodes with the
|
|
193
|
+
platform-default encoding — cp1252 on Windows — so a UTF-8 payload from
|
|
194
|
+
Electron carrying any non-ASCII character (e.g. ``εxx`` strain labels, the
|
|
195
|
+
``Å`` unit) was mojibake'd: the bytes ``0xCE 0xB5`` (UTF-8 ``ε``) decoded to
|
|
196
|
+
``ε`` and downstream string matches (tile_views' label lookup) silently
|
|
197
|
+
failed. Reading bytes + explicit UTF-8 decode is correct on every platform.
|
|
198
|
+
"""
|
|
199
|
+
if loop is None:
|
|
200
|
+
loop = asyncio.get_event_loop()
|
|
201
|
+
|
|
202
|
+
q: asyncio.Queue[str | None] = asyncio.Queue()
|
|
203
|
+
# Prefer the raw byte stream; fall back to the text stream (some test
|
|
204
|
+
# harnesses replace sys.stdin with a StringIO that has no .buffer).
|
|
205
|
+
stream = getattr(sys.stdin, "buffer", None)
|
|
206
|
+
binary = stream is not None
|
|
207
|
+
if not binary:
|
|
208
|
+
stream = sys.stdin
|
|
209
|
+
|
|
210
|
+
def _pump() -> None:
|
|
211
|
+
try:
|
|
212
|
+
while True:
|
|
213
|
+
raw = stream.readline()
|
|
214
|
+
if not raw: # EOF — pipe closed by Electron
|
|
215
|
+
break
|
|
216
|
+
if binary:
|
|
217
|
+
raw = raw.decode("utf-8", errors="replace")
|
|
218
|
+
loop.call_soon_threadsafe(q.put_nowait, raw)
|
|
219
|
+
except Exception as e:
|
|
220
|
+
log.debug("stdin pump stopped: %s", e)
|
|
221
|
+
finally:
|
|
222
|
+
loop.call_soon_threadsafe(q.put_nowait, None)
|
|
223
|
+
|
|
224
|
+
threading.Thread(target=_pump, daemon=True, name="de-shell-stdin-pump").start()
|
|
225
|
+
|
|
226
|
+
while True:
|
|
227
|
+
raw = await q.get()
|
|
228
|
+
if raw is None: # EOF sentinel
|
|
229
|
+
break
|
|
230
|
+
line = raw.strip()
|
|
231
|
+
if not line:
|
|
232
|
+
continue
|
|
233
|
+
try:
|
|
234
|
+
yield json.loads(line)
|
|
235
|
+
except json.JSONDecodeError:
|
|
236
|
+
log.debug("skipping non-JSON line from frontend: %r", line[:200])
|
de_shell/js/__init__.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""The TypeScript half of the shell, shipped inside the wheel.
|
|
2
|
+
|
|
3
|
+
Four folders, one per Electron target, each a self-contained tree of
|
|
4
|
+
TypeScript that the consuming app's bundler compiles:
|
|
5
|
+
|
|
6
|
+
* ``main/`` — the Electron main process: the window and menus, the Python
|
|
7
|
+
sidecar and its stdout demuxer, the managed environment, the
|
|
8
|
+
updater, the problem reporter.
|
|
9
|
+
* ``preload/`` — the contextBridge surface (``exposeShellBridge``).
|
|
10
|
+
* ``renderer/`` — React: the figure bridge over anyplotlib, ``FigureFrame``,
|
|
11
|
+
the chrome slice of the reducer.
|
|
12
|
+
* ``testing/`` — the Playwright harness (``launchApp``) the e2e specs use.
|
|
13
|
+
|
|
14
|
+
They live HERE rather than on npm so that the JavaScript that speaks the
|
|
15
|
+
sidecar protocol ships in the same artifact as the Python that speaks it: one
|
|
16
|
+
``pip install -U de-shell`` moves both halves together, and two versions of the
|
|
17
|
+
protocol in one app cannot happen. An app finds the tree by asking this module
|
|
18
|
+
(``python -m de_shell.js``) and links it into its Electron project — see the
|
|
19
|
+
README — which also makes an editable install live-editable.
|
|
20
|
+
|
|
21
|
+
The peer dependencies (react, electron, electron-updater, @playwright/test) are
|
|
22
|
+
the app's to declare; every app already does.
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
TARGETS = ("main", "preload", "renderer", "testing")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def path(target: str | None = None) -> Path:
|
|
32
|
+
"""The directory holding the TypeScript, or one target's folder."""
|
|
33
|
+
root = Path(__file__).resolve().parent
|
|
34
|
+
if target is None:
|
|
35
|
+
return root
|
|
36
|
+
if target not in TARGETS:
|
|
37
|
+
raise ValueError(f"unknown shell target {target!r}; one of {TARGETS}")
|
|
38
|
+
return root / target
|
de_shell/js/__main__.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* backendProcess.test.ts — node:test unit tests for the sidecar spawn-error path.
|
|
3
|
+
*
|
|
4
|
+
* Guards the `spawn uv ENOENT` trap: a backend command that does not exist must
|
|
5
|
+
* surface a READABLE stream line and a synthetic `backend_exited` message — not
|
|
6
|
+
* crash the Electron main process with an unhandled ChildProcess 'error' event,
|
|
7
|
+
* which is exactly what an unlisted 'error' emitter does.
|
|
8
|
+
*
|
|
9
|
+
* Run: `node --test src/backendProcess.test.ts` (from packages/shell-main/), or
|
|
10
|
+
* via the `test:unit` npm script.
|
|
11
|
+
*/
|
|
12
|
+
import { test } from 'node:test'
|
|
13
|
+
import assert from 'node:assert/strict'
|
|
14
|
+
import { configureShell } from './config.ts'
|
|
15
|
+
import { startBackend, stopBackend } from './backendProcess.ts'
|
|
16
|
+
|
|
17
|
+
configureShell({
|
|
18
|
+
appId: 'testapp',
|
|
19
|
+
appName: 'Test App',
|
|
20
|
+
pythonModule: 'testapp',
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
function collectBackend(cmd: string[]) {
|
|
24
|
+
const streams: string[] = []
|
|
25
|
+
const messages: Array<Record<string, unknown>> = []
|
|
26
|
+
startBackend(cmd, {
|
|
27
|
+
onMessage: (m) => messages.push(m),
|
|
28
|
+
onStream: (t) => streams.push(t),
|
|
29
|
+
})
|
|
30
|
+
return { streams, messages }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function waitFor(pred: () => boolean, ms = 5000): Promise<void> {
|
|
34
|
+
const t0 = Date.now()
|
|
35
|
+
while (!pred()) {
|
|
36
|
+
if (Date.now() - t0 > ms) throw new Error('timed out waiting for condition')
|
|
37
|
+
await new Promise((r) => setTimeout(r, 25))
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
test('a nonexistent backend command reports readably instead of crashing', async () => {
|
|
42
|
+
const { streams, messages } = collectBackend(['definitely-not-a-real-command-xyz'])
|
|
43
|
+
try {
|
|
44
|
+
await waitFor(() => messages.some((m) => m.type === 'backend_exited'))
|
|
45
|
+
} finally {
|
|
46
|
+
stopBackend()
|
|
47
|
+
}
|
|
48
|
+
const text = streams.join('')
|
|
49
|
+
// The failing command is named — "something went wrong" is not a report.
|
|
50
|
+
assert.match(text, /definitely-not-a-real-command-xyz/)
|
|
51
|
+
// And the cause is stated in words, not only an errno.
|
|
52
|
+
assert.match(text, /not found|could not be started/i)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('a missing uv names uv and says how to fix it', async () => {
|
|
56
|
+
// The dev-mode backend command is `uv run …`; when uv is absent the operator
|
|
57
|
+
// needs "install uv / put it on PATH", not a bare ENOENT stack.
|
|
58
|
+
const { streams, messages } = collectBackend([
|
|
59
|
+
'uv-definitely-not-installed-xyz.exe', 'run', 'python', '-m', 'testapp',
|
|
60
|
+
])
|
|
61
|
+
try {
|
|
62
|
+
await waitFor(() => messages.some((m) => m.type === 'backend_exited'))
|
|
63
|
+
} finally {
|
|
64
|
+
stopBackend()
|
|
65
|
+
}
|
|
66
|
+
const exited = messages.find((m) => m.type === 'backend_exited')
|
|
67
|
+
assert.ok(exited, 'no backend_exited message arrived')
|
|
68
|
+
const text = streams.join('')
|
|
69
|
+
assert.match(text, /PATH/i)
|
|
70
|
+
})
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* backendProcess.ts — the Python sidecar process manager.
|
|
3
|
+
*
|
|
4
|
+
* Spawns the resolved backend command (see pythonEnv.ts) and maintains
|
|
5
|
+
* the bidirectional PLOTAPP: JSON protocol over stdin/stdout.
|
|
6
|
+
*/
|
|
7
|
+
import { spawn, spawnSync, ChildProcess } from 'child_process'
|
|
8
|
+
import process from 'process'
|
|
9
|
+
// Extension spelled out so node:test can load this module without a bundler
|
|
10
|
+
// (native type-stripping resolves relative imports literally).
|
|
11
|
+
import { shellConfig } from './config.ts'
|
|
12
|
+
|
|
13
|
+
export interface BackendHandlers {
|
|
14
|
+
onMessage: (msg: Record<string, unknown>) => void
|
|
15
|
+
onStream: (text: string, kind: 'stdout' | 'stderr') => void
|
|
16
|
+
// A raw PLOTBIN binary frame: the decoded header (fig_id/key/dims/…) plus the
|
|
17
|
+
// raw pixel bytes (NOT base64). Forwarded to the renderer as a transferable
|
|
18
|
+
// ArrayBuffer so large image frames skip the base64/JSON/atob cost.
|
|
19
|
+
onBinary?: (header: Record<string, unknown>, payload: Buffer) => void
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const PLOTBIN = Buffer.from('PLOTBIN:')
|
|
23
|
+
const NL = 0x0a
|
|
24
|
+
|
|
25
|
+
let proc: ChildProcess | null = null
|
|
26
|
+
let tickTimer: ReturnType<typeof setInterval> | null = null
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The sidecar's last few hundred output lines, for a problem report.
|
|
30
|
+
*
|
|
31
|
+
* The renderer's log panel has the same text, but it lives in a window that a
|
|
32
|
+
* crash may have taken down, and it is not reachable from the main process
|
|
33
|
+
* where a report is assembled. Bounded so a chatty run cannot grow it without
|
|
34
|
+
* limit; long lines are clipped because a report is meant to be read.
|
|
35
|
+
*/
|
|
36
|
+
const MAX_OUTPUT_LINES = 300
|
|
37
|
+
const MAX_OUTPUT_LINE_CHARS = 1000
|
|
38
|
+
const backendOutput: string[] = []
|
|
39
|
+
|
|
40
|
+
function rememberOutput(text: string): void {
|
|
41
|
+
for (const line of text.split('\n')) {
|
|
42
|
+
if (!line.trim()) continue
|
|
43
|
+
backendOutput.push(line.slice(0, MAX_OUTPUT_LINE_CHARS))
|
|
44
|
+
}
|
|
45
|
+
if (backendOutput.length > MAX_OUTPUT_LINES) {
|
|
46
|
+
backendOutput.splice(0, backendOutput.length - MAX_OUTPUT_LINES)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The sidecar's recent output, oldest first. */
|
|
51
|
+
export function recentBackendOutput(): string[] {
|
|
52
|
+
return [...backendOutput]
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function startBackend(
|
|
56
|
+
pythonCmd: string[],
|
|
57
|
+
handlers: BackendHandlers,
|
|
58
|
+
cwd?: string,
|
|
59
|
+
): void {
|
|
60
|
+
if (proc) {
|
|
61
|
+
// Two live backends would both write the protocol channel and neither
|
|
62
|
+
// would be the one stopBackend() knows about. Stop the first explicitly.
|
|
63
|
+
throw new Error('startBackend: the backend is already running')
|
|
64
|
+
}
|
|
65
|
+
stopping = false // fresh process — allow a future stopBackend() to run
|
|
66
|
+
const [cmd, ...args] = pythonCmd
|
|
67
|
+
const child = spawn(cmd, args, {
|
|
68
|
+
cwd, // run from the project root so `uv run` finds the app's pyproject.toml
|
|
69
|
+
// APL_BINARY_TRANSPORT=1: anyplotlib ships large image pixels as raw PLOTBIN
|
|
70
|
+
// binary frames (no base64/JSON) which this runner demuxes — see the stdout
|
|
71
|
+
// parser below. Verified end-to-end (pixel-correct via GPU readback); cuts the
|
|
72
|
+
// ~200 ms/frame base64+JSON+atob transport on a 4k movie. Set to "0" to force
|
|
73
|
+
// the base64 fallback.
|
|
74
|
+
env: {
|
|
75
|
+
...process.env, PYTHONUNBUFFERED: '1',
|
|
76
|
+
APL_BINARY_TRANSPORT: process.env.APL_BINARY_TRANSPORT ?? '1',
|
|
77
|
+
},
|
|
78
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
79
|
+
})
|
|
80
|
+
proc = child
|
|
81
|
+
|
|
82
|
+
// BACKEND TICK (0.5 Hz): Windows throttles timer delivery to the hidden
|
|
83
|
+
// Python child so aggressively that its timer waits (time.sleep,
|
|
84
|
+
// Event.wait, event-loop timers — incl. dask's task-delivery flushes) can
|
|
85
|
+
// freeze INDEFINITELY, waking only when process I/O arrives. Measured
|
|
86
|
+
// end-to-end (SpyDE: spyde/tests/repro_batch_stall.py + _probe_fv_stall.spec.ts):
|
|
87
|
+
// distributed computes sat idle forever hands-off, and EVERY unstick
|
|
88
|
+
// followed a stdin message within ~4 s — a user click "fixing" it was this
|
|
89
|
+
// pipe write, not the click. Electron's own timers are healthy (foreground
|
|
90
|
+
// app), so this interval is reliable; the backend handles 'tick' as a
|
|
91
|
+
// silent no-op. Two lines of traffic per second, bounded staleness ~6 s.
|
|
92
|
+
if (tickTimer) clearInterval(tickTimer)
|
|
93
|
+
tickTimer = setInterval(() => {
|
|
94
|
+
try { sendAction('tick') } catch { /* backend gone — stop ticking */ }
|
|
95
|
+
if (!proc && tickTimer) { clearInterval(tickTimer); tickTimer = null }
|
|
96
|
+
}, 2000)
|
|
97
|
+
|
|
98
|
+
// Custom stdout demuxer: the stream interleaves text lines (PLOTAPP: JSON and
|
|
99
|
+
// plain log output, both '\n'-terminated) with raw PLOTBIN binary frames
|
|
100
|
+
// (PLOTBIN:<hlen>:<plen>\n<header_json><payload>). readline can't carry binary,
|
|
101
|
+
// so we parse the raw Buffer stream ourselves, accumulating partial reads.
|
|
102
|
+
let acc: Buffer = Buffer.alloc(0)
|
|
103
|
+
child.stdout!.on('data', (chunk: Buffer) => {
|
|
104
|
+
acc = acc.length ? Buffer.concat([acc, chunk]) : chunk
|
|
105
|
+
// Process as many complete units as are buffered; stop when we need more.
|
|
106
|
+
for (;;) {
|
|
107
|
+
if (acc.length === 0) break
|
|
108
|
+
// A binary frame if the buffer starts with the PLOTBIN marker.
|
|
109
|
+
if (acc.length >= PLOTBIN.length &&
|
|
110
|
+
acc.subarray(0, PLOTBIN.length).equals(PLOTBIN)) {
|
|
111
|
+
const nl = acc.indexOf(NL)
|
|
112
|
+
if (nl < 0) break // prefix line incomplete
|
|
113
|
+
const prefix = acc.subarray(PLOTBIN.length, nl).toString('ascii')
|
|
114
|
+
const [hlenS, plenS] = prefix.split(':')
|
|
115
|
+
const hlen = parseInt(hlenS, 10), plen = parseInt(plenS, 10)
|
|
116
|
+
if (!(hlen >= 0) || !(plen >= 0)) { // malformed → drop the line
|
|
117
|
+
handlers.onStream(`[sidecar protocol] malformed PLOTBIN prefix: ${prefix}\n`, 'stderr')
|
|
118
|
+
acc = acc.subarray(nl + 1); continue
|
|
119
|
+
}
|
|
120
|
+
const bodyStart = nl + 1
|
|
121
|
+
const end = bodyStart + hlen + plen
|
|
122
|
+
if (acc.length < end) break // body not fully arrived yet
|
|
123
|
+
let header: Record<string, unknown> = {}
|
|
124
|
+
try {
|
|
125
|
+
header = JSON.parse(acc.subarray(bodyStart, bodyStart + hlen).toString('utf8'))
|
|
126
|
+
} catch { /* malformed header — still consume the frame */ }
|
|
127
|
+
// Copy the payload out so it survives `acc` being sliced/reused.
|
|
128
|
+
const payload = Buffer.from(acc.subarray(bodyStart + hlen, end))
|
|
129
|
+
acc = acc.subarray(end)
|
|
130
|
+
try { handlers.onBinary?.(header, payload) } catch { /* ignore */ }
|
|
131
|
+
continue
|
|
132
|
+
}
|
|
133
|
+
// Otherwise a text line up to the next '\n'.
|
|
134
|
+
const nl = acc.indexOf(NL)
|
|
135
|
+
if (nl < 0) break // line incomplete
|
|
136
|
+
const line = acc.subarray(0, nl).toString('utf8')
|
|
137
|
+
acc = acc.subarray(nl + 1)
|
|
138
|
+
if (line.startsWith('PLOTAPP:')) {
|
|
139
|
+
try {
|
|
140
|
+
handlers.onMessage(JSON.parse(line.slice(8)) as Record<string, unknown>)
|
|
141
|
+
} catch {
|
|
142
|
+
// Say so rather than swallow it: a truncated frame is how a backend
|
|
143
|
+
// bug presents, and silence turns it into "the UI just stopped".
|
|
144
|
+
handlers.onStream(`[sidecar protocol] malformed JSON message: ${line.slice(0, 200)}\n`, 'stderr')
|
|
145
|
+
}
|
|
146
|
+
} else if (line.trim()) {
|
|
147
|
+
rememberOutput(line)
|
|
148
|
+
handlers.onStream(line + '\n', 'stdout')
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
child.stderr!.on('data', (d: Buffer) => {
|
|
154
|
+
rememberOutput(d.toString())
|
|
155
|
+
handlers.onStream(d.toString(), 'stderr')
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
// SPAWN-ERROR TRAP: a command that cannot be started (uv missing from PATH is
|
|
159
|
+
// the recurring case) emits 'error' on the ChildProcess — and an 'error' event
|
|
160
|
+
// with no listener CRASHES the Electron main process. 'close' never fires for
|
|
161
|
+
// a failed spawn, so this is the only place the failure can be reported.
|
|
162
|
+
child.on('error', (err: NodeJS.ErrnoException) => {
|
|
163
|
+
if (proc === child) proc = null
|
|
164
|
+
if (tickTimer) { clearInterval(tickTimer); tickTimer = null }
|
|
165
|
+
const enoent = err.code === 'ENOENT'
|
|
166
|
+
handlers.onStream(
|
|
167
|
+
`[${shellConfig().appName}] backend command could not be started: ` +
|
|
168
|
+
`${cmd} (${err.code ?? err.message})` +
|
|
169
|
+
(enoent ? ` — ${cmd} was not found. Is it installed and on PATH?` : '') +
|
|
170
|
+
'\n',
|
|
171
|
+
'stderr')
|
|
172
|
+
handlers.onMessage({ type: 'backend_exited', code: null, error: String(err.message ?? err) })
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
child.on('close', (code) => {
|
|
176
|
+
// Only forget THIS child. After stopBackend() + startBackend(), the old
|
|
177
|
+
// process's late close event must not null the new backend's handle —
|
|
178
|
+
// every sendAction() after that would silently no-op.
|
|
179
|
+
if (proc === child) proc = null
|
|
180
|
+
rememberOutput(`[exited with code ${code}]`)
|
|
181
|
+
handlers.onStream(`[${shellConfig().appName} exited with code ${code}]\n`, 'stderr')
|
|
182
|
+
// Surface the death to the renderer so the UI doesn't silently freeze —
|
|
183
|
+
// every sendAction() after this no-ops (proc is null), so without this the
|
|
184
|
+
// user gets no indication the analysis backend stopped. Routed through the
|
|
185
|
+
// same onMessage path as a synthetic message (not a PLOTAPP: line).
|
|
186
|
+
handlers.onMessage({ type: 'backend_exited', code })
|
|
187
|
+
})
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Send a JSON action message to the Python backend. */
|
|
191
|
+
export function sendAction(
|
|
192
|
+
action: string,
|
|
193
|
+
payload: Record<string, unknown> = {},
|
|
194
|
+
windowId?: number,
|
|
195
|
+
): void {
|
|
196
|
+
if (!proc?.stdin) return
|
|
197
|
+
const msg: Record<string, unknown> = { type: 'action', action, payload }
|
|
198
|
+
if (windowId !== undefined) msg.window_id = windowId
|
|
199
|
+
proc.stdin.write(JSON.stringify(msg) + '\n')
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Forward a figure interaction event back to Python. */
|
|
203
|
+
export function sendFigureEvent(figId: string, eventJson: string): void {
|
|
204
|
+
if (!proc?.stdin) return
|
|
205
|
+
proc.stdin.write(JSON.stringify({ type: 'figure_event', fig_id: figId, event_json: eventJson }) + '\n')
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Notify Python that a figure's container resized. */
|
|
209
|
+
export function sendResize(figId: string, width: number, height: number): void {
|
|
210
|
+
if (!proc?.stdin) return
|
|
211
|
+
proc.stdin.write(JSON.stringify({ type: 'resize', fig_id: figId, width, height }) + '\n')
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Stop the Python backend, leaving NO orphaned worker subprocesses.
|
|
216
|
+
*
|
|
217
|
+
* Strategy:
|
|
218
|
+
* 1. GRACEFUL: write `{type:'quit'}` to stdin. The backend's asyncio loop
|
|
219
|
+
* (app.py) handles this by breaking and calling `session.shutdown()`, which
|
|
220
|
+
* tears down the Dask cluster cleanly.
|
|
221
|
+
* 2. BACKSTOP TREE-KILL: the backend may not exit promptly (mid-compute) or
|
|
222
|
+
* stdin may already be closed, and `proc.kill()` on Windows only kills the
|
|
223
|
+
* DIRECT child — leaving the Dask worker/nanny GRANDCHILDREN orphaned. So
|
|
224
|
+
* after a short grace period we kill the whole tree:
|
|
225
|
+
* - win32: `taskkill /pid <pid> /T /F` (whole tree, force).
|
|
226
|
+
* - posix: SIGTERM, then SIGKILL after a short timer.
|
|
227
|
+
*
|
|
228
|
+
* This composes with the PYTHON-side process_guard.py: that installs a Windows
|
|
229
|
+
* kill-on-close Job Object so the OS reaps the worker tree whenever the backend
|
|
230
|
+
* process itself dies for ANY reason (clean exit, crash, or our taskkill). The
|
|
231
|
+
* graceful quit here is the preferred path (clean cluster shutdown); the
|
|
232
|
+
* tree-kill is the backstop for when Electron must hard-stop the backend before
|
|
233
|
+
* it can reach its own shutdown(). Both ultimately guarantee no leaked workers.
|
|
234
|
+
*
|
|
235
|
+
* Idempotent and null-safe: callable from window-all-closed, before-quit, and a
|
|
236
|
+
* signal handler without double-killing.
|
|
237
|
+
*
|
|
238
|
+
* `immediate` skips the grace period and tree-kills BEFORE returning. Step 2's
|
|
239
|
+
* timers only fire while this process is still alive, so a caller that is about
|
|
240
|
+
* to end the process (the update handoff — see updater.ts) would otherwise leave
|
|
241
|
+
* the sidecar and its Dask workers running: the graceful `quit` is written, the
|
|
242
|
+
* timer is armed, and Electron exits before it can fire. The Windows installer
|
|
243
|
+
* that starts moments later then finds processes still holding the install
|
|
244
|
+
* directory and refuses to continue.
|
|
245
|
+
*/
|
|
246
|
+
let stopping = false
|
|
247
|
+
export function stopBackend(options: { immediate?: boolean } = {}): void {
|
|
248
|
+
const p = proc
|
|
249
|
+
if (!p || stopping) {
|
|
250
|
+
proc = null
|
|
251
|
+
return
|
|
252
|
+
}
|
|
253
|
+
stopping = true
|
|
254
|
+
proc = null // every sendAction() after this no-ops; prevents re-entrant kills
|
|
255
|
+
// KNOWN BUG (open): quitting while a find-vectors batch is still streaming can
|
|
256
|
+
// WEDGE shutdown on Windows. Clearing the tick timer here stops the stdin tick
|
|
257
|
+
// that keeps the hidden backend scheduled (the very starvation the tick was
|
|
258
|
+
// added for), so a mid-batch backend may never get scheduled long enough to
|
|
259
|
+
// process the graceful `quit` — only the 1.5 s taskkill backstop ends it. The
|
|
260
|
+
// e2e specs work around it by waiting for the '[fv-batch] finalized' log line
|
|
261
|
+
// before closing the app. The app-side fix (keep ticking until the backend
|
|
262
|
+
// exits, or force-reap the batch) is still open.
|
|
263
|
+
if (tickTimer) { clearInterval(tickTimer); tickTimer = null }
|
|
264
|
+
|
|
265
|
+
// 1. Ask the backend to quit gracefully (clean Dask shutdown).
|
|
266
|
+
try {
|
|
267
|
+
if (p.stdin && p.stdin.writable) {
|
|
268
|
+
p.stdin.write(JSON.stringify({ type: 'quit' }) + '\n')
|
|
269
|
+
}
|
|
270
|
+
} catch { /* stdin may already be torn down — fall through to tree-kill */ }
|
|
271
|
+
|
|
272
|
+
const pid = p.pid
|
|
273
|
+
|
|
274
|
+
// 2. Backstop: if it hasn't exited shortly, kill the whole process tree so no
|
|
275
|
+
// Dask worker/nanny grandchildren are left behind.
|
|
276
|
+
if (options.immediate) {
|
|
277
|
+
killTreeNow(p, pid)
|
|
278
|
+
return
|
|
279
|
+
}
|
|
280
|
+
if (process.platform === 'win32') {
|
|
281
|
+
if (pid !== undefined) {
|
|
282
|
+
setTimeout(() => {
|
|
283
|
+
if (p.exitCode !== null || p.signalCode !== null) return // already gone
|
|
284
|
+
try {
|
|
285
|
+
spawn('taskkill', ['/pid', String(pid), '/T', '/F'], { stdio: 'ignore' })
|
|
286
|
+
} catch { try { p.kill() } catch { /* nothing else to do */ } }
|
|
287
|
+
// 5 s, not 1.5: Ground Crew's shutdown must send deapi's disconnect
|
|
288
|
+
// before dying — a taskkilled backend leaves a dead client session on
|
|
289
|
+
// the DE Server, which outlives the app. (Vendored change — hand-sync
|
|
290
|
+
// to the monorepo, where 1.5 s was sized for Dask teardown only.)
|
|
291
|
+
}, 5000)
|
|
292
|
+
} else {
|
|
293
|
+
try { p.kill() } catch { /* */ }
|
|
294
|
+
}
|
|
295
|
+
} else {
|
|
296
|
+
setTimeout(() => {
|
|
297
|
+
if (p.exitCode !== null || p.signalCode !== null) return
|
|
298
|
+
try { p.kill('SIGTERM') } catch { /* */ }
|
|
299
|
+
setTimeout(() => {
|
|
300
|
+
if (p.exitCode !== null || p.signalCode !== null) return
|
|
301
|
+
try { p.kill('SIGKILL') } catch { /* */ }
|
|
302
|
+
}, 1500)
|
|
303
|
+
}, 1500)
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Kill the sidecar's whole process tree and return once the request has been
|
|
309
|
+
* made — no timers, so it still runs when the caller is about to end this
|
|
310
|
+
* process. `spawnSync` is the point: a detached `spawn` would only be queued.
|
|
311
|
+
*/
|
|
312
|
+
function killTreeNow(p: ChildProcess, pid: number | undefined): void {
|
|
313
|
+
if (p.exitCode !== null || p.signalCode !== null) return
|
|
314
|
+
if (process.platform === 'win32') {
|
|
315
|
+
if (pid !== undefined) {
|
|
316
|
+
try {
|
|
317
|
+
spawnSync('taskkill', ['/pid', String(pid), '/T', '/F'], { stdio: 'ignore' })
|
|
318
|
+
return
|
|
319
|
+
} catch { /* fall through to the direct kill */ }
|
|
320
|
+
}
|
|
321
|
+
try { p.kill() } catch { /* nothing else to do */ }
|
|
322
|
+
return
|
|
323
|
+
}
|
|
324
|
+
try { p.kill('SIGKILL') } catch { /* nothing else to do */ }
|
|
325
|
+
// The workers are grandchildren, so also target the child's process group
|
|
326
|
+
// when it leads one. A pid that leads no group simply has no group to match.
|
|
327
|
+
if (pid !== undefined) {
|
|
328
|
+
try { process.kill(-pid, 'SIGKILL') } catch { /* no such group */ }
|
|
329
|
+
}
|
|
330
|
+
}
|