@mindexec/cli 0.2.493 → 0.2.495

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 (30) hide show
  1. package/electron/main.cjs +108 -7
  2. package/electron/native-renderer-owner-smoke.mjs +387 -0
  3. package/electron/native-renderer-owner.cjs +1044 -0
  4. package/electron/preload.cjs +27 -12
  5. package/electron/source-smoke.mjs +63 -0
  6. package/electron/windows-package-smoke.mjs +92 -0
  7. package/package.json +18 -8
  8. package/remote-fast/osx-arm64/mindexec-remote-fast.dll +0 -0
  9. package/remote-fast/osx-x64/mindexec-remote-fast.dll +0 -0
  10. package/remote-fast/win-x64/mindexec-remote-fast.dll +0 -0
  11. package/scripts/desktop-update-publisher-smoke.mjs +33 -5
  12. package/scripts/publish-mindexec-desktop-updates.mjs +34 -5
  13. package/wwwroot/_content/MindExecution.Shared/js/mind-map-core.js +3 -0
  14. package/wwwroot/_content/MindExecution.Shared/js/mind-map-logic-workers.js +21 -1
  15. package/wwwroot/_content/MindExecution.Shared/js/mind-map-native-render-mirror.js +586 -0
  16. package/wwwroot/_content/MindExecution.Shared/native-core/native-core-manifest.json +2 -2
  17. package/wwwroot/_framework/{MindExecution.Core.60vfpag0ii.dll → MindExecution.Core.x2u2ssgfpb.dll} +0 -0
  18. package/wwwroot/_framework/{MindExecution.Kernel.mvd5iguar3.dll → MindExecution.Kernel.ii6sbi4gnx.dll} +0 -0
  19. package/wwwroot/_framework/{MindExecution.Plugins.Admin.vdu428rgb7.dll → MindExecution.Plugins.Admin.f8zjm0p5bt.dll} +0 -0
  20. package/wwwroot/_framework/{MindExecution.Plugins.Business.ew1bh09dyd.dll → MindExecution.Plugins.Business.dlkfqt93hm.dll} +0 -0
  21. package/wwwroot/_framework/{MindExecution.Plugins.Concept.jk98j1djoc.dll → MindExecution.Plugins.Concept.1dauzp0wt9.dll} +0 -0
  22. package/wwwroot/_framework/{MindExecution.Plugins.Directory.6ub1ud4o4q.dll → MindExecution.Plugins.Directory.3h32owxein.dll} +0 -0
  23. package/wwwroot/_framework/{MindExecution.Plugins.PlanMaster.0as3sypjys.dll → MindExecution.Plugins.PlanMaster.xcrnct9qgp.dll} +0 -0
  24. package/wwwroot/_framework/{MindExecution.Plugins.YouTube.b12xkgn7e0.dll → MindExecution.Plugins.YouTube.md7aqqgjj2.dll} +0 -0
  25. package/wwwroot/_framework/{MindExecution.Shared.2fytw4afkm.dll → MindExecution.Shared.a9wtfnqxlv.dll} +0 -0
  26. package/wwwroot/_framework/{MindExecution.Web.f6sfehwfzb.dll → MindExecution.Web.xg7sbkw72l.dll} +0 -0
  27. package/wwwroot/_framework/blazor.boot.json +21 -21
  28. package/wwwroot/index.html +2 -1
  29. package/wwwroot/service-worker-assets.js +30 -26
  30. package/wwwroot/service-worker.js +1 -1
package/electron/main.cjs CHANGED
@@ -7,6 +7,7 @@ const http = require('http');
7
7
  const net = require('net');
8
8
  const path = require('path');
9
9
  const workspaceState = require('../desktop-workspace-state.cjs');
10
+ const { createNativeRendererOwner } = require('./native-renderer-owner.cjs');
10
11
  const { createProductUpdateManager } = require('./update-manager.cjs');
11
12
  const {
12
13
  DEFAULT_UPDATE_INTERVAL_MS,
@@ -33,6 +34,7 @@ let desktopWorkspaceStatePath = '';
33
34
  let productUpdates = null;
34
35
  let recurringProductUpdates = null;
35
36
  let updateInstallSequenceStarted = false;
37
+ let nativeRenderer = null;
36
38
 
37
39
  function resolveDesktopIconPath() {
38
40
  const iconPath = path.resolve(__dirname, '..', 'wwwroot', 'icon-512.png');
@@ -61,6 +63,28 @@ function resolveNativeCoreMode() {
61
63
  }
62
64
  }
63
65
 
66
+ function resolveNativeRendererMode() {
67
+ const validModes = new Set(['off', 'mirror']);
68
+ const environmentMode = String(
69
+ process.env.MINDEXEC_NATIVE_RENDER_MODE || process.env.MINDEXEC_NATIVE_RENDERER_MODE || ''
70
+ ).trim().toLowerCase();
71
+ if (validModes.has(environmentMode)) return environmentMode;
72
+
73
+ try {
74
+ const packagePath = app.isPackaged
75
+ ? path.join(app.getAppPath(), 'package.json')
76
+ : path.resolve(__dirname, '..', 'package.json');
77
+ const packageInfo = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
78
+ const configuredMode = app.isPackaged
79
+ ? packageInfo.nativeRenderMode
80
+ : packageInfo.build?.extraMetadata?.nativeRenderMode;
81
+ const normalizedMode = String(configuredMode || '').trim().toLowerCase();
82
+ return validModes.has(normalizedMode) ? normalizedMode : 'off';
83
+ } catch {
84
+ return 'off';
85
+ }
86
+ }
87
+
64
88
  function parsePort(value) {
65
89
  const parsed = Number.parseInt(String(value ?? ''), 10);
66
90
  return Number.isInteger(parsed) && parsed > 0 && parsed <= 65_535 ? parsed : 0;
@@ -236,19 +260,23 @@ async function prepareBundledRuntime() {
236
260
  return waitForBundledBridge();
237
261
  }
238
262
 
239
- function assertTrustedUpdateIpc(event) {
263
+ function assertTrustedDesktopIpc(event, feature) {
240
264
  const senderUrl = event?.senderFrame?.url || event?.sender?.getURL?.() || '';
241
265
  let sender;
242
266
  try {
243
267
  sender = new URL(senderUrl);
244
268
  } catch {
245
- throw new Error('desktop-update-ipc-sender-invalid');
269
+ throw new Error(`desktop-${feature}-ipc-sender-invalid`);
246
270
  }
247
271
  if (sender.protocol !== 'http:' || sender.hostname !== '127.0.0.1' || sender.port !== String(activeBridgePort)) {
248
- throw new Error('desktop-update-ipc-sender-untrusted');
272
+ throw new Error(`desktop-${feature}-ipc-sender-untrusted`);
249
273
  }
250
274
  }
251
275
 
276
+ function assertTrustedUpdateIpc(event) {
277
+ assertTrustedDesktopIpc(event, 'update');
278
+ }
279
+
252
280
  function registerUpdateIpc() {
253
281
  const handle = (channel, callback) => {
254
282
  ipcMain.handle(channel, async (event) => {
@@ -261,6 +289,50 @@ function registerUpdateIpc() {
261
289
  handle('updates:install', () => productUpdates?.install() || Promise.resolve({ state: 'unavailable' }));
262
290
  }
263
291
 
292
+ function sendNativeRendererStatus(status) {
293
+ appendDesktopLog(
294
+ `[native-renderer] mode=${status.mode} ready=${status.ready} backend=${status.backend || ''} ` +
295
+ `generation=${status.engineGeneration} accepted=${status.acceptedFrames} rejected=${status.rejectedFrames}` +
296
+ `${status.lastError ? ` error=${status.lastError}` : ''}`
297
+ );
298
+ if (mainWindow && !mainWindow.isDestroyed() && !mainWindow.webContents.isDestroyed()) {
299
+ mainWindow.webContents.send('native-renderer:status', status);
300
+ }
301
+ }
302
+
303
+ function initializeNativeRenderer() {
304
+ nativeRenderer = createNativeRendererOwner({
305
+ mode: resolveNativeRendererMode(),
306
+ appIsPackaged: app.isPackaged,
307
+ resourcesPath: process.resourcesPath,
308
+ packageRoot: path.resolve(__dirname, '..'),
309
+ onStatus: sendNativeRendererStatus,
310
+ log: appendDesktopLog
311
+ });
312
+ }
313
+
314
+ function registerNativeRendererIpc() {
315
+ const handle = (channel, callback) => {
316
+ ipcMain.handle(channel, async (event, payload) => {
317
+ assertTrustedDesktopIpc(event, 'native-renderer');
318
+ return callback(payload);
319
+ });
320
+ };
321
+ handle('native-renderer:get-status', () => nativeRenderer?.getStatus() || {
322
+ mode: 'off',
323
+ ready: false,
324
+ backend: '',
325
+ engineGeneration: 0,
326
+ acceptedFrames: 0,
327
+ rejectedFrames: 0,
328
+ lastError: '',
329
+ processId: 0
330
+ });
331
+ handle('native-renderer:snapshot', scene => nativeRenderer?.sendSnapshot(scene));
332
+ handle('native-renderer:camera', frame => nativeRenderer?.sendCamera(frame));
333
+ handle('native-renderer:detach', owner => nativeRenderer?.detach(owner));
334
+ }
335
+
264
336
  function sendUpdateStatus(status) {
265
337
  appendDesktopLog(
266
338
  `[update] state=${status.state} current=${status.currentVersion || ''} ` +
@@ -389,6 +461,23 @@ async function waitForNativeCoreSmoke(browserWindow, expectedMode) {
389
461
  throw new Error(`Native core did not become ready in ${expectedMode} mode: ${JSON.stringify(lastSnapshot)}`);
390
462
  }
391
463
 
464
+ async function waitForNativeRendererSmoke(expectedMode) {
465
+ if (expectedMode === 'off') return nativeRenderer?.getStatus() || null;
466
+ const deadline = Date.now() + 30_000;
467
+ let lastSnapshot = null;
468
+ while (Date.now() < deadline) {
469
+ lastSnapshot = nativeRenderer?.getStatus() || null;
470
+ if (lastSnapshot?.lastError) {
471
+ throw new Error(`Native renderer smoke failed: ${lastSnapshot.lastError}`);
472
+ }
473
+ if (lastSnapshot?.ready === true && Number(lastSnapshot.acceptedFrames || 0) > 0) {
474
+ return lastSnapshot;
475
+ }
476
+ await new Promise(resolve => setTimeout(resolve, 100));
477
+ }
478
+ throw new Error(`Native renderer did not accept a board frame: ${JSON.stringify(lastSnapshot)}`);
479
+ }
480
+
392
481
  async function stopBundledBridge() {
393
482
  const child = bridgeChild;
394
483
  bridgeChild = null;
@@ -448,9 +537,10 @@ async function prepareRuntimeForUpdateInstall() {
448
537
  recurringProductUpdates?.stop();
449
538
  isQuitting = true;
450
539
  appendDesktopLog('[update] stopping bundled runtime before installer launch');
540
+ await nativeRenderer?.stop();
451
541
  await stopBundledBridge();
452
542
  await waitForBundledRuntimePortsReleased();
453
- appendDesktopLog('[update] bundled runtime and ports are fully released');
543
+ appendDesktopLog('[update] native renderer, bundled runtime, and ports are fully released');
454
544
  }
455
545
 
456
546
  async function recoverRuntimeAfterUpdateInstallError(error) {
@@ -460,11 +550,15 @@ async function recoverRuntimeAfterUpdateInstallError(error) {
460
550
  if (!bridgeChild || bridgeChild.exitCode !== null) {
461
551
  await prepareBundledRuntime();
462
552
  }
553
+ await nativeRenderer?.start();
463
554
  recurringProductUpdates?.start();
464
555
  }
465
556
 
466
557
  async function launchDesktop() {
467
- const status = await prepareBundledRuntime();
558
+ const [status] = await Promise.all([
559
+ prepareBundledRuntime(),
560
+ nativeRenderer?.start()
561
+ ]);
468
562
  const window = createMainWindow();
469
563
  const nativeCoreMode = resolveNativeCoreMode();
470
564
  const nativeCoreQuery = nativeCoreMode === 'off'
@@ -475,6 +569,7 @@ async function launchDesktop() {
475
569
 
476
570
  if (process.env.MINDEXEC_DESKTOP_SMOKE === '1') {
477
571
  const nativeCore = await waitForNativeCoreSmoke(window, nativeCoreMode);
572
+ const nativeRendererStatus = await waitForNativeRendererSmoke(nativeRenderer?.getStatus().mode || 'off');
478
573
  writeSmokeResult({
479
574
  ok: true,
480
575
  appVersion: app.getVersion(),
@@ -486,6 +581,7 @@ async function launchDesktop() {
486
581
  workspace: activeWorkspace,
487
582
  url: appUrl,
488
583
  nativeCore,
584
+ nativeRenderer: nativeRendererStatus,
489
585
  desktopUpdate: productUpdates?.getStatus() || null
490
586
  });
491
587
  setTimeout(() => app.quit(), 250).unref();
@@ -514,20 +610,25 @@ if (!hasSingleInstanceLock) {
514
610
  });
515
611
 
516
612
  app.on('before-quit', (event) => {
517
- if (isQuitting || !bridgeChild || bridgeChild.exitCode !== null) {
613
+ const bridgeRunning = Boolean(bridgeChild && bridgeChild.exitCode === null);
614
+ const nativeRendererRunning = nativeRenderer?.isRunning() === true;
615
+ if (isQuitting || (!bridgeRunning && !nativeRendererRunning)) {
518
616
  return;
519
617
  }
520
618
 
521
619
  event.preventDefault();
522
620
  recurringProductUpdates?.stop();
523
621
  isQuitting = true;
524
- stopBundledBridge().finally(() => app.exit(requestedExitCode));
622
+ Promise.all([nativeRenderer?.stop(), stopBundledBridge()])
623
+ .finally(() => app.exit(requestedExitCode));
525
624
  });
526
625
 
527
626
  app.whenReady()
528
627
  .then(async () => {
529
628
  initializeProductUpdates();
629
+ initializeNativeRenderer();
530
630
  registerUpdateIpc();
631
+ registerNativeRendererIpc();
531
632
  void productUpdates.checkAtStartup().catch(error => {
532
633
  appendDesktopLog(`[update:startup-error] ${error?.stack || error}`);
533
634
  });
@@ -0,0 +1,387 @@
1
+ import assert from 'node:assert/strict';
2
+ import { EventEmitter } from 'node:events';
3
+ import { PassThrough } from 'node:stream';
4
+ import protocol from './native-renderer-owner.cjs';
5
+
6
+ const {
7
+ HEADER_BYTES,
8
+ PROTOCOL_MAGIC,
9
+ PROTOCOL_VERSION,
10
+ REQUEST_TYPE,
11
+ RESPONSE_TYPE,
12
+ createNativeRendererOwner,
13
+ framePayload
14
+ } = protocol;
15
+
16
+ function stringU16(value) {
17
+ const bytes = Buffer.from(value, 'utf8');
18
+ const output = Buffer.allocUnsafe(2 + bytes.length);
19
+ output.writeUInt16LE(bytes.length, 0);
20
+ bytes.copy(output, 2);
21
+ return output;
22
+ }
23
+
24
+ function requestOwnerPayload(payload) {
25
+ const boardIdLength = payload.readUInt16LE(0);
26
+ return payload.subarray(0, 2 + boardIdLength + 32);
27
+ }
28
+
29
+ function responseReady(backend) {
30
+ return framePayload(RESPONSE_TYPE.ready, stringU16(backend));
31
+ }
32
+
33
+ function responseAccepted(requestType, ownerPayload) {
34
+ const payload = Buffer.allocUnsafe(2 + ownerPayload.length);
35
+ payload.writeUInt16LE(requestType, 0);
36
+ ownerPayload.copy(payload, 2);
37
+ return framePayload(RESPONSE_TYPE.accepted, payload);
38
+ }
39
+
40
+ function responseShutdown() {
41
+ return framePayload(RESPONSE_TYPE.shutdown, Buffer.alloc(0));
42
+ }
43
+
44
+ class FakeNativeRendererChild extends EventEmitter {
45
+ constructor(options = {}) {
46
+ super();
47
+ this.pid = options.pid || 4242;
48
+ this.exitCode = null;
49
+ this.stdin = new PassThrough();
50
+ this.stdout = new PassThrough();
51
+ this.stderr = new PassThrough();
52
+ this.requests = [];
53
+ this.input = Buffer.alloc(0);
54
+ this.holdRequestResponses = false;
55
+ this.autoExitOnShutdown = options.autoExitOnShutdown !== false;
56
+ this.autoExitOnKill = options.autoExitOnKill !== false;
57
+ this.killSignals = [];
58
+ this.stdin.on('data', chunk => this.handleInput(chunk));
59
+ setImmediate(() => this.stdout.write(responseReady('fake-d3d11')));
60
+ }
61
+
62
+ handleInput(chunk) {
63
+ this.input = this.input.length === 0 ? Buffer.from(chunk) : Buffer.concat([this.input, chunk]);
64
+ while (this.input.length >= HEADER_BYTES) {
65
+ const payloadLength = this.input.readUInt32LE(8);
66
+ const frameLength = HEADER_BYTES + payloadLength;
67
+ if (this.input.length < frameLength) return;
68
+ const frame = Buffer.from(this.input.subarray(0, frameLength));
69
+ this.input = this.input.subarray(frameLength);
70
+ this.requests.push(frame);
71
+ const requestType = frame.readUInt16LE(6);
72
+ const payload = frame.subarray(HEADER_BYTES);
73
+ if (requestType === REQUEST_TYPE.shutdown) {
74
+ this.stdout.write(responseShutdown());
75
+ if (this.autoExitOnShutdown) {
76
+ setImmediate(() => this.exit(0, null));
77
+ }
78
+ } else if (!this.holdRequestResponses) {
79
+ this.stdout.write(responseAccepted(requestType, requestOwnerPayload(payload)));
80
+ }
81
+ }
82
+ }
83
+
84
+ kill(signal) {
85
+ this.killSignals.push(signal);
86
+ if (this.autoExitOnKill) {
87
+ setImmediate(() => this.exit(signal === 'SIGKILL' ? 1 : 0, signal));
88
+ }
89
+ return true;
90
+ }
91
+
92
+ exit(code, signal) {
93
+ if (this.exitCode !== null) return;
94
+ this.exitCode = code;
95
+ this.emit('exit', code, signal);
96
+ }
97
+ }
98
+
99
+ let fakeChild = null;
100
+ function fakeSpawn() {
101
+ fakeChild = new FakeNativeRendererChild();
102
+ return fakeChild;
103
+ }
104
+ fakeSpawn.__allowNonWindowsForTest = true;
105
+
106
+ const statusEvents = [];
107
+ const owner = createNativeRendererOwner({
108
+ mode: 'mirror',
109
+ appIsPackaged: false,
110
+ executablePath: process.execPath,
111
+ spawnProcess: fakeSpawn,
112
+ readyTimeoutMs: 1_000,
113
+ responseTimeoutMs: 100,
114
+ stopTimeoutMs: 1_000,
115
+ onStatus: status => statusEvents.push(status)
116
+ });
117
+
118
+ const readyStatus = await owner.start();
119
+ assert.equal(readyStatus.ready, true, 'The native renderer owner did not accept READY.');
120
+ assert.equal(readyStatus.backend, 'fake-d3d11', 'READY backend parsing drifted.');
121
+ assert.equal(readyStatus.engineGeneration, 1, 'The first owned process must use generation 1.');
122
+ assert.equal(readyStatus.processId, 4242, 'The owner must report the exact child process id.');
123
+
124
+ const commonOwner = {
125
+ boardId: 'board-한글',
126
+ engineGeneration: readyStatus.engineGeneration,
127
+ boardEpoch: 7,
128
+ boardRevision: 11,
129
+ requestSequence: 13
130
+ };
131
+ const camera = {
132
+ x: 10,
133
+ y: -20,
134
+ z: 1_200,
135
+ near: 1,
136
+ far: 10_000,
137
+ fov: 45,
138
+ viewportW: 1_440,
139
+ viewportH: 960,
140
+ zoom: 1.25
141
+ };
142
+ const snapshotResult = await owner.sendSnapshot({
143
+ owner: commonOwner,
144
+ camera,
145
+ nodes: [{
146
+ handle: 1,
147
+ x: 100,
148
+ y: 200,
149
+ z: 0,
150
+ width: 320,
151
+ height: 180,
152
+ fillRgba: 0x89abcdef
153
+ }],
154
+ edges: [{
155
+ handle: 2,
156
+ sourceHandle: 1,
157
+ targetHandle: 1,
158
+ z: -1,
159
+ width: 3,
160
+ colorRgba: 0x102030ff,
161
+ points: [{ x: 100, y: 200 }, { x: 260, y: 290 }]
162
+ }]
163
+ });
164
+ assert.equal(snapshotResult.accepted, true, 'A valid snapshot was not acknowledged.');
165
+ assert.equal(snapshotResult.acceptedFrames, 1, 'Accepted snapshot count drifted.');
166
+
167
+ const snapshotFrame = fakeChild.requests[0];
168
+ assert.equal(snapshotFrame.readUInt32LE(0), PROTOCOL_MAGIC, 'Snapshot magic drifted.');
169
+ assert.equal(snapshotFrame.readUInt16LE(4), PROTOCOL_VERSION, 'Snapshot version drifted.');
170
+ assert.equal(snapshotFrame.readUInt16LE(6), REQUEST_TYPE.snapshot, 'Snapshot request type drifted.');
171
+ assert.equal(snapshotFrame.readUInt32LE(8), snapshotFrame.length - HEADER_BYTES,
172
+ 'Snapshot payload length does not match its frame.');
173
+ let offset = HEADER_BYTES;
174
+ const boardIdLength = snapshotFrame.readUInt16LE(offset);
175
+ offset += 2;
176
+ assert.equal(snapshotFrame.toString('utf8', offset, offset + boardIdLength), commonOwner.boardId,
177
+ 'UTF-8 board id encoding drifted.');
178
+ offset += boardIdLength;
179
+ assert.equal(snapshotFrame.readBigUInt64LE(offset), 1n, 'Engine generation encoding drifted.');
180
+ assert.equal(snapshotFrame.readBigUInt64LE(offset + 8), 7n, 'Board epoch encoding drifted.');
181
+ assert.equal(snapshotFrame.readBigUInt64LE(offset + 16), 11n, 'Board revision encoding drifted.');
182
+ assert.equal(snapshotFrame.readBigUInt64LE(offset + 24), 13n, 'Request sequence encoding drifted.');
183
+ offset += 32;
184
+ assert.equal(snapshotFrame.readDoubleLE(offset), camera.x, 'Camera x encoding drifted.');
185
+ assert.equal(snapshotFrame.readDoubleLE(offset + 56), camera.viewportH, 'Camera viewport height encoding drifted.');
186
+ assert.equal(snapshotFrame.readDoubleLE(offset + 64), camera.zoom, 'Camera zoom encoding drifted.');
187
+ offset += 72;
188
+ assert.equal(snapshotFrame.readUInt32LE(offset), 1, 'Snapshot node count drifted.');
189
+ assert.equal(snapshotFrame.readUInt32LE(offset + 4), 1, 'Snapshot edge count drifted.');
190
+ assert.equal(snapshotFrame.readUInt32LE(offset + 8), 2, 'Snapshot point count drifted.');
191
+ offset += 12;
192
+ assert.equal(snapshotFrame.readUInt32LE(offset), 1, 'Node handle encoding drifted.');
193
+ assert.equal(snapshotFrame.readDoubleLE(offset + 4), 100, 'Node x encoding drifted.');
194
+ assert.equal(snapshotFrame.readUInt32LE(offset + 44), 0x89abcdef, 'Node color encoding drifted.');
195
+ offset += 48;
196
+ assert.equal(snapshotFrame.readUInt32LE(offset), 2, 'Edge handle encoding drifted.');
197
+ assert.equal(snapshotFrame.readUInt32LE(offset + 4), 1, 'Edge source handle encoding drifted.');
198
+ assert.equal(snapshotFrame.readUInt32LE(offset + 8), 1, 'Edge target handle encoding drifted.');
199
+ assert.equal(snapshotFrame.readDoubleLE(offset + 12), -1, 'Edge z encoding drifted.');
200
+ assert.equal(snapshotFrame.readDoubleLE(offset + 20), 3, 'Edge width encoding drifted.');
201
+ assert.equal(snapshotFrame.readUInt32LE(offset + 28), 0x102030ff, 'Edge color encoding drifted.');
202
+ assert.equal(snapshotFrame.readUInt32LE(offset + 32), 2, 'Edge point count encoding drifted.');
203
+ assert.equal(snapshotFrame.readDoubleLE(offset + 36), 100, 'First edge point x encoding drifted.');
204
+ assert.equal(snapshotFrame.readDoubleLE(offset + 60), 290, 'Second edge point y encoding drifted.');
205
+ assert.equal(offset + 68, snapshotFrame.length, 'Snapshot record size drifted.');
206
+
207
+ const requestCountBeforeStaleFrame = fakeChild.requests.length;
208
+ const staleResult = await owner.sendCamera({
209
+ owner: { ...commonOwner, engineGeneration: readyStatus.engineGeneration + 1, requestSequence: 14 },
210
+ camera
211
+ });
212
+ assert.equal(staleResult.accepted, false, 'A stale renderer generation was accepted.');
213
+ assert.equal(staleResult.reason, 'native-renderer-engine-generation-mismatch',
214
+ 'Stale generation rejection reason drifted.');
215
+ assert.equal(fakeChild.requests.length, requestCountBeforeStaleFrame,
216
+ 'A stale renderer generation reached the child process.');
217
+
218
+ const cameraResult = await owner.sendCamera({
219
+ owner: { ...commonOwner, requestSequence: 15 },
220
+ camera: { ...camera, x: 42 }
221
+ });
222
+ assert.equal(cameraResult.accepted, true, 'A valid camera frame was not acknowledged.');
223
+ assert.equal(cameraResult.acceptedFrames, 2, 'Accepted camera count drifted.');
224
+ assert.equal(cameraResult.rejectedFrames, 1, 'Rejected stale frame count drifted.');
225
+
226
+ const detachResult = await owner.detach({ ...commonOwner, requestSequence: 16 });
227
+ assert.equal(detachResult.accepted, true, 'A valid board detach was not acknowledged.');
228
+ assert.equal(detachResult.acceptedFrames, 2, 'Detach must not count as a rendered frame.');
229
+
230
+ fakeChild.holdRequestResponses = true;
231
+ const pendingKeepAlive = setInterval(() => undefined, 25);
232
+ const boundedResults = await Promise.all(Array.from({ length: 9 }, (_, index) => owner.sendCamera({
233
+ owner: { ...commonOwner, requestSequence: 100 + index },
234
+ camera
235
+ })));
236
+ clearInterval(pendingKeepAlive);
237
+ assert.equal(boundedResults.filter(result => result.reason === 'native-renderer-pending-limit').length, 1,
238
+ 'The owner must reject work beyond its bounded pending request count.');
239
+ assert.equal(boundedResults.filter(result => result.reason === 'native-renderer-response-timeout').length, 8,
240
+ 'The bounded pending requests did not resolve through their timeout path.');
241
+ fakeChild.holdRequestResponses = false;
242
+
243
+ const stoppedStatus = await owner.stop();
244
+ assert.equal(stoppedStatus.ready, false, 'Stopped renderer still reports ready.');
245
+ assert.equal(stoppedStatus.processId, 0, 'Stopped renderer still reports a child pid.');
246
+ assert.equal(fakeChild.requests.at(-1).readUInt16LE(6), REQUEST_TYPE.shutdown,
247
+ 'Owned shutdown did not use the protocol shutdown frame.');
248
+
249
+ const missingOwner = createNativeRendererOwner({
250
+ mode: 'mirror',
251
+ executablePath: `${process.execPath}.definitely-missing`,
252
+ spawnProcess: fakeSpawn
253
+ });
254
+ const missingStatus = await missingOwner.start();
255
+ assert.equal(missingStatus.ready, false, 'A missing renderer executable reported ready.');
256
+ assert.match(missingStatus.lastError, /^native-renderer-executable-missing:/,
257
+ 'A missing renderer executable must fail open with a diagnosable status.');
258
+
259
+ function controlledSpawnFactory(options) {
260
+ const children = [];
261
+ const spawnControlled = () => {
262
+ const child = new FakeNativeRendererChild({
263
+ ...options,
264
+ pid: (options.pid || 5000) + children.length
265
+ });
266
+ children.push(child);
267
+ return child;
268
+ };
269
+ spawnControlled.__allowNonWindowsForTest = true;
270
+ return { children, spawnControlled };
271
+ }
272
+
273
+ const stoppingFactory = controlledSpawnFactory({
274
+ pid: 5100,
275
+ autoExitOnShutdown: false,
276
+ autoExitOnKill: true
277
+ });
278
+ const stoppingOwner = createNativeRendererOwner({
279
+ mode: 'mirror',
280
+ executablePath: process.execPath,
281
+ spawnProcess: stoppingFactory.spawnControlled,
282
+ readyTimeoutMs: 1_000,
283
+ responseTimeoutMs: 100,
284
+ stopTimeoutMs: 1_000,
285
+ forceExitTimeoutMs: 1_000
286
+ });
287
+ const stoppingReady = await stoppingOwner.start();
288
+ const stoppingChild = stoppingFactory.children[0];
289
+ const stoppingPromise = stoppingOwner.stop();
290
+ await new Promise(resolve => setTimeout(resolve, 10));
291
+ const sendWhileStopping = await stoppingOwner.sendCamera({
292
+ owner: {
293
+ ...commonOwner,
294
+ engineGeneration: stoppingReady.engineGeneration,
295
+ requestSequence: 201
296
+ },
297
+ camera
298
+ });
299
+ assert.equal(sendWhileStopping.accepted, false, 'A frame was accepted while shutdown was in progress.');
300
+ assert.equal(sendWhileStopping.reason, 'native-renderer-stopping',
301
+ 'A frame during shutdown did not report the lifecycle rejection.');
302
+ assert.equal(stoppingFactory.children.length, 1,
303
+ 'sendOwned spawned a replacement native renderer while the owned child was stopping.');
304
+ assert.equal(stoppingOwner.isRunning(), true,
305
+ 'The owner discarded its child before the child confirmed exit.');
306
+ stoppingChild.exit(0, null);
307
+ await stoppingPromise;
308
+ assert.equal(stoppingOwner.isRunning(), false, 'Confirmed child exit did not complete shutdown.');
309
+
310
+ const delayedKillFactory = controlledSpawnFactory({
311
+ pid: 5200,
312
+ autoExitOnShutdown: false,
313
+ autoExitOnKill: false
314
+ });
315
+ const delayedKillOwner = createNativeRendererOwner({
316
+ mode: 'mirror',
317
+ executablePath: process.execPath,
318
+ spawnProcess: delayedKillFactory.spawnControlled,
319
+ readyTimeoutMs: 1_000,
320
+ stopTimeoutMs: 20,
321
+ forceExitTimeoutMs: 1_000
322
+ });
323
+ await delayedKillOwner.start();
324
+ const delayedKillChild = delayedKillFactory.children[0];
325
+ let delayedStopSettled = false;
326
+ const delayedStopPromise = delayedKillOwner.stop().then(
327
+ status => {
328
+ delayedStopSettled = true;
329
+ return status;
330
+ },
331
+ error => {
332
+ delayedStopSettled = true;
333
+ throw error;
334
+ }
335
+ );
336
+ await new Promise(resolve => setTimeout(resolve, 60));
337
+ assert.ok(delayedKillChild.killSignals.includes('SIGKILL'),
338
+ 'The owner did not force-kill a child that ignored graceful shutdown.');
339
+ assert.equal(delayedStopSettled, false,
340
+ 'The stop promise settled when SIGKILL was sent instead of waiting for child exit.');
341
+ assert.equal(delayedKillOwner.getStatus().processId, delayedKillChild.pid,
342
+ 'The owner cleared the child pid before receiving its exit event.');
343
+ delayedKillChild.exit(1, 'SIGKILL');
344
+ await delayedStopPromise;
345
+ assert.equal(delayedKillOwner.getStatus().processId, 0,
346
+ 'The owner retained the child after a confirmed forced exit.');
347
+
348
+ const stuckFactory = controlledSpawnFactory({
349
+ pid: 5300,
350
+ autoExitOnShutdown: false,
351
+ autoExitOnKill: false
352
+ });
353
+ const stuckOwner = createNativeRendererOwner({
354
+ mode: 'mirror',
355
+ executablePath: process.execPath,
356
+ spawnProcess: stuckFactory.spawnControlled,
357
+ readyTimeoutMs: 1_000,
358
+ stopTimeoutMs: 20,
359
+ forceExitTimeoutMs: 20
360
+ });
361
+ await stuckOwner.start();
362
+ const stuckChild = stuckFactory.children[0];
363
+ await assert.rejects(
364
+ stuckOwner.stop(),
365
+ /native-renderer-force-exit-timeout/,
366
+ 'A child with no exit confirmation must block shutdown completion.'
367
+ );
368
+ assert.equal(stuckOwner.getStatus().processId, stuckChild.pid,
369
+ 'A force-exit timeout discarded the still-running child reference.');
370
+ assert.equal(stuckOwner.isRunning(), true,
371
+ 'A force-exit timeout reported a still-running child as stopped.');
372
+ stuckChild.exit(1, 'SIGKILL');
373
+
374
+ assert.ok(statusEvents.some(status => status.ready && status.backend === 'fake-d3d11'),
375
+ 'READY status was not projected to the Electron owner.');
376
+
377
+ console.log(JSON.stringify({
378
+ ok: true,
379
+ backend: readyStatus.backend,
380
+ acceptedFrames: cameraResult.acceptedFrames,
381
+ rejectedFrames: cameraResult.rejectedFrames,
382
+ requestCount: fakeChild.requests.length,
383
+ missingHostFailOpen: true,
384
+ sendDuringStopBlocked: true,
385
+ forcedExitConfirmed: true,
386
+ stuckChildRetained: true
387
+ }));