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
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])
@@ -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
@@ -0,0 +1,4 @@
1
+ """``python -m de_shell.js`` prints where the TypeScript is, for build scripts."""
2
+ from . import path
3
+
4
+ print(path())
@@ -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
+ }