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.
- {de_shell-0.2.0 → de_shell-0.2.1}/PKG-INFO +13 -12
- {de_shell-0.2.0 → de_shell-0.2.1}/README.md +7 -6
- {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/__init__.py +1 -1
- {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/js/main/backendProcess.ts +14 -56
- de_shell-0.2.1/de_shell/js/main/stdoutDemux.test.ts +195 -0
- de_shell-0.2.1/de_shell/js/main/stdoutDemux.ts +167 -0
- {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/js/renderer/FigureFrame.tsx +26 -15
- {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/js/renderer/figureBridge.test.ts +64 -1
- {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/js/renderer/figureBridge.ts +33 -0
- de_shell-0.2.1/de_shell/js/renderer/sizeReporter.test.ts +76 -0
- de_shell-0.2.1/de_shell/js/renderer/sizeReporter.ts +29 -0
- {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/plotting/figure.py +32 -1
- {de_shell-0.2.0 → de_shell-0.2.1}/de_shell.egg-info/PKG-INFO +13 -12
- {de_shell-0.2.0 → de_shell-0.2.1}/de_shell.egg-info/SOURCES.txt +4 -0
- {de_shell-0.2.0 → de_shell-0.2.1}/de_shell.egg-info/requires.txt +1 -1
- {de_shell-0.2.0 → de_shell-0.2.1}/pyproject.toml +11 -5
- {de_shell-0.2.0 → de_shell-0.2.1}/LICENSE +0 -0
- {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/actions/__init__.py +0 -0
- {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/actions/context.py +0 -0
- {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/actions/figure_registry.py +0 -0
- {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/actions/lifecycle.py +0 -0
- {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/actions/registry.py +0 -0
- {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/actions/wizard.py +0 -0
- {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/app.py +0 -0
- {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/compute.py +0 -0
- {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/debug_flags.py +0 -0
- {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/ipc.py +0 -0
- {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/js/__init__.py +0 -0
- {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/js/__main__.py +0 -0
- {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/js/main/backendProcess.test.ts +0 -0
- {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/js/main/config.ts +0 -0
- {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/js/main/dialogs.ts +0 -0
- {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/js/main/envProgress.ts +0 -0
- {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/js/main/errorReport.ts +0 -0
- {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/js/main/index.ts +0 -0
- {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/js/main/problemLog.ts +0 -0
- {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/js/main/pythonEnv.test.ts +0 -0
- {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/js/main/pythonEnv.ts +0 -0
- {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/js/main/sentryEnvelope.test.ts +0 -0
- {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/js/main/sentryEnvelope.ts +0 -0
- {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/js/main/updater.ts +0 -0
- {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/js/main/updaterErrors.test.ts +0 -0
- {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/js/main/updaterErrors.ts +0 -0
- {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/js/main/window.ts +0 -0
- {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/js/package.json +0 -0
- {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/js/preload/index.ts +0 -0
- {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/js/renderer/figureBridge.react.ts +0 -0
- {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/js/renderer/index.ts +0 -0
- {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/js/renderer/protocol.ts +0 -0
- {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/js/renderer/shellState.test.ts +0 -0
- {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/js/renderer/shellState.ts +0 -0
- {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/js/testing/harness.cjs +0 -0
- {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/js/testing/harness.test.cjs +0 -0
- {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/log_stream.py +0 -0
- {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/plotting/__init__.py +0 -0
- {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/plotting/colormaps.py +0 -0
- {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/plotting/selectors/__init__.py +0 -0
- {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/plotting/selectors/utils.py +0 -0
- {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/plotting/stream.py +0 -0
- {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/process_guard.py +0 -0
- {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/session.py +0 -0
- {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/testing/__init__.py +0 -0
- {de_shell-0.2.0 → de_shell-0.2.1}/de_shell/timing.py +0 -0
- {de_shell-0.2.0 → de_shell-0.2.1}/de_shell.egg-info/dependency_links.txt +0 -0
- {de_shell-0.2.0 → de_shell-0.2.1}/de_shell.egg-info/top_level.txt +0 -0
- {de_shell-0.2.0 → de_shell-0.2.1}/setup.cfg +0 -0
- {de_shell-0.2.0 → de_shell-0.2.1}/tests/test_actions.py +0 -0
- {de_shell-0.2.0 → de_shell-0.2.1}/tests/test_boundary.py +0 -0
- {de_shell-0.2.0 → de_shell-0.2.1}/tests/test_figure.py +0 -0
- {de_shell-0.2.0 → de_shell-0.2.1}/tests/test_session_base.py +0 -0
- {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.
|
|
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/
|
|
8
|
-
Project-URL: Repository, https://github.com/
|
|
9
|
-
Project-URL: Issues, https://github.com/
|
|
10
|
-
Project-URL: Changelog, https://github.com/
|
|
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.
|
|
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
|
[](https://pypi.org/project/de-shell/)
|
|
39
|
-
[](https://github.com/directelectron/de-shell/actions/workflows/ci.yml)
|
|
40
40
|
[](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 (
|
|
126
|
-
way
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
[](https://pypi.org/project/de-shell/)
|
|
4
|
-
[](https://github.com/directelectron/de-shell/actions/workflows/ci.yml)
|
|
5
5
|
[](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 (
|
|
91
|
-
way
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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.
|
|
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
|
|
99
|
-
// plain log output, both '\n'-terminated) with raw PLOTBIN
|
|
100
|
-
// (PLOTBIN:<hlen>:<plen>\n<header_json><payload>). readline
|
|
101
|
-
//
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
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
|
|
48
|
-
//
|
|
49
|
-
//
|
|
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
|
|
53
|
-
const
|
|
54
|
-
|
|
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
|
|
72
|
+
}, [figId])
|
|
64
73
|
|
|
65
|
-
//
|
|
66
|
-
|
|
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.
|
|
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/
|
|
8
|
-
Project-URL: Repository, https://github.com/
|
|
9
|
-
Project-URL: Issues, https://github.com/
|
|
10
|
-
Project-URL: Changelog, https://github.com/
|
|
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.
|
|
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
|
[](https://pypi.org/project/de-shell/)
|
|
39
|
-
[](https://github.com/directelectron/de-shell/actions/workflows/ci.yml)
|
|
40
40
|
[](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 (
|
|
126
|
-
way
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
|
@@ -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
|
-
|
|
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/
|
|
51
|
-
Repository = "https://github.com/
|
|
52
|
-
Issues = "https://github.com/
|
|
53
|
-
Changelog = "https://github.com/
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|