@dimina-kit/devtools 0.4.0-dev.20260701155140 → 0.4.0-dev.20260702164903

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 (63) hide show
  1. package/dist/main/app/max-listeners-diagnostic.js +5 -1
  2. package/dist/main/index.bundle.js +1533 -1237
  3. package/dist/main/ipc/bridge-router.d.ts +28 -0
  4. package/dist/main/ipc/bridge-router.js +175 -50
  5. package/dist/main/ipc/session-listener-bag.d.ts +41 -0
  6. package/dist/main/ipc/session-listener-bag.js +40 -0
  7. package/dist/main/ipc/simulator.js +5 -1
  8. package/dist/main/services/elements-forward/index.js +19 -0
  9. package/dist/main/services/network-forward/index.js +13 -0
  10. package/dist/main/services/views/destroy-child-view.d.ts +9 -0
  11. package/dist/main/services/views/destroy-child-view.js +23 -0
  12. package/dist/main/services/views/host-toolbar-view.d.ts +21 -0
  13. package/dist/main/services/views/host-toolbar-view.js +249 -0
  14. package/dist/main/services/views/inject-when-ready.d.ts +41 -0
  15. package/dist/main/services/views/inject-when-ready.js +62 -0
  16. package/dist/main/services/views/native-simulator-devtools-host.d.ts +26 -0
  17. package/dist/main/services/views/native-simulator-devtools-host.js +484 -0
  18. package/dist/main/services/views/native-simulator-view.d.ts +28 -0
  19. package/dist/main/services/views/native-simulator-view.js +428 -0
  20. package/dist/main/services/views/overlay-panels-view.d.ts +29 -0
  21. package/dist/main/services/views/overlay-panels-view.js +150 -0
  22. package/dist/main/services/views/placement-reconciler.d.ts +73 -0
  23. package/dist/main/services/views/placement-reconciler.js +120 -0
  24. package/dist/main/services/views/resolve-project-editor-target.d.ts +13 -0
  25. package/dist/main/services/views/resolve-project-editor-target.js +33 -0
  26. package/dist/main/services/views/view-manager.d.ts +20 -16
  27. package/dist/main/services/views/view-manager.js +66 -1545
  28. package/dist/main/services/views/workbench-view.d.ts +15 -0
  29. package/dist/main/services/views/workbench-view.js +170 -0
  30. package/dist/main/services/workspace/workspace-service.js +1 -1
  31. package/dist/preload/windows/host-toolbar-runtime.cjs.map +2 -2
  32. package/dist/preload/windows/main.cjs +10 -1
  33. package/dist/preload/windows/main.cjs.map +2 -2
  34. package/dist/preload/windows/simulator.cjs +6 -0
  35. package/dist/preload/windows/simulator.cjs.map +2 -2
  36. package/dist/preload/windows/simulator.js +6 -0
  37. package/dist/renderer/assets/index-B-Dqb7G0.js +49 -0
  38. package/dist/renderer/assets/{input-Djg2NR4U.js → input-Cjk0wSEh.js} +2 -2
  39. package/dist/renderer/assets/ipc-transport-D5dKIti1.css +1 -0
  40. package/dist/renderer/assets/{ipc-transport-Be-AzCub.js → ipc-transport-OZS-KmOW.js} +2 -2
  41. package/dist/renderer/assets/{popover-eDUh4NYe.js → popover-CQVvOe90.js} +2 -2
  42. package/dist/renderer/assets/{select-DesFfkVV.js → select-BQDXi5ih.js} +2 -2
  43. package/dist/renderer/assets/{settings-api-HQ7ZFPWi.js → settings-api-B6x2mhaD.js} +2 -2
  44. package/dist/renderer/assets/{settings-CRmO7zUG.js → settings-tP3Px2KR.js} +2 -2
  45. package/dist/renderer/assets/{workbenchSettings-CZBWsliT.js → workbenchSettings-Bim9ol9R.js} +2 -2
  46. package/dist/renderer/entries/main/index.html +6 -6
  47. package/dist/renderer/entries/popover/index.html +5 -5
  48. package/dist/renderer/entries/settings/index.html +5 -5
  49. package/dist/renderer/entries/workbench-settings/index.html +4 -4
  50. package/dist/shared/bridge-channels.d.ts +17 -0
  51. package/dist/shared/bridge-channels.js +9 -0
  52. package/dist/shared/ipc-channels.d.ts +1 -0
  53. package/dist/shared/ipc-channels.js +6 -0
  54. package/dist/shared/ipc-schemas.d.ts +6 -0
  55. package/dist/shared/ipc-schemas.js +10 -0
  56. package/dist/simulator/assets/{bridge-channels-BUQ5AbvJ.js → bridge-channels-zhsumfky.js} +2 -2
  57. package/dist/simulator/assets/{device-shell-CT3LRnTB.js → device-shell-CUl0ILfn.js} +2 -2
  58. package/dist/simulator/assets/{simulator-BizhGGE3.js → simulator-Dz8iGcgy.js} +5 -5
  59. package/dist/simulator/assets/{simulator-mini-app-BfNuDF3R.js → simulator-mini-app-Bt639XL_.js} +2 -2
  60. package/dist/simulator/simulator.html +2 -1
  61. package/package.json +5 -5
  62. package/dist/renderer/assets/index-jDrNHghH.js +0 -49
  63. package/dist/renderer/assets/ipc-transport-C6-KPMqw.css +0 -1
@@ -25,6 +25,29 @@ export interface RenderEvent {
25
25
  * Lets the current-page panel push the route without a separate lookup. */
26
26
  pagePath?: string;
27
27
  }
28
+ /**
29
+ * Point-in-time counts of every resource class RouterState owns. Leak coverage
30
+ * (unit + e2e) asserts EXACT equality of this ledger around a churn cycle —
31
+ * coarse memory sampling cannot see a leaked listener or a stale map entry, so
32
+ * the owner of the bookkeeping reports precise counts instead.
33
+ */
34
+ export interface BridgeResourceCensus {
35
+ appSessions: number;
36
+ pageSessions: number;
37
+ serviceWcBindings: number;
38
+ /** Distinct live simulator webContents currently hosting ≥1 session. */
39
+ simulatorWcs: number;
40
+ /** Total session memberships across all simulator wcs. */
41
+ simulatorWcBindings: number;
42
+ renderWcBindings: number;
43
+ pendingApiCalls: number;
44
+ /**
45
+ * `listenerCount('destroyed')` per unique live simulator wc (keyed by wc id).
46
+ * One teardown hook per live session — a count above the hosted-session count
47
+ * is the one-dead-listener-per-soft-reload leak class.
48
+ */
49
+ simulatorDestroyedListeners: Record<number, number>;
50
+ }
28
51
  export interface BridgeRouterHandle {
29
52
  /** Whether native-host mode is on (main is the source of truth). */
30
53
  isNativeHost(): boolean;
@@ -71,6 +94,11 @@ export interface BridgeRouterHandle {
71
94
  * error channel.
72
95
  */
73
96
  disposeSessionsForSimulator?(simulatorWcId: number): Promise<void>;
97
+ /**
98
+ * The router's resource ledger (see BridgeResourceCensus). Optional so the
99
+ * many partial BridgeRouterHandle test mocks don't each have to stub it.
100
+ */
101
+ census?(): BridgeResourceCensus;
74
102
  /**
75
103
  * The flag-gated debugTap (§7) over the bridge message stream. Exposed so a
76
104
  * hidden devtools panel / automation can read `.entries()` when
@@ -4,6 +4,7 @@ import { pathToFileURL } from 'node:url';
4
4
  import { BRIDGE_CHANNELS as C, SIMULATOR_EVENTS as E, deviceInfoToHostEnv } from '../../shared/bridge-channels.js';
5
5
  import { isPersistentSimulatorApi } from '../../shared/simulator-api-metadata.js';
6
6
  import { devtoolsPackageRoot } from '../utils/paths.js';
7
+ import { createSessionListenerBag } from './session-listener-bag.js';
7
8
  import { createDebugTap } from '@dimina-kit/electron-deck/main';
8
9
  import { startDiminaResourceServer } from '../services/dimina-resource-server.js';
9
10
  import { buildServiceHostSpawnUrl, createServiceHostWindow, navigateServiceHost, serviceHostSpec, } from '../windows/service-host-window/create.js';
@@ -147,7 +148,8 @@ export function installBridgeRouter(ctx) {
147
148
  const state = {
148
149
  appSessions: new Map(),
149
150
  pageSessions: new Map(),
150
- wcIdToAppSessionId: new Map(),
151
+ serviceWcIdToAppSessionId: new Map(),
152
+ simulatorWcIdToAppSessionIds: new Map(),
151
153
  wcIdToBridgeId: new Map(),
152
154
  pendingApiCalls: new Map(),
153
155
  pool: null,
@@ -299,8 +301,37 @@ export function installBridgeRouter(ctx) {
299
301
  return Promise.all(ids.map((id) => disposeAppSession(state, id))).then(() => { });
300
302
  },
301
303
  debugTap: state.debugTap,
304
+ census: () => {
305
+ const simulatorDestroyedListeners = {};
306
+ let simulatorWcBindings = 0;
307
+ for (const ids of state.simulatorWcIdToAppSessionIds.values())
308
+ simulatorWcBindings += ids.size;
309
+ for (const ap of state.appSessions.values()) {
310
+ const wc = ap.simulatorWc;
311
+ if (!wc.isDestroyed() && simulatorDestroyedListeners[wc.id] === undefined) {
312
+ simulatorDestroyedListeners[wc.id] = wc.listenerCount('destroyed');
313
+ }
314
+ }
315
+ return {
316
+ appSessions: state.appSessions.size,
317
+ pageSessions: state.pageSessions.size,
318
+ serviceWcBindings: state.serviceWcIdToAppSessionId.size,
319
+ simulatorWcs: state.simulatorWcIdToAppSessionIds.size,
320
+ simulatorWcBindings,
321
+ renderWcBindings: state.wcIdToBridgeId.size,
322
+ pendingApiCalls: state.pendingApiCalls.size,
323
+ simulatorDestroyedListeners,
324
+ };
325
+ },
302
326
  };
303
327
  ctx.bridge = bridgeHandle;
328
+ // e2e resource-census probe: Playwright's electronApp.evaluate() reads the
329
+ // router's ledger straight off this main-process global — no IPC surface
330
+ // needed. Test builds only.
331
+ if (process.env.NODE_ENV === 'test') {
332
+ ;
333
+ globalThis.__diminaResourceCensus = () => bridgeHandle.census?.();
334
+ }
304
335
  // Always-on guest console fan-out. Owns `ctx.guestConsole` (the sink the
305
336
  // consoleLog case below routes to) so that render-layer console output is
306
337
  // mirrored into the service host's own console — surfacing it in the embedded
@@ -322,8 +353,7 @@ export function installBridgeRouter(ctx) {
322
353
  const ap = state.appSessions.get(payload.appSessionId);
323
354
  if (!ap)
324
355
  return;
325
- const senderApp = appByWc(state, event.sender);
326
- if (!senderApp || senderApp.appSessionId !== ap.appSessionId)
356
+ if (!senderBoundToSession(state, event.sender, ap))
327
357
  return;
328
358
  if (ap.pages.has(payload.bridgeId)) {
329
359
  ap.activeBridgeId = payload.bridgeId;
@@ -340,8 +370,7 @@ export function installBridgeRouter(ctx) {
340
370
  const ap = state.appSessions.get(payload.appSessionId);
341
371
  if (!ap)
342
372
  return;
343
- const senderApp = appByWc(state, event.sender);
344
- if (!senderApp || senderApp.appSessionId !== ap.appSessionId)
373
+ if (!senderBoundToSession(state, event.sender, ap))
345
374
  return;
346
375
  ap.pageStack = payload.stack;
347
376
  };
@@ -366,8 +395,7 @@ export function installBridgeRouter(ctx) {
366
395
  // its tab (mirrors the default path's postMessage(pageUnload) eviction).
367
396
  if (payload.event === 'pageUnload' && ctx.appData) {
368
397
  const ap = state.appSessions.get(payload.appSessionId);
369
- const senderApp = appByWc(state, event.sender);
370
- if (ap && senderApp && senderApp.appSessionId === ap.appSessionId) {
398
+ if (ap && senderBoundToSession(state, event.sender, ap)) {
371
399
  ctx.appData.evictBridge(ap.appId, payload.bridgeId);
372
400
  }
373
401
  }
@@ -384,9 +412,11 @@ export function installBridgeRouter(ctx) {
384
412
  if (!target)
385
413
  return;
386
414
  // Only the app's service window or simulator window can issue dispose.
387
- const senderApp = appByWc(state, event.sender);
388
- if (senderApp && senderApp.appSessionId !== target.appSessionId) {
389
- console.warn(`[bridge-router] DISPOSE rejected: sender belongs to ${senderApp.appSessionId}, target ${target.appSessionId}`);
415
+ // (Unknown senders pass — lenient by design; a simulator WCV is authorized
416
+ // for every session it hosts, so an older session's own dispose stays
417
+ // valid while a newer spawn shares the wc.)
418
+ if (!senderBoundToSession(state, event.sender, target) && appByWc(state, event.sender)) {
419
+ console.warn(`[bridge-router] DISPOSE rejected: sender not bound to target ${target.appSessionId}`);
390
420
  return;
391
421
  }
392
422
  // AppData bridge eviction happens inside disposeAppSession (single
@@ -581,8 +611,8 @@ async function handleSpawn(state, ctx, event, opts) {
581
611
  pages: new Map(),
582
612
  activeBridgeId: null,
583
613
  poolEntryId,
584
- onServiceClosed: null,
585
614
  onServiceBoot: null,
615
+ listenerBag: createSessionListenerBag(),
586
616
  registryHandle: null,
587
617
  };
588
618
  const rootPage = {
@@ -601,8 +631,8 @@ async function handleSpawn(state, ctx, event, opts) {
601
631
  state.appSessions.set(appSessionId, appSession);
602
632
  state.pageSessions.set(bridgeId, rootPage);
603
633
  appSession.pages.set(bridgeId, rootPage);
604
- bindWc(state.wcIdToAppSessionId, serviceWindow.webContents, appSessionId);
605
- bindWc(state.wcIdToAppSessionId, simulatorWc, appSessionId);
634
+ bindWc(state.serviceWcIdToAppSessionId, serviceWindow.webContents, appSessionId);
635
+ bindSimulatorWc(state.simulatorWcIdToAppSessionIds, simulatorWc, appSessionId);
606
636
  // Track the service-host webContents as a Connection (foundation.md §4.3) and
607
637
  // own this session's serviceWc→appSessionId binding on its CURRENT lifetime
608
638
  // segment. On a pooled-window REUSE, `disposeAppSession` calls
@@ -614,16 +644,15 @@ async function handleSpawn(state, ctx, event, opts) {
614
644
  // wc outlives individual app sessions — cross-connection state, §4.4 — so it
615
645
  // cannot hang off any single session's segment.)
616
646
  state.connections.acquire(serviceWindow.webContents).own(() => {
617
- if (state.wcIdToAppSessionId.get(appSession.serviceWc.id) === appSessionId) {
618
- state.wcIdToAppSessionId.delete(appSession.serviceWc.id);
647
+ if (state.serviceWcIdToAppSessionId.get(appSession.serviceWc.id) === appSessionId) {
648
+ state.serviceWcIdToAppSessionId.delete(appSession.serviceWc.id);
619
649
  }
620
650
  });
621
651
  appSession.registryHandle = ctx.registry.add(() => disposeAppSession(state, appSessionId));
622
652
  const onServiceClosed = () => {
623
653
  void disposeAppSession(state, appSessionId, { serviceAlreadyClosed: true });
624
654
  };
625
- appSession.onServiceClosed = onServiceClosed;
626
- serviceWindow.once('closed', onServiceClosed);
655
+ appSession.listenerBag.once(serviceWindow, 'closed', onServiceClosed);
627
656
  // The simulator WCV owns the app's UI lifetime. When it is destroyed —
628
657
  // project close (`views.disposeAll`/detach) or a DeviceShell respawn
629
658
  // (`attachNativeSimulator`, e.g. watcher hot reload) — the guest never gets
@@ -636,7 +665,7 @@ async function handleSpawn(state, ctx, event, opts) {
636
665
  const onSimulatorDestroyed = () => {
637
666
  void disposeAppSession(state, appSessionId);
638
667
  };
639
- simulatorWc.once('destroyed', onSimulatorDestroyed);
668
+ appSession.listenerBag.once(simulatorWc, 'destroyed', onSimulatorDestroyed);
640
669
  // The simulator WCV may have been torn down DURING this spawn's awaits
641
670
  // (loadAppConfig / resource server / service-host creation) — e.g. a project
642
671
  // close/switch that ran while we were mid-flight. In that case its 'destroyed'
@@ -703,8 +732,7 @@ async function handlePageOpen(state, event, opts) {
703
732
  if (!ap)
704
733
  throw new Error(`[bridge-router] PAGE_OPEN unknown appSession ${opts.appSessionId}`);
705
734
  // Only the simulator window owning the app can open additional pages.
706
- const senderApp = appByWc(state, event.sender);
707
- if (!senderApp || senderApp.appSessionId !== opts.appSessionId) {
735
+ if (!senderBoundToSession(state, event.sender, ap)) {
708
736
  throw new Error('[bridge-router] PAGE_OPEN rejected: caller not bound to app session');
709
737
  }
710
738
  const pagePath = normalizePagePath(opts.pagePath);
@@ -739,8 +767,7 @@ function handlePageClose(state, sender, payload) {
739
767
  const ap = state.appSessions.get(page.appSessionId);
740
768
  if (!ap)
741
769
  return;
742
- const senderApp = appByWc(state, sender);
743
- if (!senderApp || senderApp.appSessionId !== ap.appSessionId) {
770
+ if (!senderBoundToSession(state, sender, ap)) {
744
771
  console.warn('[bridge-router] PAGE_CLOSE rejected: caller not bound to app session');
745
772
  return;
746
773
  }
@@ -750,8 +777,7 @@ function handlePageLifecycle(state, sender, payload) {
750
777
  const ap = state.appSessions.get(payload.appSessionId);
751
778
  if (!ap)
752
779
  return;
753
- const senderApp = appByWc(state, sender);
754
- if (!senderApp || senderApp.appSessionId !== ap.appSessionId)
780
+ if (!senderBoundToSession(state, sender, ap))
755
781
  return;
756
782
  forwardToService(ap, {
757
783
  type: payload.event,
@@ -763,8 +789,7 @@ function handleNavCallback(state, sender, payload) {
763
789
  const ap = state.appSessions.get(payload.appSessionId);
764
790
  if (!ap)
765
791
  return;
766
- const senderApp = appByWc(state, sender);
767
- if (!senderApp || senderApp.appSessionId !== ap.appSessionId)
792
+ if (!senderBoundToSession(state, sender, ap))
768
793
  return;
769
794
  const result = { errMsg: payload.errMsg };
770
795
  if (payload.ok) {
@@ -794,23 +819,50 @@ async function bootServiceHost(state, ap, ctx) {
794
819
  reportLogicLoadFailure(ap, ctx);
795
820
  return;
796
821
  }
797
- // serviceLoaded is flipped only when service responds with
798
- // `serviceResourceLoaded`; see handleContainerMsg. Setting it here would
799
- // race a per-page `resourceLoaded` ahead of the service-side handler.
800
- forwardToService(ap, makeLoadResource(ap, ap.pages.get(ap.appSessionId), 'service'));
822
+ // A root page absent from the compiled manifest — most commonly a page the
823
+ // developer deleted, then hot-reloaded to is not registered in logic.js.
824
+ // BOTH the service runtime AND the render runtime eager-`modRequire(pagePath)`
825
+ // it and throw `module <pagePath> not found`, which aborts the launch and
826
+ // leaves the simulator permanently blank. Gate BOTH sends and surface a
827
+ // WeChat-style diagnostic instead: the shell stays alive (an empty phone frame
828
+ // + a clear Console error) rather than a silent dead blank. Choosing a valid
829
+ // fallback page belongs at the renderer reload source (it owns the pagePath
830
+ // the render guest is spawned with); here we only refuse a load guaranteed to
831
+ // throw, so main and render never disagree about which page is live.
832
+ const rootPage = ap.pages.get(ap.appSessionId);
833
+ const rootMissing = !!rootPage && !pageInManifest(ap, rootPage.pagePath);
834
+ if (rootMissing) {
835
+ reportPageNotFound(ap, ctx, rootPage.pagePath);
836
+ }
837
+ else if (rootPage) {
838
+ // serviceLoaded is flipped only when service responds with
839
+ // `serviceResourceLoaded`; see handleContainerMsg. Setting it here would
840
+ // race a per-page `resourceLoaded` ahead of the service-side handler.
841
+ forwardToService(ap, makeLoadResource(ap, rootPage, 'service'));
842
+ }
801
843
  // Flush any render `loadResource` that arrived (renderHostReady) while
802
844
  // injection was in-flight — now that the bundle is confirmed present.
845
+ // `sendRenderLoadResource` refuses a page absent from the manifest, so a
846
+ // missing root never reaches the throwing render `modRequire`.
803
847
  for (const page of ap.pages.values()) {
804
- if (page.renderLoadPending) {
805
- page.renderLoadPending = false;
806
- sendRenderLoadResource(ap, page);
807
- }
848
+ if (!page.renderLoadPending)
849
+ continue;
850
+ page.renderLoadPending = false;
851
+ sendRenderLoadResource(ap, page);
808
852
  }
809
853
  }
810
854
  function sendRenderLoadResource(ap, page) {
811
- if (page.renderWc && !page.renderWc.isDestroyed()) {
812
- page.renderWc.send(C.TO_RENDER, { msg: makeLoadResource(ap, page, 'render') });
813
- }
855
+ if (!page.renderWc || page.renderWc.isDestroyed())
856
+ return;
857
+ // Single choke point for BOTH the boot flush above and the late
858
+ // `renderHostReady` direct-send in `routeFromRender`: never send a render load
859
+ // for a page absent from the compiled manifest. The render runtime
860
+ // eager-`modRequire`s the pagePath and would throw `module <pagePath> not
861
+ // found`, blanking the simulator (a page the developer deleted, then
862
+ // hot-reloaded to). bootServiceHost surfaces the one-shot diagnostic.
863
+ if (!pageInManifest(ap, page.pagePath))
864
+ return;
865
+ page.renderWc.send(C.TO_RENDER, { msg: makeLoadResource(ap, page, 'render') });
814
866
  }
815
867
  /** The compiled logic.js URL `injectLogicBundle` fetches (mode-dependent). */
816
868
  function logicBundleUrl(ap) {
@@ -857,6 +909,21 @@ function reportLogicLoadFailure(ap, ctx) {
857
909
  console.error(message);
858
910
  ctx.guestConsole?.emit({ source: 'service', level: 'error', args: [message] });
859
911
  }
912
+ /** Whether `pagePath` still exists in the app's freshly compiled manifest. */
913
+ function pageInManifest(ap, pagePath) {
914
+ return ap.manifest.pages.includes(normalizePagePath(pagePath));
915
+ }
916
+ /**
917
+ * WeChat-devtools-style "page does not exist" diagnostic. Emitted (main log +
918
+ * guest Console) when a mount targets a pagePath that is not in the compiled
919
+ * manifest — most commonly a page the developer deleted, then hot-reloaded.
920
+ */
921
+ function reportPageNotFound(ap, ctx, pagePath) {
922
+ const message = `Page[${pagePath}] not found. May be caused by: 1. Forgetting to add page route in app.json. `
923
+ + '2. Invoking Page() in async task.';
924
+ console.error(`[bridge-router] ${message}`);
925
+ ctx.guestConsole?.emit({ source: 'service', level: 'error', args: [message] });
926
+ }
860
927
  function maybeSendResourceLoaded(ap, page) {
861
928
  if (page.resourceLoadedSent || !ap.serviceLoaded || !page.renderLoaded)
862
929
  return;
@@ -1260,11 +1327,13 @@ function handleApiResponse(state, sender, payload) {
1260
1327
  clearTimeout(pending.timer);
1261
1328
  return;
1262
1329
  }
1263
- // Only the simulator window bound to that app may respond. Validate BEFORE
1330
+ // Only a simulator window bound to the OWNING session may respond. Membership
1331
+ // (senderBoundToSession), not sender-resolution equality: during a soft-reload
1332
+ // overlap the shared simulator wc still answers the outgoing session's
1333
+ // in-flight calls, which a latest-wins comparison would drop. Validate BEFORE
1264
1334
  // mutating pending state so a spoofed/foreign response can't tear down a
1265
1335
  // live subscription.
1266
- const senderApp = appByWc(state, sender);
1267
- if (!senderApp || senderApp.appSessionId !== ap.appSessionId) {
1336
+ if (!senderBoundToSession(state, sender, ap)) {
1268
1337
  console.warn('[bridge-router] API_RESPONSE rejected: sender not bound to app session');
1269
1338
  return;
1270
1339
  }
@@ -1395,9 +1464,20 @@ function pageFromMsg(state, ap, msg) {
1395
1464
  function appByWc(state, wc) {
1396
1465
  if (wc.isDestroyed())
1397
1466
  return undefined;
1398
- const appSessionId = state.wcIdToAppSessionId.get(wc.id);
1467
+ const appSessionId = state.serviceWcIdToAppSessionId.get(wc.id);
1399
1468
  if (appSessionId)
1400
1469
  return state.appSessions.get(appSessionId);
1470
+ // A simulator wc hosts several sessions during a soft reload; a message that
1471
+ // doesn't name its session belongs to the LATEST still-alive spawn (the Set
1472
+ // preserves spawn order).
1473
+ const hosted = state.simulatorWcIdToAppSessionIds.get(wc.id);
1474
+ if (hosted) {
1475
+ let latest;
1476
+ for (const id of hosted)
1477
+ latest = state.appSessions.get(id) ?? latest;
1478
+ if (latest)
1479
+ return latest;
1480
+ }
1401
1481
  const bridgeId = state.wcIdToBridgeId.get(wc.id);
1402
1482
  if (bridgeId) {
1403
1483
  const page = state.pageSessions.get(bridgeId);
@@ -1406,6 +1486,21 @@ function appByWc(state, wc) {
1406
1486
  }
1407
1487
  return undefined;
1408
1488
  }
1489
+ /**
1490
+ * Whether `sender` is authorized to control app session `ap`. A simulator WCV
1491
+ * is authorized for EVERY session it hosts (`simulatorWcIdToAppSessionIds` is
1492
+ * a membership set, so an older session's own control traffic stays valid
1493
+ * while a newer spawn boots on the same wc); any other sender (service host,
1494
+ * render guest) resolves one-to-one through `appByWc`.
1495
+ */
1496
+ function senderBoundToSession(state, sender, ap) {
1497
+ if (sender.isDestroyed())
1498
+ return false;
1499
+ const hosted = state.simulatorWcIdToAppSessionIds.get(sender.id);
1500
+ if (hosted)
1501
+ return hosted.has(ap.appSessionId);
1502
+ return appByWc(state, sender)?.appSessionId === ap.appSessionId;
1503
+ }
1409
1504
  function resolveAppByBridgeId(state, bridgeId) {
1410
1505
  if (state.appSessions.has(bridgeId))
1411
1506
  return state.appSessions.get(bridgeId);
@@ -1419,6 +1514,17 @@ function bindWc(map, wc, id) {
1419
1514
  return;
1420
1515
  map.set(wc.id, id);
1421
1516
  }
1517
+ /** Add a session to a simulator wc's hosted set (created on first bind). */
1518
+ function bindSimulatorWc(map, wc, id) {
1519
+ if (wc.isDestroyed())
1520
+ return;
1521
+ let hosted = map.get(wc.id);
1522
+ if (!hosted) {
1523
+ hosted = new Set();
1524
+ map.set(wc.id, hosted);
1525
+ }
1526
+ hosted.add(id);
1527
+ }
1422
1528
  function readBridgeId(msg) {
1423
1529
  return typeof msg.body?.bridgeId === 'string' ? msg.body.bridgeId : undefined;
1424
1530
  }
@@ -1483,6 +1589,14 @@ async function disposeAppSession(state, appSessionId, opts = {}) {
1483
1589
  state.pageSessions.delete(page.bridgeId);
1484
1590
  }
1485
1591
  ap.pages.clear();
1592
+ // Detach every session-scoped hook on emitters that outlive this session —
1593
+ // the shared simulator WCV's 'destroyed' and the service window's 'closed' —
1594
+ // BEFORE the window is released/closed below: a pool-recycled window must
1595
+ // not re-trigger this session's teardown from a stale 'closed', and the
1596
+ // simulator wc must not accumulate one dead hook per soft reload until the
1597
+ // MaxListeners warning fires. Idempotent with teardown being triggered BY
1598
+ // one of these hooks (removing a fired once() is a no-op).
1599
+ ap.listenerBag.dispose();
1486
1600
  if (ap.poolEntryId !== null && state.pool && !opts.serviceAlreadyClosed) {
1487
1601
  // Soft reuse (foundation.md §4.3): the pooled service-host webContents keeps
1488
1602
  // its wc.id but is about to run a NEW app session. Reset its Connection
@@ -1492,13 +1606,10 @@ async function disposeAppSession(state, appSessionId, opts = {}) {
1492
1606
  // clean segment. The connection object stays alive (it's reused), so this is
1493
1607
  // reset (not close); a real destroy still closes it via its 'destroyed' hook.
1494
1608
  state.connections.reset(ap.serviceWc.id);
1495
- // Return the window to the pool instead of closing it. Detach the per-spawn
1496
- // listeners first so the pool resetting/recycling (or a later pool-side
1497
- // destroy) can't re-trigger disposeAppSession or boot this disposed session
1498
- // into the next spawn's recycled window.
1499
- if (ap.onServiceClosed && !ap.serviceWindow.isDestroyed()) {
1500
- ap.serviceWindow.removeListener('closed', ap.onServiceClosed);
1501
- }
1609
+ // Return the window to the pool instead of closing it. Detach the boot
1610
+ // listener first (the 'closed' hook already came off with the listener-bag
1611
+ // dispose above) so a stale did-finish-load can't boot this disposed
1612
+ // session into the next spawn's recycled window.
1502
1613
  if (ap.onServiceBoot && !ap.serviceWindow.isDestroyed()) {
1503
1614
  ap.serviceWindow.webContents.removeListener('did-finish-load', ap.onServiceBoot);
1504
1615
  }
@@ -1518,8 +1629,22 @@ async function disposeAppSession(state, appSessionId, opts = {}) {
1518
1629
  else if (!opts.serviceAlreadyClosed && !ap.serviceWindow.isDestroyed()) {
1519
1630
  ap.serviceWindow.close();
1520
1631
  }
1521
- state.wcIdToAppSessionId.delete(ap.serviceWc.id);
1522
- state.wcIdToAppSessionId.delete(ap.simulatorWc.id);
1632
+ // Value-checked unbind for the service wc: this delete runs after the
1633
+ // pool-release await above, so on the pool path the next spawn may have
1634
+ // already re-acquired the SAME window and rebound its wc id — only remove
1635
+ // an entry that still names THIS session.
1636
+ if (state.serviceWcIdToAppSessionId.get(ap.serviceWc.id) === appSessionId) {
1637
+ state.serviceWcIdToAppSessionId.delete(ap.serviceWc.id);
1638
+ }
1639
+ // The simulator wc's hosted set sheds only this session; sessions that are
1640
+ // still live on the shared wc (the soft-reload survivor) keep their
1641
+ // membership. The map entry goes away with the last session.
1642
+ const hosted = state.simulatorWcIdToAppSessionIds.get(ap.simulatorWc.id);
1643
+ if (hosted) {
1644
+ hosted.delete(appSessionId);
1645
+ if (hosted.size === 0)
1646
+ state.simulatorWcIdToAppSessionIds.delete(ap.simulatorWc.id);
1647
+ }
1523
1648
  // Only the local fallback server needs closing; the dev-server base is owned
1524
1649
  // by the workspace session, not this app session.
1525
1650
  if (ap.resourceServer) {
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Session-scoped listener bookkeeping. An app session hangs lifecycle hooks on
3
+ * emitters it does NOT own — the shared simulator WebContentsView, a pooled
4
+ * service-host window — and those emitters outlive the session (one simulator
5
+ * wc soft-reloads through many sessions). Every such hook is registered
6
+ * through this bag so the session's single teardown chokepoint can detach them
7
+ * all with one `dispose()`; a hook left behind survives as a dead listener on
8
+ * the shared emitter, growing by one per soft reload until Node's MaxListeners
9
+ * warning fires.
10
+ */
11
+ type Listener = (...args: unknown[]) => void;
12
+ /**
13
+ * Structural surface of the Node/Electron emitters the bag manages.
14
+ * `removeListener(event, fn)` also detaches a `once(event, fn)` registration:
15
+ * Node's once wrapper keeps a `.listener` back-reference to `fn` and
16
+ * removeListener matches through it. `isDestroyed` covers Electron objects
17
+ * (WebContents / BrowserWindow) whose methods throw once destroyed.
18
+ */
19
+ export interface BagEmitter {
20
+ on(event: string, listener: Listener): unknown;
21
+ once(event: string, listener: Listener): unknown;
22
+ removeListener(event: string, listener: Listener): unknown;
23
+ isDestroyed?(): boolean;
24
+ }
25
+ export interface SessionListenerBag {
26
+ /** Attach `fn` via `emitter.on` and track it for dispose. No-op after dispose. */
27
+ on(emitter: BagEmitter, event: string, fn: Listener): void;
28
+ /** Attach `fn` via `emitter.once` and track it for dispose. No-op after dispose. */
29
+ once(emitter: BagEmitter, event: string, fn: Listener): void;
30
+ /**
31
+ * Detach every tracked listener that may still be attached. Emitters that
32
+ * report `isDestroyed()` are skipped — their listeners died with them, and
33
+ * touching a destroyed Electron object throws. Removing a `once` that
34
+ * already fired is a no-op, so dispose is safe to call FROM one of the
35
+ * bag's own hooks (teardown triggered by the hook itself). Idempotent.
36
+ */
37
+ dispose(): void;
38
+ }
39
+ export declare function createSessionListenerBag(): SessionListenerBag;
40
+ export {};
41
+ //# sourceMappingURL=session-listener-bag.d.ts.map
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Session-scoped listener bookkeeping. An app session hangs lifecycle hooks on
3
+ * emitters it does NOT own — the shared simulator WebContentsView, a pooled
4
+ * service-host window — and those emitters outlive the session (one simulator
5
+ * wc soft-reloads through many sessions). Every such hook is registered
6
+ * through this bag so the session's single teardown chokepoint can detach them
7
+ * all with one `dispose()`; a hook left behind survives as a dead listener on
8
+ * the shared emitter, growing by one per soft reload until Node's MaxListeners
9
+ * warning fires.
10
+ */
11
+ export function createSessionListenerBag() {
12
+ const records = [];
13
+ let disposed = false;
14
+ return {
15
+ on(emitter, event, fn) {
16
+ if (disposed)
17
+ return;
18
+ emitter.on(event, fn);
19
+ records.push({ emitter, event, fn });
20
+ },
21
+ once(emitter, event, fn) {
22
+ if (disposed)
23
+ return;
24
+ emitter.once(event, fn);
25
+ records.push({ emitter, event, fn });
26
+ },
27
+ dispose() {
28
+ if (disposed)
29
+ return;
30
+ disposed = true;
31
+ for (const { emitter, event, fn } of records) {
32
+ if (emitter.isDestroyed?.())
33
+ continue;
34
+ emitter.removeListener(event, fn);
35
+ }
36
+ records.length = 0;
37
+ },
38
+ };
39
+ }
40
+ //# sourceMappingURL=session-listener-bag.js.map
@@ -1,5 +1,5 @@
1
1
  import { ServiceHostChannel, SimulatorChannel, SimulatorCustomApiChannel } from '../../shared/ipc-channels.js';
2
- import { SimulatorAttachNativeSchema, SimulatorCustomApiInvokeSchema, SimulatorSetDeviceInfoSchema, } from '../../shared/ipc-schemas.js';
2
+ import { SimulatorAttachNativeSchema, SimulatorCustomApiInvokeSchema, SimulatorSetDeviceInfoSchema, SimulatorSoftReloadSchema, } from '../../shared/ipc-schemas.js';
3
3
  import { deviceInfoToHostEnv } from '../../shared/bridge-channels.js';
4
4
  import { validate } from '../utils/ipc-schema.js';
5
5
  import { IpcRegistry } from '../utils/ipc-registry.js';
@@ -8,6 +8,10 @@ export function registerSimulatorIpc(ctx) {
8
8
  .handle(SimulatorChannel.AttachNative, (_, ...args) => {
9
9
  const [simulatorUrl, simWidth] = validate(SimulatorChannel.AttachNative, SimulatorAttachNativeSchema, args);
10
10
  return ctx.views.attachNativeSimulator(simulatorUrl, simWidth);
11
+ })
12
+ .handle(SimulatorChannel.SoftReload, (_, ...args) => {
13
+ const [simulatorUrl] = validate(SimulatorChannel.SoftReload, SimulatorSoftReloadSchema, args);
14
+ return ctx.views.softReloadNativeSimulator(simulatorUrl);
11
15
  })
12
16
  .handle(SimulatorChannel.Detach, () => {
13
17
  ctx.views.detachSimulator();
@@ -49,6 +49,7 @@
49
49
  * hook + dispatch scripts) as self-contained code.
50
50
  */
51
51
  import { webContents as electronWebContents } from 'electron';
52
+ import { isFrontendSettled } from '../views/inject-when-ready.js';
52
53
  /**
53
54
  * CDP domains that target the RENDER GUEST'S document tree. A command in one of
54
55
  * these is intercepted and sent to the active render guest's debugger instead of
@@ -274,6 +275,12 @@ export function installElementsForward(deps) {
274
275
  function dispatchToFrontend(message) {
275
276
  if (disposed || devtoolsWc.isDestroyed())
276
277
  return;
278
+ // Unified settled gate (same predicate as the reconcile tick): an unsettled
279
+ // front-end wipes its state on load anyway, so the message is meaningless
280
+ // there — and the executeJavaScript would queue one did-stop-loading waiter
281
+ // per push (an activePage burst piles them). The post-settle tick re-primes.
282
+ if (!isFrontendSettled(devtoolsWc))
283
+ return;
277
284
  let json;
278
285
  try {
279
286
  json = JSON.stringify(message);
@@ -309,6 +316,10 @@ export function installElementsForward(deps) {
309
316
  function pushDocumentUpdated() {
310
317
  if (disposed || devtoolsWc.isDestroyed())
311
318
  return;
319
+ // Same unified settled gate as dispatchToFrontend — render events (the
320
+ // activePage burst) drive this during churn.
321
+ if (!isFrontendSettled(devtoolsWc))
322
+ return;
312
323
  let script;
313
324
  try {
314
325
  script = buildDocumentUpdatedScript();
@@ -594,6 +605,14 @@ export function installElementsForward(deps) {
594
605
  stop();
595
606
  return;
596
607
  }
608
+ // An unsettled front-end can't run the script anyway, and executeJavaScript
609
+ // against it queues one did-stop-loading waiter PER TICK on the emitter
610
+ // (150ms × a seconds-long reload piles past the MaxListeners ceiling).
611
+ // MUST be the shared Electron-aligned predicate — a bare isLoading()
612
+ // probe diverges from Electron's internal isLoadingMainFrame gate and
613
+ // keeps piling. Skip; the first post-settle tick re-establishes the hook.
614
+ if (!isFrontendSettled(devtoolsWc))
615
+ return;
597
616
  devtoolsWc
598
617
  .executeJavaScript(buildReconcileScript())
599
618
  .then((res) => {
@@ -1,4 +1,5 @@
1
1
  import { DisposableRegistry, toDisposable } from '@dimina-kit/electron-deck/main';
2
+ import { isFrontendSettled } from '../views/inject-when-ready.js';
2
3
  // ── requestId namespacing (pure, testable) ──────────────────────────────────
3
4
  /**
4
5
  * CDP events whose `params.requestId` we rewrite into the virtual namespace.
@@ -359,6 +360,18 @@ export function createNetworkForwarder(bridge) {
359
360
  trimQueue();
360
361
  return;
361
362
  }
363
+ if (!isFrontendSettled(wc)) {
364
+ // An unsettled front-end can't run the dispatch script anyway — and every
365
+ // executeJavaScript against it queues one did-stop-loading waiter on the
366
+ // emitter, so a relaunch's network burst piles them past the MaxListeners
367
+ // ceiling. MUST be the shared Electron-aligned predicate (a bare
368
+ // isLoading() probe diverges from the internal isLoadingMainFrame gate).
369
+ // Hold the (bounded) queue; the next event's flush or the ready-retry
370
+ // delivers after the load.
371
+ trimQueue();
372
+ scheduleReadyRetry();
373
+ return;
374
+ }
362
375
  if (sink === 'idle')
363
376
  beginProbing();
364
377
  // Pack greedily up to MAX_BATCH_CHARS so one executeJavaScript stays sized;
@@ -0,0 +1,9 @@
1
+ import type { BrowserWindow, WebContentsView } from 'electron';
2
+ /**
3
+ * Detach a child overlay WebContentsView from the main window's contentView and
4
+ * close its webContents. Tolerates an already-removed view and a destroyed
5
+ * webContents (best-effort teardown). The single low-level view-destroy path
6
+ * shared by every overlay domain.
7
+ */
8
+ export declare function destroyChildView(mainWindow: BrowserWindow, view: WebContentsView | null): void;
9
+ //# sourceMappingURL=destroy-child-view.d.ts.map
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Detach a child overlay WebContentsView from the main window's contentView and
3
+ * close its webContents. Tolerates an already-removed view and a destroyed
4
+ * webContents (best-effort teardown). The single low-level view-destroy path
5
+ * shared by every overlay domain.
6
+ */
7
+ export function destroyChildView(mainWindow, view) {
8
+ if (!view)
9
+ return;
10
+ if (!mainWindow.isDestroyed()) {
11
+ try {
12
+ mainWindow.contentView.removeChildView(view);
13
+ }
14
+ catch { /* already removed */ }
15
+ }
16
+ try {
17
+ if (!view.webContents.isDestroyed()) {
18
+ view.webContents.close();
19
+ }
20
+ }
21
+ catch { /* ignore */ }
22
+ }
23
+ //# sourceMappingURL=destroy-child-view.js.map