de-shell 0.2.0__tar.gz → 0.2.1__tar.gz

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 (71) hide show
  1. {de_shell-0.2.0 → de_shell-0.2.1}/PKG-INFO +13 -12
  2. {de_shell-0.2.0 → de_shell-0.2.1}/README.md +7 -6
  3. {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/__init__.py +1 -1
  4. {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/js/main/backendProcess.ts +14 -56
  5. de_shell-0.2.1/de_shell/js/main/stdoutDemux.test.ts +195 -0
  6. de_shell-0.2.1/de_shell/js/main/stdoutDemux.ts +167 -0
  7. {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/js/renderer/FigureFrame.tsx +26 -15
  8. {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/js/renderer/figureBridge.test.ts +64 -1
  9. {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/js/renderer/figureBridge.ts +33 -0
  10. de_shell-0.2.1/de_shell/js/renderer/sizeReporter.test.ts +76 -0
  11. de_shell-0.2.1/de_shell/js/renderer/sizeReporter.ts +29 -0
  12. {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/plotting/figure.py +32 -1
  13. {de_shell-0.2.0 → de_shell-0.2.1}/de_shell.egg-info/PKG-INFO +13 -12
  14. {de_shell-0.2.0 → de_shell-0.2.1}/de_shell.egg-info/SOURCES.txt +4 -0
  15. {de_shell-0.2.0 → de_shell-0.2.1}/de_shell.egg-info/requires.txt +1 -1
  16. {de_shell-0.2.0 → de_shell-0.2.1}/pyproject.toml +11 -5
  17. {de_shell-0.2.0 → de_shell-0.2.1}/LICENSE +0 -0
  18. {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/actions/__init__.py +0 -0
  19. {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/actions/context.py +0 -0
  20. {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/actions/figure_registry.py +0 -0
  21. {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/actions/lifecycle.py +0 -0
  22. {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/actions/registry.py +0 -0
  23. {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/actions/wizard.py +0 -0
  24. {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/app.py +0 -0
  25. {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/compute.py +0 -0
  26. {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/debug_flags.py +0 -0
  27. {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/ipc.py +0 -0
  28. {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/js/__init__.py +0 -0
  29. {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/js/__main__.py +0 -0
  30. {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/js/main/backendProcess.test.ts +0 -0
  31. {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/js/main/config.ts +0 -0
  32. {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/js/main/dialogs.ts +0 -0
  33. {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/js/main/envProgress.ts +0 -0
  34. {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/js/main/errorReport.ts +0 -0
  35. {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/js/main/index.ts +0 -0
  36. {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/js/main/problemLog.ts +0 -0
  37. {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/js/main/pythonEnv.test.ts +0 -0
  38. {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/js/main/pythonEnv.ts +0 -0
  39. {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/js/main/sentryEnvelope.test.ts +0 -0
  40. {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/js/main/sentryEnvelope.ts +0 -0
  41. {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/js/main/updater.ts +0 -0
  42. {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/js/main/updaterErrors.test.ts +0 -0
  43. {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/js/main/updaterErrors.ts +0 -0
  44. {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/js/main/window.ts +0 -0
  45. {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/js/package.json +0 -0
  46. {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/js/preload/index.ts +0 -0
  47. {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/js/renderer/figureBridge.react.ts +0 -0
  48. {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/js/renderer/index.ts +0 -0
  49. {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/js/renderer/protocol.ts +0 -0
  50. {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/js/renderer/shellState.test.ts +0 -0
  51. {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/js/renderer/shellState.ts +0 -0
  52. {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/js/testing/harness.cjs +0 -0
  53. {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/js/testing/harness.test.cjs +0 -0
  54. {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/log_stream.py +0 -0
  55. {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/plotting/__init__.py +0 -0
  56. {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/plotting/colormaps.py +0 -0
  57. {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/plotting/selectors/__init__.py +0 -0
  58. {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/plotting/selectors/utils.py +0 -0
  59. {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/plotting/stream.py +0 -0
  60. {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/process_guard.py +0 -0
  61. {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/session.py +0 -0
  62. {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/testing/__init__.py +0 -0
  63. {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/timing.py +0 -0
  64. {de_shell-0.2.0 → de_shell-0.2.1}/de_shell.egg-info/dependency_links.txt +0 -0
  65. {de_shell-0.2.0 → de_shell-0.2.1}/de_shell.egg-info/top_level.txt +0 -0
  66. {de_shell-0.2.0 → de_shell-0.2.1}/setup.cfg +0 -0
  67. {de_shell-0.2.0 → de_shell-0.2.1}/tests/test_actions.py +0 -0
  68. {de_shell-0.2.0 → de_shell-0.2.1}/tests/test_boundary.py +0 -0
  69. {de_shell-0.2.0 → de_shell-0.2.1}/tests/test_figure.py +0 -0
  70. {de_shell-0.2.0 → de_shell-0.2.1}/tests/test_session_base.py +0 -0
  71. {de_shell-0.2.0 → de_shell-0.2.1}/tests/test_stream.py +0 -0
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: de-shell
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: The Electron + Python desktop-app shell Direct Electron's apps (SpyDE, Ground Crew, Autopilot) are assembled from
5
5
  Author-email: Carter Francis <cartsfrancis@gmail.com>
6
6
  License: MIT
7
- Project-URL: Homepage, https://github.com/CSSFrancis/de-shell
8
- Project-URL: Repository, https://github.com/CSSFrancis/de-shell
9
- Project-URL: Issues, https://github.com/CSSFrancis/de-shell/issues
10
- Project-URL: Changelog, https://github.com/CSSFrancis/de-shell/blob/main/CHANGELOG.md
7
+ Project-URL: Homepage, https://github.com/directelectron/de-shell
8
+ Project-URL: Repository, https://github.com/directelectron/de-shell
9
+ Project-URL: Issues, https://github.com/directelectron/de-shell/issues
10
+ Project-URL: Changelog, https://github.com/directelectron/de-shell/blob/main/CHANGELOG.md
11
11
  Keywords: electron,desktop,anyplotlib,electron-microscopy,direct-electron
12
12
  Classifier: Development Status :: 3 - Alpha
13
13
  Classifier: Intended Audience :: Developers
@@ -26,7 +26,7 @@ Requires-Python: >=3.10
26
26
  Description-Content-Type: text/markdown
27
27
  License-File: LICENSE
28
28
  Requires-Dist: numpy>=1.20.0
29
- Requires-Dist: anyplotlib>=0.7.3
29
+ Requires-Dist: anyplotlib>=0.8.0
30
30
  Requires-Dist: pyyaml
31
31
  Provides-Extra: tests
32
32
  Requires-Dist: pytest>=3.6; extra == "tests"
@@ -36,7 +36,7 @@ Dynamic: license-file
36
36
  # DE Shell
37
37
 
38
38
  [![PyPI](https://img.shields.io/pypi/v/de-shell.svg)](https://pypi.org/project/de-shell/)
39
- [![CI](https://github.com/CSSFrancis/de-shell/actions/workflows/ci.yml/badge.svg)](https://github.com/CSSFrancis/de-shell/actions/workflows/ci.yml)
39
+ [![CI](https://github.com/directelectron/de-shell/actions/workflows/ci.yml/badge.svg)](https://github.com/directelectron/de-shell/actions/workflows/ci.yml)
40
40
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
41
41
 
42
42
  The substrate Direct Electron's desktop apps are assembled from: **SpyDE**
@@ -122,8 +122,9 @@ peer dependencies — react, electron, electron-updater, @playwright/test —
122
122
  are the app's to declare; every app already does. The e2e specs take the
123
123
  harness from `shell/testing/harness.cjs`.
124
124
 
125
- Autopilot and SpyDE (on its `chore/de-shell-split` branch) are wired this
126
- way; Ground Crew still carries a copy under its `packages/` and is next.
125
+ Autopilot and SpyDE ([directelectron/spyde#152](https://github.com/directelectron/spyde/pull/152))
126
+ are wired this way, pinned `de-shell>=0.2,<0.3`; Ground Crew still carries a
127
+ copy under its `packages/` and is next.
127
128
 
128
129
  ## Developing it
129
130
 
@@ -175,9 +176,9 @@ SpyDE commit the app copies were taken from:
175
176
  panes on large stills), JSON emit that never writes bare `NaN`, the harness
176
177
  hardening, and the unit tests for all of it.
177
178
 
178
- Not yet included: Ground Crew's `dev/instrument-actions` shell deltas
179
- (`stdoutDemux`, `sizeReporter`, the `frameBytes` transport) — they ride a
180
- branch that has not merged.
179
+ * Ground Crew `main` @ 0382549 (0.2.1): the chunk-list stdout demuxer, the
180
+ size reporter and StrictMode-safe registration in `FigureFrame`, the figure
181
+ document's scroll pin, and the anyplotlib 0.8.0 floor.
181
182
 
182
183
  ## Rules
183
184
 
@@ -1,7 +1,7 @@
1
1
  # DE Shell
2
2
 
3
3
  [![PyPI](https://img.shields.io/pypi/v/de-shell.svg)](https://pypi.org/project/de-shell/)
4
- [![CI](https://github.com/CSSFrancis/de-shell/actions/workflows/ci.yml/badge.svg)](https://github.com/CSSFrancis/de-shell/actions/workflows/ci.yml)
4
+ [![CI](https://github.com/directelectron/de-shell/actions/workflows/ci.yml/badge.svg)](https://github.com/directelectron/de-shell/actions/workflows/ci.yml)
5
5
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
6
6
 
7
7
  The substrate Direct Electron's desktop apps are assembled from: **SpyDE**
@@ -87,8 +87,9 @@ peer dependencies — react, electron, electron-updater, @playwright/test —
87
87
  are the app's to declare; every app already does. The e2e specs take the
88
88
  harness from `shell/testing/harness.cjs`.
89
89
 
90
- Autopilot and SpyDE (on its `chore/de-shell-split` branch) are wired this
91
- way; Ground Crew still carries a copy under its `packages/` and is next.
90
+ Autopilot and SpyDE ([directelectron/spyde#152](https://github.com/directelectron/spyde/pull/152))
91
+ are wired this way, pinned `de-shell>=0.2,<0.3`; Ground Crew still carries a
92
+ copy under its `packages/` and is next.
92
93
 
93
94
  ## Developing it
94
95
 
@@ -140,9 +141,9 @@ SpyDE commit the app copies were taken from:
140
141
  panes on large stills), JSON emit that never writes bare `NaN`, the harness
141
142
  hardening, and the unit tests for all of it.
142
143
 
143
- Not yet included: Ground Crew's `dev/instrument-actions` shell deltas
144
- (`stdoutDemux`, `sizeReporter`, the `frameBytes` transport) — they ride a
145
- branch that has not merged.
144
+ * Ground Crew `main` @ 0382549 (0.2.1): the chunk-list stdout demuxer, the
145
+ size reporter and StrictMode-safe registration in `FigureFrame`, the figure
146
+ document's scroll pin, and the anyplotlib 0.8.0 floor.
146
147
 
147
148
  ## Rules
148
149
 
@@ -20,6 +20,6 @@ from __future__ import annotations
20
20
 
21
21
  #: The package version — the one place it is written. pyproject.toml reads it
22
22
  #: (dynamic version) and the release workflow refuses a tag that disagrees.
23
- __version__ = "0.2.0"
23
+ __version__ = "0.2.1"
24
24
 
25
25
  __all__ = ["ipc", "log_stream", "process_guard", "debug_flags", "compute"]
@@ -9,6 +9,7 @@ import process from 'process'
9
9
  // Extension spelled out so node:test can load this module without a bundler
10
10
  // (native type-stripping resolves relative imports literally).
11
11
  import { shellConfig } from './config.ts'
12
+ import { createStdoutDemux } from './stdoutDemux.ts'
12
13
 
13
14
  export interface BackendHandlers {
14
15
  onMessage: (msg: Record<string, unknown>) => void
@@ -19,9 +20,6 @@ export interface BackendHandlers {
19
20
  onBinary?: (header: Record<string, unknown>, payload: Buffer) => void
20
21
  }
21
22
 
22
- const PLOTBIN = Buffer.from('PLOTBIN:')
23
- const NL = 0x0a
24
-
25
23
  let proc: ChildProcess | null = null
26
24
  let tickTimer: ReturnType<typeof setInterval> | null = null
27
25
 
@@ -95,60 +93,20 @@ export function startBackend(
95
93
  if (!proc && tickTimer) { clearInterval(tickTimer); tickTimer = null }
96
94
  }, 2000)
97
95
 
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
- }
96
+ // Custom stdout demuxer (stdoutDemux.ts): the stream interleaves text lines
97
+ // (PLOTAPP: JSON and plain log output, both '\n'-terminated) with raw PLOTBIN
98
+ // binary frames (PLOTBIN:<hlen>:<plen>\n<header_json><payload>). readline
99
+ // can't carry binary, so the raw Buffer stream is parsed with a chunk-list
100
+ // accumulator — each byte is copied exactly once, into the emitted unit
101
+ // (the old whole-prefix Buffer.concat per chunk was O(N²/chunkSize) while a
102
+ // large frame streamed in: 11.9 s per 64 MB frame at 64 KiB chunks). Plain
103
+ // output is remembered for a problem report on the way past; the demuxer
104
+ // itself says so on a malformed message rather than swallowing it.
105
+ const demux = createStdoutDemux({
106
+ ...handlers,
107
+ onStream: (text, kind) => { rememberOutput(text); handlers.onStream(text, kind) },
151
108
  })
109
+ child.stdout!.on('data', (chunk: Buffer) => demux.push(chunk))
152
110
 
153
111
  child.stderr!.on('data', (d: Buffer) => {
154
112
  rememberOutput(d.toString())
@@ -0,0 +1,195 @@
1
+ /**
2
+ * stdoutDemux.test.ts — node:test unit tests for the backend stdout demuxer.
3
+ *
4
+ * The stream interleaves '\n'-terminated text lines (PLOTAPP: JSON + plain
5
+ * log output) with raw PLOTBIN binary frames
6
+ * (PLOTBIN:<hlen>:<plen>\n<header_json><payload>). The demuxer must produce
7
+ * the SAME event trace regardless of how the OS slices the stream into
8
+ * 'data' chunks — Windows delivers a single large child-stdout write as
9
+ * uniform 64 KiB chunks (measured 2026-08-15), and chunk boundaries can land
10
+ * mid-marker, mid-prefix, mid-header or mid-payload.
11
+ *
12
+ * Also pins the accumulator's cost shape: a 16 MB frame arriving as 64 KiB
13
+ * chunks must demux in linear time. The pre-fix Buffer.concat accumulator
14
+ * recopied the whole prefix per chunk — O(N²/chunk): measured 813 ms at
15
+ * 16 MB, 11.9 s at 64 MB (notes/runs/2026-08-15-perf-review-measurements.md
16
+ * §1) — so the 250 ms budget is ~3× under the old cost and ~38× over the
17
+ * chunk-list cost (6.6 ms measured); generous against CI timer noise on
18
+ * both sides.
19
+ *
20
+ * Run: `node --test src/stdoutDemux.test.ts` (from packages/shell-main/),
21
+ * or via the `test:unit` npm script.
22
+ */
23
+ import { test } from 'node:test'
24
+ import assert from 'node:assert/strict'
25
+ import { createStdoutDemux } from './stdoutDemux.ts'
26
+
27
+ type Event =
28
+ | { kind: 'message'; msg: Record<string, unknown> }
29
+ | { kind: 'stream'; text: string }
30
+ | { kind: 'binary'; header: Record<string, unknown>; payload: string }
31
+
32
+ function collector() {
33
+ const events: Event[] = []
34
+ const demux = createStdoutDemux({
35
+ onMessage: (msg) => events.push({ kind: 'message', msg }),
36
+ onStream: (text) => events.push({ kind: 'stream', text }),
37
+ onBinary: (header, payload) =>
38
+ events.push({ kind: 'binary', header, payload: payload.toString('hex') }),
39
+ })
40
+ return { events, demux }
41
+ }
42
+
43
+ function frame(header: Record<string, unknown>, payload: Buffer): Buffer {
44
+ const h = Buffer.from(JSON.stringify(header), 'utf8')
45
+ return Buffer.concat([
46
+ Buffer.from(`PLOTBIN:${h.length}:${payload.length}\n`, 'ascii'),
47
+ h,
48
+ payload,
49
+ ])
50
+ }
51
+
52
+ /** Feed `stream` in slices of `sizes` (cycled); return the event trace. */
53
+ function run(stream: Buffer, sizes: number[]): Event[] {
54
+ const { events, demux } = collector()
55
+ let pos = 0
56
+ let i = 0
57
+ while (pos < stream.length) {
58
+ const n = Math.min(sizes[i % sizes.length], stream.length - pos)
59
+ demux.push(stream.subarray(pos, pos + n))
60
+ pos += n
61
+ i++
62
+ }
63
+ return events
64
+ }
65
+
66
+ function patternPayload(n: number): Buffer {
67
+ const p = Buffer.allocUnsafe(n)
68
+ for (let i = 0; i < n; i++) p[i] = i & 0xff // includes 0x0a bytes
69
+ return p
70
+ }
71
+
72
+ // A representative interleaved stream: log line, PLOTAPP message, a binary
73
+ // frame whose payload contains newline bytes and marker-lookalike bytes,
74
+ // another log line, a second frame back-to-back with a third, final message.
75
+ function interleavedStream(): Buffer {
76
+ const nasty = Buffer.concat([
77
+ Buffer.from('\nPLOTBIN:9:9\nPLOTAPP:{}\n', 'ascii'), // lookalikes INSIDE payload
78
+ patternPayload(3000),
79
+ ])
80
+ return Buffer.concat([
81
+ Buffer.from('starting up\n', 'utf8'),
82
+ Buffer.from('PLOTAPP:{"type":"state_update","key":"clim"}\n', 'utf8'),
83
+ frame({ fig_id: 'f1', key: 'image' }, nasty),
84
+ Buffer.from('mid-run log\n', 'utf8'),
85
+ frame({ fig_id: 'f2', key: 'image' }, patternPayload(1)),
86
+ frame({ fig_id: 'f3', key: 'spec' }, Buffer.alloc(0)),
87
+ Buffer.from('PLOTAPP:{"type":"done"}\n', 'utf8'),
88
+ ])
89
+ }
90
+
91
+ test('one whole-stream chunk parses every unit in order', () => {
92
+ const events = run(interleavedStream(), [1 << 30])
93
+ assert.deepEqual(events.map((e) => e.kind),
94
+ ['stream', 'message', 'binary', 'stream', 'binary', 'binary', 'message'])
95
+ const b1 = events[2] as Extract<Event, { kind: 'binary' }>
96
+ assert.deepEqual(b1.header, { fig_id: 'f1', key: 'image' })
97
+ const b3 = events[5] as Extract<Event, { kind: 'binary' }>
98
+ assert.equal(b3.payload, '')
99
+ assert.deepEqual((events[6] as Extract<Event, { kind: 'message' }>).msg,
100
+ { type: 'done' })
101
+ })
102
+
103
+ test('the event trace is chunking-invariant (64 KiB, tiny, 1-byte, ragged)', () => {
104
+ const stream = interleavedStream()
105
+ const reference = run(stream, [1 << 30])
106
+ for (const sizes of [[65536], [7], [1], [3, 1, 40, 2, 1000, 1]]) {
107
+ assert.deepEqual(run(stream, sizes), reference,
108
+ `trace diverged for chunk sizes ${JSON.stringify(sizes)}`)
109
+ }
110
+ })
111
+
112
+ test('splits inside the marker, prefix, header and payload all reassemble', () => {
113
+ const f = frame({ fig_id: 'x' }, patternPayload(64))
114
+ const reference = run(f, [1 << 30])
115
+ // Split at every single boundary position of the frame — brute force
116
+ // covers mid-marker, mid-prefix, mid-header and mid-payload by exhaustion.
117
+ for (let cut = 1; cut < f.length; cut++) {
118
+ const { events, demux } = collector()
119
+ demux.push(f.subarray(0, cut))
120
+ demux.push(f.subarray(cut))
121
+ assert.deepEqual(events, reference, `split at byte ${cut} diverged`)
122
+ }
123
+ })
124
+
125
+ test('a malformed PLOTBIN prefix line is dropped and parsing resumes', () => {
126
+ const stream = Buffer.concat([
127
+ Buffer.from('PLOTBIN:not:numbers\n', 'ascii'),
128
+ Buffer.from('PLOTAPP:{"type":"after"}\n', 'utf8'),
129
+ ])
130
+ for (const sizes of [[1 << 30], [1]]) {
131
+ const events = run(stream, sizes)
132
+ assert.deepEqual(events, [
133
+ { kind: 'message', msg: { type: 'after' } },
134
+ ])
135
+ }
136
+ })
137
+
138
+ test('a malformed header still consumes the frame and delivers the payload', () => {
139
+ const h = Buffer.from('{broken', 'utf8')
140
+ const payload = patternPayload(32)
141
+ const stream = Buffer.concat([
142
+ Buffer.from(`PLOTBIN:${h.length}:${payload.length}\n`, 'ascii'),
143
+ h, payload,
144
+ Buffer.from('PLOTAPP:{"type":"after"}\n', 'utf8'),
145
+ ])
146
+ const events = run(stream, [5])
147
+ assert.deepEqual(events, [
148
+ { kind: 'binary', header: {}, payload: payload.toString('hex') },
149
+ { kind: 'message', msg: { type: 'after' } },
150
+ ])
151
+ })
152
+
153
+ test('malformed PLOTAPP JSON is reported, not swallowed; blank lines emit nothing', () => {
154
+ // A truncated frame is how a backend bug presents, and silence turns it
155
+ // into "the UI just stopped" — so the line is dropped AND said so, on
156
+ // stderr, and the parse carries on to the next unit.
157
+ const stream = Buffer.from('PLOTAPP:{nope\n\n \nreal log\n', 'utf8')
158
+ const events = run(stream, [2])
159
+ assert.equal(events.length, 2)
160
+ assert.match((events[0] as { text?: string }).text ?? '',
161
+ /^\[sidecar protocol\] malformed JSON message: PLOTAPP:\{nope/)
162
+ assert.deepEqual(events[1], { kind: 'stream', text: 'real log\n' })
163
+ })
164
+
165
+ test('a handler that throws does not stop the parse', () => {
166
+ const seen: string[] = []
167
+ const demux = createStdoutDemux({
168
+ onMessage: (m) => { seen.push(`m:${(m as { type?: string }).type}`) },
169
+ onStream: () => { throw new Error('boom') },
170
+ onBinary: () => { throw new Error('boom') },
171
+ })
172
+ demux.push(Buffer.concat([
173
+ Buffer.from('log line\n', 'utf8'),
174
+ frame({ fig_id: 'f' }, patternPayload(8)),
175
+ Buffer.from('PLOTAPP:{"type":"after"}\n', 'utf8'),
176
+ ]))
177
+ assert.deepEqual(seen, ['m:after'])
178
+ })
179
+
180
+ test('PERF PIN: a 16 MB frame in 64 KiB chunks demuxes in linear time', () => {
181
+ const payload = Buffer.allocUnsafe(16 * 1024 * 1024).fill(7)
182
+ const stream = frame({ fig_id: 'big', key: 'image' }, payload)
183
+ const { events, demux } = collector()
184
+ const t0 = process.hrtime.bigint()
185
+ for (let pos = 0; pos < stream.length; pos += 65536) {
186
+ demux.push(stream.subarray(pos, Math.min(pos + 65536, stream.length)))
187
+ }
188
+ const ms = Number(process.hrtime.bigint() - t0) / 1e6
189
+ assert.equal(events.length, 1)
190
+ assert.equal((events[0] as Extract<Event, { kind: 'binary' }>).payload.length,
191
+ payload.length * 2) // hex doubles
192
+ assert.ok(ms < 250,
193
+ `16 MB frame took ${ms.toFixed(0)} ms — the quadratic Buffer.concat ` +
194
+ `accumulator signature (measured 813 ms pre-fix, 6.6 ms chunk-list)`)
195
+ })
@@ -0,0 +1,167 @@
1
+ /**
2
+ * stdoutDemux.ts — the backend stdout demuxer.
3
+ *
4
+ * The Python sidecar's stdout interleaves '\n'-terminated text lines
5
+ * (PLOTAPP: JSON and plain log output) with raw PLOTBIN binary frames
6
+ * (PLOTBIN:<hlen>:<plen>\n<header_json><payload>). readline can't carry
7
+ * binary, so the raw Buffer stream is parsed here, accumulating partial
8
+ * reads; the event trace must be invariant to how the OS slices the stream
9
+ * into 'data' chunks (Windows delivers a large child-stdout write as uniform
10
+ * 64 KiB chunks).
11
+ */
12
+
13
+ export interface DemuxHandlers {
14
+ onMessage: (msg: Record<string, unknown>) => void
15
+ onStream: (text: string, kind: 'stdout' | 'stderr') => void
16
+ onBinary?: (header: Record<string, unknown>, payload: Buffer) => void
17
+ }
18
+
19
+ export interface StdoutDemux {
20
+ /** Feed one stdout 'data' chunk; fires handlers for each completed unit. */
21
+ push(chunk: Buffer): void
22
+ }
23
+
24
+ const PLOTBIN = Buffer.from('PLOTBIN:')
25
+ const NL = 0x0a
26
+
27
+ /**
28
+ * Chunk-list accumulator (perf arc Phase F fix #4). The previous
29
+ * implementation concatenated every incoming chunk onto one accumulator
30
+ * (`Buffer.concat([acc, chunk])`), recopying the whole buffered prefix per
31
+ * chunk — O(N²/chunkSize) while a large PLOTBIN frame streams in: measured
32
+ * 813 ms per 16 MB frame and 11.9 s / 32.9 GB copied per 64 MB frame at the
33
+ * OS's 64 KiB chunk size. This version keeps the chunks in a list and copies
34
+ * each byte exactly once, into the emitted unit (payload/header/line):
35
+ * 6.6 ms per 16 MB frame, 30 ms per 64 MB. Byte-identical event traces
36
+ * proven across chunkings in stdoutDemux.test.ts (and previously in the
37
+ * tools/perf/transport bench, incl. 1-byte splits).
38
+ */
39
+ export function createStdoutDemux(handlers: DemuxHandlers): StdoutDemux {
40
+ const chunks: Buffer[] = [] // chunks[0] is valid from `off`
41
+ let off = 0 // consumed bytes inside chunks[0]
42
+ let total = 0 // unconsumed bytes across the list
43
+ let noNLBefore = 0 // scan hint: first `noNLBefore` bytes have no NL
44
+
45
+ // byte at logical position i (i < total); only used for the tiny marker probe
46
+ const byteAt = (i: number): number => {
47
+ let pos = i + off
48
+ for (const c of chunks) {
49
+ if (pos < c.length) return c[pos]
50
+ pos -= c.length
51
+ }
52
+ return -1
53
+ }
54
+
55
+ const startsWithMarker = (): boolean => {
56
+ for (let i = 0; i < PLOTBIN.length; i++) {
57
+ if (byteAt(i) !== PLOTBIN[i]) return false
58
+ }
59
+ return true
60
+ }
61
+
62
+ // logical index of the first NL at/after `from`, else -1 (uses + updates the
63
+ // hint so an incomplete unit is never rescanned from the start)
64
+ const findNL = (from: number): number => {
65
+ const start = Math.max(from, noNLBefore)
66
+ let logical = 0 // logical index of the start of chunk ci
67
+ for (let ci = 0; ci < chunks.length; ci++) {
68
+ const c = chunks[ci]
69
+ const cOff = ci === 0 ? off : 0
70
+ const cLen = c.length - cOff
71
+ if (start < logical + cLen) {
72
+ const within = cOff + Math.max(0, start - logical)
73
+ const hit = c.indexOf(NL, within)
74
+ if (hit >= 0) return logical + (hit - cOff)
75
+ }
76
+ logical += cLen
77
+ }
78
+ noNLBefore = total
79
+ return -1
80
+ }
81
+
82
+ // copy `len` bytes starting at logical `start` into a fresh Buffer — the
83
+ // single copy each emitted byte pays
84
+ const extract = (start: number, len: number): Buffer => {
85
+ const out = Buffer.allocUnsafe(len)
86
+ if (len === 0) return out
87
+ let pos = start + off
88
+ let ci = 0
89
+ while (pos >= chunks[ci].length) { pos -= chunks[ci].length; ci++ }
90
+ let written = 0
91
+ while (written < len) {
92
+ const c = chunks[ci]
93
+ const n = Math.min(len - written, c.length - pos)
94
+ c.copy(out, written, pos, pos + n)
95
+ written += n; pos = 0; ci++
96
+ }
97
+ return out
98
+ }
99
+
100
+ const consume = (n: number): void => {
101
+ total -= n
102
+ noNLBefore = Math.max(0, noNLBefore - n)
103
+ let left = n + off
104
+ while (left > 0 && chunks.length) {
105
+ const c = chunks[0]
106
+ if (left >= c.length) { left -= c.length; chunks.shift() }
107
+ else break
108
+ }
109
+ off = left
110
+ }
111
+
112
+ const parse = (): void => {
113
+ // Process as many complete units as are buffered; stop when we need more.
114
+ for (;;) {
115
+ if (total === 0) break
116
+ // A binary frame if the stream starts with the PLOTBIN marker.
117
+ if (total >= PLOTBIN.length && startsWithMarker()) {
118
+ const nl = findNL(0)
119
+ if (nl < 0) break // prefix line incomplete
120
+ const prefix = extract(PLOTBIN.length, nl - PLOTBIN.length).toString('ascii')
121
+ const [hlenS, plenS] = prefix.split(':')
122
+ const hlen = parseInt(hlenS, 10), plen = parseInt(plenS, 10)
123
+ if (!(hlen >= 0) || !(plen >= 0)) { // malformed → drop the line
124
+ consume(nl + 1); continue
125
+ }
126
+ const bodyStart = nl + 1
127
+ const end = bodyStart + hlen + plen
128
+ if (total < end) break // body not fully arrived yet
129
+ let header: Record<string, unknown> = {}
130
+ try {
131
+ header = JSON.parse(extract(bodyStart, hlen).toString('utf8'))
132
+ } catch { /* malformed header — still consume the frame */ }
133
+ const payload = extract(bodyStart + hlen, plen)
134
+ consume(end)
135
+ try { handlers.onBinary?.(header, payload) } catch { /* ignore */ }
136
+ continue
137
+ }
138
+ // Otherwise a text line up to the next '\n'.
139
+ const nl = findNL(0)
140
+ if (nl < 0) break // line incomplete
141
+ const line = extract(0, nl).toString('utf8')
142
+ consume(nl + 1)
143
+ if (line.startsWith('PLOTAPP:')) {
144
+ try {
145
+ handlers.onMessage(JSON.parse(line.slice(8)) as Record<string, unknown>)
146
+ } catch {
147
+ // Say so rather than swallow it: a truncated frame is how a backend
148
+ // bug presents, and silence turns it into "the UI just stopped".
149
+ try {
150
+ handlers.onStream(`[sidecar protocol] malformed JSON message: ${line.slice(0, 200)}\n`, 'stderr')
151
+ } catch { /* ignore */ }
152
+ }
153
+ } else if (line.trim()) {
154
+ try { handlers.onStream(line + '\n', 'stdout') } catch { /* ignore */ }
155
+ }
156
+ }
157
+ }
158
+
159
+ return {
160
+ push(chunk: Buffer): void {
161
+ if (chunk.length === 0) return
162
+ chunks.push(chunk)
163
+ total += chunk.length
164
+ parse()
165
+ },
166
+ }
167
+ }
@@ -5,7 +5,10 @@
5
5
  * are easy to omit without any error appearing:
6
6
  *
7
7
  * * **Register with the bridge**, so state can be routed to this frame — and
8
- * deregister on unmount, so a dead element is not held.
8
+ * deregister on unmount, so a dead element is not held. In an EFFECT
9
+ * (`attachFigure`), not the ref callback alone: StrictMode's double-invoke
10
+ * otherwise leaves the figure registered nowhere, and everything pushed to it
11
+ * afterwards goes into the void.
9
12
  * * **Replay on load**, passing THIS element. State that arrived before the
10
13
  * frame mounted was posted into the void; replay is the only thing that
11
14
  * recovers it, and a figure mounted twice must serve itself rather than
@@ -21,7 +24,8 @@
21
24
  * the host page needs `script-src … blob:` for anyplotlib's ESM boot.
22
25
  */
23
26
  import React, { useEffect, useRef } from 'react'
24
- import type { FigureBridge } from './figureBridge'
27
+ import { attachFigure, type FigureBridge } from './figureBridge'
28
+ import { createSizeReporter } from './sizeReporter'
25
29
 
26
30
  export interface FigureFrameProps {
27
31
  bridge: FigureBridge
@@ -44,26 +48,33 @@ export function FigureFrame({
44
48
  }: FigureFrameProps) {
45
49
  const ref = useRef<HTMLIFrameElement | null>(null)
46
50
 
47
- // Report size to the backend. Fires once on mount and on every resize; the
48
- // zero-size guard skips the frame's first layout pass, which would otherwise
49
- // tell the backend to lay the figure out at 0×0.
51
+ // Report size to the backend. Fires once on mount and on every OBSERVED
52
+ // resize; `createSizeReporter` skips the frame's zero-size first layout
53
+ // pass and any firing whose rounded size hasn't changed.
54
+ //
55
+ // Hardened on both axes the resize burst rode in on: the callback lives in
56
+ // a ref and the effect deps carry `figId` ONLY, so an inline `onResize`
57
+ // prop — a new identity on every parent render — no longer re-runs the
58
+ // effect (which sent unconditionally per re-run; message-driven re-renders
59
+ // then fed a loop measured at ~1,500 sends/s over constant geometry). The
60
+ // reporter's skip-on-unchanged covers ResizeObserver refires the same way.
61
+ const onResizeRef = useRef(onResize)
62
+ onResizeRef.current = onResize
50
63
  useEffect(() => {
51
64
  const el = ref.current
52
- if (!el || !onResize) return
53
- const send = () => {
54
- const r = el.getBoundingClientRect()
55
- if (r.width > 0 && r.height > 0) {
56
- onResize(Math.round(r.width), Math.round(r.height))
57
- }
58
- }
65
+ if (!el) return
66
+ const report = createSizeReporter((w, h) => onResizeRef.current?.(w, h))
67
+ const send = () => report(el.getBoundingClientRect())
59
68
  send()
60
69
  const ro = new ResizeObserver(send)
61
70
  ro.observe(el)
62
71
  return () => ro.disconnect()
63
- }, [figId, onResize])
72
+ }, [figId])
64
73
 
65
- // Deregister on unmount so the bridge never holds a detached element.
66
- useEffect(() => () => bridge.registerIframe(figId, null), [bridge, figId])
74
+ // REGISTRATION IS OWNED BY AN EFFECT, not by the ref callback alone, and it
75
+ // re-registers on every run — see `attachFigure`, which holds the reason and
76
+ // is where the StrictMode double-invoke is pinned by a test.
77
+ useEffect(() => attachFigure(bridge, figId, ref.current), [bridge, figId])
67
78
 
68
79
  return (
69
80
  <iframe
@@ -10,7 +10,7 @@
10
10
  import { test, describe } from 'node:test'
11
11
  import assert from 'node:assert/strict'
12
12
 
13
- import { createFigureBridge } from './figureBridge.ts'
13
+ import { attachFigure, createFigureBridge } from './figureBridge.ts'
14
14
 
15
15
  /** A stand-in iframe that records what was posted into it. */
16
16
  function fakeIframe(testid = 'frame') {
@@ -182,3 +182,66 @@ describe('registry and eviction', () => {
182
182
  assert.equal(row.registeredIn, 'NONE')
183
183
  })
184
184
  })
185
+
186
+ describe('attachFigure — the mount lifecycle a figure has to survive', () => {
187
+ /** React StrictMode, in dev builds: effect, cleanup, effect — same element. */
188
+ function strictMount(bridge: any, figId: string, el: HTMLIFrameElement) {
189
+ const detach1 = attachFigure(bridge, figId, el)
190
+ detach1()
191
+ return attachFigure(bridge, figId, el)
192
+ }
193
+
194
+ test('a StrictMode double-invoke leaves the figure REGISTERED', () => {
195
+ // The defect this pins: the cleanup deregistered and nothing put the
196
+ // element back, because the ref callback does not fire again for the same
197
+ // DOM node. Everything pushed afterwards was stashed and posted nowhere,
198
+ // and the pane kept the placeholder its srcdoc was born with — a solid
199
+ // black FFT pane in a dev build, correct in a production one.
200
+ const bridge = createFigureBridge()
201
+ const frame = fakeIframe()
202
+ strictMount(bridge, 'f1', frame.el)
203
+
204
+ assert.equal(bridge.iframes.current.get('f1'), frame.el)
205
+ frame.posted.length = 0
206
+ bridge.applyState('f1', 'display_min', -66.4)
207
+ assert.deepEqual(frame.posted.map(p => p.message),
208
+ [{ type: 'awi_state', key: 'display_min', value: -66.4 }])
209
+ })
210
+
211
+ test('state that arrived while the slot was empty is replayed on attach', () => {
212
+ // The other half: a pane whose frame is computed seconds after it mounts
213
+ // (an FFT, a fit) pushes into an empty slot. Attaching has to deliver it.
214
+ const bridge = createFigureBridge()
215
+ const frame = fakeIframe()
216
+ bridge.applyState('f1', 'title', 'Image FFT')
217
+ bridge.applyState('f1', 'raw_max', 255)
218
+ attachFigure(bridge, 'f1', frame.el)
219
+
220
+ assert.deepEqual(frame.posted.map(p => p.message.key), ['title', 'raw_max'])
221
+ })
222
+
223
+ test('a real unmount deregisters, and a remount is not evicted by it', () => {
224
+ const bridge = createFigureBridge()
225
+ const first = fakeIframe('first')
226
+ const detach = attachFigure(bridge, 'f1', first.el)
227
+ detach()
228
+ assert.equal(bridge.iframes.current.get('f1'), undefined)
229
+
230
+ // A remount registers the NEW element before the old one's cleanup runs,
231
+ // which is the order React uses. The stale cleanup must not evict it.
232
+ const second = fakeIframe('second')
233
+ const detachSecond = attachFigure(bridge, 'f1', second.el)
234
+ detach()
235
+ assert.equal(bridge.iframes.current.get('f1'), second.el)
236
+ detachSecond()
237
+ assert.equal(bridge.iframes.current.get('f1'), undefined)
238
+ })
239
+
240
+ test('attaching nothing is a no-op, and detaching it evicts nobody', () => {
241
+ const bridge = createFigureBridge()
242
+ const frame = fakeIframe()
243
+ bridge.registerIframe('f1', frame.el)
244
+ attachFigure(bridge, 'f1', null)()
245
+ assert.equal(bridge.iframes.current.get('f1'), frame.el)
246
+ })
247
+ })
@@ -58,6 +58,39 @@ export interface FigureBridge {
58
58
  dump(classify?: (el: HTMLIFrameElement | undefined) => string): Record<string, unknown>[]
59
59
  }
60
60
 
61
+ /**
62
+ * Attach `el` to `figId` for as long as a host component is mounted, and return
63
+ * the detach. The registration half of `FigureFrame`, exported so the lifecycle
64
+ * it has to survive can be driven in a test — a `.tsx` cannot be, under this
65
+ * package's plain `node --test` runner.
66
+ *
67
+ * WHY IT IS AN EFFECT BODY AND NOT A REF CALLBACK. React StrictMode (dev builds
68
+ * only) mounts, runs effects, runs their CLEANUP, and runs them AGAIN — against
69
+ * the same DOM element, so a ref callback does not fire the second time. A
70
+ * cleanup that only deregistered therefore left the bridge with no iframe for
71
+ * the figure and nothing to put one back: every later `applyState`/`applyBinary`
72
+ * was stashed and posted into the void, and the frame's `onLoad` replay had
73
+ * already fired. A pane whose state arrives LATER than its mount then keeps
74
+ * whatever its srcdoc was born with — the zeros placeholder, i.e. solid black
75
+ * in a dev build and correct in a production one. Re-registering on every run
76
+ * makes the double-invoke harmless, and the replay re-delivers whatever landed
77
+ * while the slot was empty.
78
+ *
79
+ * The detach checks that the slot is still OURS: a real remount registers the
80
+ * new element first, and this must not evict it.
81
+ */
82
+ export function attachFigure(bridge: FigureBridge, figId: string,
83
+ el: HTMLIFrameElement | null): () => void {
84
+ if (el) {
85
+ bridge.registerIframe(figId, el)
86
+ bridge.replay(figId, el)
87
+ }
88
+ return () => {
89
+ if (bridge.iframes.current.get(figId) === el) bridge.registerIframe(figId, null)
90
+ }
91
+ }
92
+
93
+
61
94
  export function createFigureBridge(
62
95
  log: (label: string, detail: Record<string, unknown>) => void = () => {},
63
96
  ): FigureBridge {
@@ -0,0 +1,76 @@
1
+ /**
2
+ * sizeReporter.test.ts — the dedupe half of FigureFrame's resize hardening.
3
+ *
4
+ * The failure it prevents shipped: FigureFrame's size-report effect re-ran on
5
+ * every parent render (inline `onResize` props make a new identity each
6
+ * time) and sent unconditionally, so message-driven re-renders fed a resize
7
+ * loop that sustained ~1,500 sends/s with constant geometry. The reporter is
8
+ * the testable core: only a CHANGED rounded size goes out.
9
+ *
10
+ * The other half — the effect no longer re-running on `onResize` identity —
11
+ * is wiring node:test cannot see; the e2e resize spec counts the backend's
12
+ * applied resizes over a Calibrate mount to pin it end-to-end.
13
+ *
14
+ * Run by `npm run test:unit` (node:test, native TS type-stripping).
15
+ */
16
+ import { test, describe } from 'node:test'
17
+ import assert from 'node:assert/strict'
18
+
19
+ import { createSizeReporter } from './sizeReporter.ts'
20
+
21
+ function collector() {
22
+ const sent: Array<[number, number]> = []
23
+ return { sent, send: (w: number, h: number) => { sent.push([w, h]) } }
24
+ }
25
+
26
+ describe('createSizeReporter', () => {
27
+ test('a real size is reported, rounded', () => {
28
+ const { sent, send } = collector()
29
+ const report = createSizeReporter(send)
30
+ report({ width: 353.4, height: 380.6 })
31
+ assert.deepEqual(sent, [[353, 381]])
32
+ })
33
+
34
+ test('the frame\'s zero-size first layout pass is skipped', () => {
35
+ const { sent, send } = collector()
36
+ const report = createSizeReporter(send)
37
+ report({ width: 0, height: 0 })
38
+ report({ width: 0, height: 200 })
39
+ assert.deepEqual(sent, [])
40
+ })
41
+
42
+ test('an unchanged size is NOT resent — the burst mechanism', () => {
43
+ const { sent, send } = collector()
44
+ const report = createSizeReporter(send)
45
+ for (let i = 0; i < 100; i++) report({ width: 353, height: 381 })
46
+ assert.equal(sent.length, 1)
47
+ })
48
+
49
+ test('a sub-pixel wobble that rounds to the same size is not resent', () => {
50
+ const { sent, send } = collector()
51
+ const report = createSizeReporter(send)
52
+ report({ width: 353.2, height: 381.1 })
53
+ report({ width: 353.4, height: 380.8 })
54
+ assert.equal(sent.length, 1)
55
+ })
56
+
57
+ test('a genuine size change IS sent — the fix\'s own failure mode', () => {
58
+ // The one dangerous way to over-fix this: a figure that never learns its
59
+ // new size. Required red-first by the 8-15 review's fix-4 pin.
60
+ const { sent, send } = collector()
61
+ const report = createSizeReporter(send)
62
+ report({ width: 353, height: 381 })
63
+ report({ width: 353, height: 381 })
64
+ report({ width: 500, height: 381 })
65
+ assert.deepEqual(sent, [[353, 381], [500, 381]])
66
+ })
67
+
68
+ test('a size can recur after an intervening change', () => {
69
+ const { sent, send } = collector()
70
+ const report = createSizeReporter(send)
71
+ report({ width: 100, height: 100 })
72
+ report({ width: 200, height: 100 })
73
+ report({ width: 100, height: 100 })
74
+ assert.equal(sent.length, 3)
75
+ })
76
+ })
@@ -0,0 +1,29 @@
1
+ /**
2
+ * sizeReporter.ts — dedupe for FigureFrame's size reports.
3
+ *
4
+ * One reporter per mounted frame. Rounds the observed rect, skips the frame's
5
+ * zero-size first layout pass, and only lets a CHANGED size through — a
6
+ * ResizeObserver refires on observe() and on sub-pixel wobble, and an
7
+ * unconditional send per firing is how the Calibrate resize burst sustained
8
+ * ~1,500 messages/s over constant geometry.
9
+ */
10
+
11
+ export interface SizeLike {
12
+ width: number
13
+ height: number
14
+ }
15
+
16
+ export function createSizeReporter(
17
+ send: (width: number, height: number) => void,
18
+ ): (rect: SizeLike) => void {
19
+ let last: string | null = null
20
+ return (rect: SizeLike) => {
21
+ const w = Math.round(rect.width)
22
+ const h = Math.round(rect.height)
23
+ if (w <= 0 || h <= 0) return
24
+ const key = `${w}x${h}`
25
+ if (key === last) return
26
+ last = key
27
+ send(w, h)
28
+ }
29
+ }
@@ -109,6 +109,37 @@ def robust_levels(frame: np.ndarray, *,
109
109
  #: Figure chrome background. The apps are dark; anyplotlib's template is not.
110
110
  FIGURE_BACKGROUND = "#1e1e2e"
111
111
 
112
+ #: Injected into every figure frame: the figure document must never SCROLL.
113
+ #:
114
+ #: anyplotlib lays its panel out at the size the app reports and then wraps it
115
+ #: in ~16 px of its own chrome, so the figure document is always a little taller
116
+ #: than the frame it lives in. Nothing shows that — the overflow is clipped —
117
+ #: until something inside gets FOCUS: the first pointer entry focuses the plot
118
+ #: canvas, the browser scrolls that focused element into view, and the whole
119
+ #: picture jumps up by half the overflow. Once. On the first hover of a fresh
120
+ #: pane, which is exactly what an operator reads as "the FFT moved".
121
+ #:
122
+ #: CSS cannot stop it. `overflow: hidden` still leaves a programmatically
123
+ #: scrollable box, and `overflow: clip` on the root propagates to the viewport,
124
+ #: which Chromium scrolls anyway (measured, both).
125
+ #:
126
+ #: So the scroll is UNDONE, on the capture phase, the moment it happens. The
127
+ #: figure's own pan and zoom never touch document scroll — they are canvas
128
+ #: transforms — so a scrolled figure document is always this artefact and never
129
+ #: something a user asked for. Assigning 0 to an already-0 offset does not
130
+ #: re-fire, so this settles rather than loops.
131
+ #: BOTH boxes, deliberately: which one actually scrolls depends on the overflow
132
+ #: cascade — with `html` hidden the viewport cannot scroll and `body` keeps a
133
+ #: scroll box of its own, and the focus scroll lands on whichever it is
134
+ #: (measured: `body` under the style above, `html` when the root is `clip`).
135
+ PIN_SCROLL = (
136
+ "<script>addEventListener('scroll',function(){"
137
+ "var d=document,b=[d.documentElement,d.body,d.scrollingElement];"
138
+ "for(var i=0;i<b.length;i++){var e=b[i];if(!e)continue;"
139
+ "if(e.scrollTop)e.scrollTop=0;if(e.scrollLeft)e.scrollLeft=0}"
140
+ "},{capture:true,passive:true})</script>"
141
+ )
142
+
112
143
 
113
144
  def fill_iframe_html(html: str, *, background: str = FIGURE_BACKGROUND,
114
145
  extra_head: str = "") -> str:
@@ -130,7 +161,7 @@ def fill_iframe_html(html: str, *, background: str = FIGURE_BACKGROUND,
130
161
  f"#widget-root{{background:{background} !important;"
131
162
  "width:100% !important;height:100% !important;display:block !important}"
132
163
  "</style>")
133
- return html.replace("<body>", style + extra_head + "<body>", 1)
164
+ return html.replace("<body>", style + PIN_SCROLL + extra_head + "<body>", 1)
134
165
 
135
166
 
136
167
  class FigureView:
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: de-shell
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: The Electron + Python desktop-app shell Direct Electron's apps (SpyDE, Ground Crew, Autopilot) are assembled from
5
5
  Author-email: Carter Francis <cartsfrancis@gmail.com>
6
6
  License: MIT
7
- Project-URL: Homepage, https://github.com/CSSFrancis/de-shell
8
- Project-URL: Repository, https://github.com/CSSFrancis/de-shell
9
- Project-URL: Issues, https://github.com/CSSFrancis/de-shell/issues
10
- Project-URL: Changelog, https://github.com/CSSFrancis/de-shell/blob/main/CHANGELOG.md
7
+ Project-URL: Homepage, https://github.com/directelectron/de-shell
8
+ Project-URL: Repository, https://github.com/directelectron/de-shell
9
+ Project-URL: Issues, https://github.com/directelectron/de-shell/issues
10
+ Project-URL: Changelog, https://github.com/directelectron/de-shell/blob/main/CHANGELOG.md
11
11
  Keywords: electron,desktop,anyplotlib,electron-microscopy,direct-electron
12
12
  Classifier: Development Status :: 3 - Alpha
13
13
  Classifier: Intended Audience :: Developers
@@ -26,7 +26,7 @@ Requires-Python: >=3.10
26
26
  Description-Content-Type: text/markdown
27
27
  License-File: LICENSE
28
28
  Requires-Dist: numpy>=1.20.0
29
- Requires-Dist: anyplotlib>=0.7.3
29
+ Requires-Dist: anyplotlib>=0.8.0
30
30
  Requires-Dist: pyyaml
31
31
  Provides-Extra: tests
32
32
  Requires-Dist: pytest>=3.6; extra == "tests"
@@ -36,7 +36,7 @@ Dynamic: license-file
36
36
  # DE Shell
37
37
 
38
38
  [![PyPI](https://img.shields.io/pypi/v/de-shell.svg)](https://pypi.org/project/de-shell/)
39
- [![CI](https://github.com/CSSFrancis/de-shell/actions/workflows/ci.yml/badge.svg)](https://github.com/CSSFrancis/de-shell/actions/workflows/ci.yml)
39
+ [![CI](https://github.com/directelectron/de-shell/actions/workflows/ci.yml/badge.svg)](https://github.com/directelectron/de-shell/actions/workflows/ci.yml)
40
40
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
41
41
 
42
42
  The substrate Direct Electron's desktop apps are assembled from: **SpyDE**
@@ -122,8 +122,9 @@ peer dependencies — react, electron, electron-updater, @playwright/test —
122
122
  are the app's to declare; every app already does. The e2e specs take the
123
123
  harness from `shell/testing/harness.cjs`.
124
124
 
125
- Autopilot and SpyDE (on its `chore/de-shell-split` branch) are wired this
126
- way; Ground Crew still carries a copy under its `packages/` and is next.
125
+ Autopilot and SpyDE ([directelectron/spyde#152](https://github.com/directelectron/spyde/pull/152))
126
+ are wired this way, pinned `de-shell>=0.2,<0.3`; Ground Crew still carries a
127
+ copy under its `packages/` and is next.
127
128
 
128
129
  ## Developing it
129
130
 
@@ -175,9 +176,9 @@ SpyDE commit the app copies were taken from:
175
176
  panes on large stills), JSON emit that never writes bare `NaN`, the harness
176
177
  hardening, and the unit tests for all of it.
177
178
 
178
- Not yet included: Ground Crew's `dev/instrument-actions` shell deltas
179
- (`stdoutDemux`, `sizeReporter`, the `frameBytes` transport) — they ride a
180
- branch that has not merged.
179
+ * Ground Crew `main` @ 0382549 (0.2.1): the chunk-list stdout demuxer, the
180
+ size reporter and StrictMode-safe registration in `FigureFrame`, the figure
181
+ document's scroll pin, and the anyplotlib 0.8.0 floor.
181
182
 
182
183
  ## Rules
183
184
 
@@ -36,6 +36,8 @@ de_shell/js/main/pythonEnv.test.ts
36
36
  de_shell/js/main/pythonEnv.ts
37
37
  de_shell/js/main/sentryEnvelope.test.ts
38
38
  de_shell/js/main/sentryEnvelope.ts
39
+ de_shell/js/main/stdoutDemux.test.ts
40
+ de_shell/js/main/stdoutDemux.ts
39
41
  de_shell/js/main/updater.ts
40
42
  de_shell/js/main/updaterErrors.test.ts
41
43
  de_shell/js/main/updaterErrors.ts
@@ -49,6 +51,8 @@ de_shell/js/renderer/index.ts
49
51
  de_shell/js/renderer/protocol.ts
50
52
  de_shell/js/renderer/shellState.test.ts
51
53
  de_shell/js/renderer/shellState.ts
54
+ de_shell/js/renderer/sizeReporter.test.ts
55
+ de_shell/js/renderer/sizeReporter.ts
52
56
  de_shell/js/testing/harness.cjs
53
57
  de_shell/js/testing/harness.test.cjs
54
58
  de_shell/plotting/__init__.py
@@ -1,5 +1,5 @@
1
1
  numpy>=1.20.0
2
- anyplotlib>=0.7.3
2
+ anyplotlib>=0.8.0
3
3
  pyyaml
4
4
 
5
5
  [tests]
@@ -36,7 +36,13 @@ dependencies = [
36
36
  # needs: the centre is the DC term, and enforcing that from Python cannot
37
37
  # work — the renderer recomputes the drag from its own grab-time snapshot,
38
38
  # so a correction pushed from here is overwritten on the next mousemove.
39
- "anyplotlib>=0.7.3",
39
+ #
40
+ # 0.8.0 for its PR #62, "a tiled image born on a placeholder rendered solid
41
+ # black". Every large STILL in Ground Crew is that case: a pane opened at
42
+ # the frame's shape, given a tile source on its zeros placeholder, then
43
+ # painted. On 0.7.3 a state push landing between the open and the first
44
+ # frame could leave the pane showing the placeholder.
45
+ "anyplotlib>=0.8.0",
40
46
  "pyyaml",
41
47
  ]
42
48
 
@@ -47,10 +53,10 @@ tests = [
47
53
  ]
48
54
 
49
55
  [project.urls]
50
- Homepage = "https://github.com/CSSFrancis/de-shell"
51
- Repository = "https://github.com/CSSFrancis/de-shell"
52
- Issues = "https://github.com/CSSFrancis/de-shell/issues"
53
- Changelog = "https://github.com/CSSFrancis/de-shell/blob/main/CHANGELOG.md"
56
+ Homepage = "https://github.com/directelectron/de-shell"
57
+ Repository = "https://github.com/directelectron/de-shell"
58
+ Issues = "https://github.com/directelectron/de-shell/issues"
59
+ Changelog = "https://github.com/directelectron/de-shell/blob/main/CHANGELOG.md"
54
60
 
55
61
  # One version, in one place: de_shell.__version__. The release workflow refuses
56
62
  # a tag that does not match it.
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes