@dimina-kit/devtools 0.4.0-dev.20260702175315 → 0.4.0-dev.20260703051110

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 (50) hide show
  1. package/dist/main/app/app.js +5 -25
  2. package/dist/main/app/native-overview.d.ts +27 -0
  3. package/dist/main/app/native-overview.js +44 -0
  4. package/dist/main/index.bundle.js +826 -690
  5. package/dist/main/ipc/bridge-router.js +180 -141
  6. package/dist/main/ipc/project-fs.js +39 -21
  7. package/dist/main/services/automation/handlers/app.js +143 -109
  8. package/dist/main/services/mcp/tools/simulator-tools.js +118 -97
  9. package/dist/main/services/network-forward/dispatch-batch.d.ts +22 -0
  10. package/dist/main/services/network-forward/dispatch-batch.js +21 -0
  11. package/dist/main/services/network-forward/index.js +5 -20
  12. package/dist/main/services/projects/create-project-service.js +54 -34
  13. package/dist/main/services/projects/types.d.ts +1 -32
  14. package/dist/main/services/service-console/console-api.js +36 -27
  15. package/dist/main/services/simulator-temp-files/disk.js +109 -86
  16. package/dist/main/services/workbench-coi-server.js +58 -45
  17. package/dist/main/utils/logger.d.ts +5 -12
  18. package/dist/main/utils/logger.js +5 -45
  19. package/dist/preload/instrumentation/wxml-extract.js +94 -62
  20. package/dist/preload/runtime/main-api-runner.d.ts +1 -1
  21. package/dist/preload/windows/main.cjs +284 -565
  22. package/dist/preload/windows/main.cjs.map +2 -2
  23. package/dist/preload/windows/simulator.cjs +37 -39
  24. package/dist/preload/windows/simulator.cjs.map +2 -2
  25. package/dist/preload/windows/simulator.js +37 -39
  26. package/dist/render-host/render-inspect.js +63 -52
  27. package/dist/renderer/assets/index-D-ksyN1p.js +49 -0
  28. package/dist/renderer/assets/workbenchSettings-COhuFF-i.js +8 -0
  29. package/dist/renderer/entries/main/index.html +1 -1
  30. package/dist/renderer/entries/workbench-settings/index.html +1 -1
  31. package/dist/shared/appdata-accumulator.js +53 -48
  32. package/dist/shared/open-in-editor-resource-path.d.ts +56 -0
  33. package/dist/shared/open-in-editor-resource-path.js +133 -0
  34. package/dist/shared/open-in-editor.d.ts +2 -9
  35. package/dist/shared/open-in-editor.js +8 -93
  36. package/dist/shared/types.d.ts +13 -0
  37. package/dist/simulator/assets/{device-shell-CUl0ILfn.js → device-shell-CiLAPa0I.js} +2 -2
  38. package/dist/simulator/assets/simulator-Dvnxry3y.js +10 -0
  39. package/dist/simulator/simulator.html +1 -1
  40. package/dist/vscode-workbench/assets/__vite-browser-external-CXi6Kz9W.js +1 -0
  41. package/dist/vscode-workbench/assets/{dist-CS7SQP3K.js → dist-TpGpmMGi.js} +3 -3
  42. package/dist/vscode-workbench/assets/{iconv-lite-umd-D3q-1-o6.js → iconv-lite-umd-C9tiCAJa.js} +1 -1
  43. package/dist/vscode-workbench/assets/{index-zjigpZ25.js → index-DJ1HyMZ7.js} +20 -22
  44. package/dist/vscode-workbench/assets/{jschardet-Cu4ufeHq.js → jschardet-DE1jV513.js} +1 -1
  45. package/dist/vscode-workbench/index.html +1 -1
  46. package/package.json +5 -5
  47. package/dist/renderer/assets/index-B-Dqb7G0.js +0 -49
  48. package/dist/renderer/assets/workbenchSettings-Bim9ol9R.js +0 -8
  49. package/dist/simulator/assets/simulator-Dz8iGcgy.js +0 -10
  50. package/dist/vscode-workbench/assets/__vite-browser-external-B0DTNerG.js +0 -1
@@ -94,6 +94,34 @@ function summarizeBridgeMsg(payload) {
94
94
  }
95
95
  /** Default timeout for a simulator-forwarded API call. */
96
96
  const API_CALL_TIMEOUT_MS = 5_000;
97
+ // Same-appId matches prefer the MOST RECENT spawn (Maps preserve insertion
98
+ // order): after a respawn/reopen the newest session is the live one — the
99
+ // first match could be a just-superseded session mid-teardown. The loop must
100
+ // scan every session (no early break) so the LAST inserted match wins.
101
+ function findAppSessionByAppId(state, appId) {
102
+ let match;
103
+ for (const ap of state.appSessions.values())
104
+ if (ap.appId === appId)
105
+ match = ap;
106
+ return match;
107
+ }
108
+ // No appId hint and no workspace session to disambiguate. Picking by appId is
109
+ // impossible, so fall back to the most-recent spawn — but ONLY when every live
110
+ // session belongs to the same app. Same-appId multiples are a respawn/reopen
111
+ // in progress where the newest is the live one (insertion order = spawn
112
+ // order). Multiple DISTINCT appIds mean a previous project is mid-teardown
113
+ // alongside the new one; any pick is a guess that can resolve the WRONG
114
+ // project's content (the screenshot/inspect-stale-app bug class), so prefer
115
+ // null over a wrong guess.
116
+ function resolveFallbackAppSession(state) {
117
+ let last;
118
+ const distinctAppIds = new Set();
119
+ for (const ap of state.appSessions.values()) {
120
+ last = ap;
121
+ distinctAppIds.add(ap.appId);
122
+ }
123
+ return distinctAppIds.size <= 1 ? last : undefined;
124
+ }
97
125
  /**
98
126
  * Resolve the "current" app session: prefer an explicit appId, else the
99
127
  * workspace's active project, else the only / most-recently-created session.
@@ -101,24 +129,15 @@ const API_CALL_TIMEOUT_MS = 5_000;
101
129
  * rather than assuming a single session.
102
130
  */
103
131
  function resolveCurrentApp(state, ctx, appId) {
104
- // Same-appId matches prefer the MOST RECENT spawn (Maps preserve insertion
105
- // order): after a respawn/reopen the newest session is the live one — the
106
- // first match could be a just-superseded session mid-teardown.
107
132
  if (appId) {
108
- let match;
109
- for (const ap of state.appSessions.values())
110
- if (ap.appId === appId)
111
- match = ap;
133
+ const match = findAppSessionByAppId(state, appId);
112
134
  if (match)
113
135
  return match;
114
136
  }
115
137
  const appInfo = ctx.workspace?.getSession?.()?.appInfo;
116
138
  const activeAppId = appInfo?.appId;
117
139
  if (activeAppId) {
118
- let match;
119
- for (const ap of state.appSessions.values())
120
- if (ap.appId === activeAppId)
121
- match = ap;
140
+ const match = findAppSessionByAppId(state, activeAppId);
122
141
  if (match)
123
142
  return match;
124
143
  }
@@ -128,21 +147,7 @@ function resolveCurrentApp(state, ctx, appId) {
128
147
  // while a close is in flight.
129
148
  if (ctx.workspace?.isClosing?.())
130
149
  return undefined;
131
- // No appId hint and no workspace session to disambiguate. Picking by appId is
132
- // impossible, so fall back to the most-recent spawn — but ONLY when every live
133
- // session belongs to the same app. Same-appId multiples are a respawn/reopen
134
- // in progress where the newest is the live one (insertion order = spawn
135
- // order). Multiple DISTINCT appIds mean a previous project is mid-teardown
136
- // alongside the new one; any pick is a guess that can resolve the WRONG
137
- // project's content (the screenshot/inspect-stale-app bug class), so prefer
138
- // null over a wrong guess.
139
- let last;
140
- const distinctAppIds = new Set();
141
- for (const ap of state.appSessions.values()) {
142
- last = ap;
143
- distinctAppIds.add(ap.appId);
144
- }
145
- return distinctAppIds.size <= 1 ? last : undefined;
150
+ return resolveFallbackAppSession(state);
146
151
  }
147
152
  export function installBridgeRouter(ctx) {
148
153
  const state = {
@@ -1136,86 +1141,119 @@ const APP_LIFECYCLE_UNREGISTER = {
1136
1141
  offAppHide: 'onAppHide',
1137
1142
  offError: 'onError',
1138
1143
  };
1139
- async function handleSimulatorApi(state, ap, page, body, ctx) {
1140
- const name = String(body.name ?? '');
1141
- const params = normalizeParams(body.params);
1142
- if (NAV_BAR_API_NAMES.has(name)) {
1143
- if (!ap.simulatorWc.isDestroyed()) {
1144
- ap.simulatorWc.send(E.NAV_BAR, {
1145
- bridgeId: page.bridgeId,
1146
- name,
1147
- params,
1148
- });
1149
- }
1150
- const successResult = { errMsg: `${name}:ok` };
1151
- sendCallback(ap, params.success, successResult);
1152
- sendCallback(ap, params.complete, successResult);
1153
- return;
1154
- }
1155
- if (NAV_ACTION_NAMES.has(name)) {
1156
- const payload = {
1157
- appSessionId: ap.appSessionId,
1144
+ function handleNavBarApi(ap, page, name, params) {
1145
+ if (!ap.simulatorWc.isDestroyed()) {
1146
+ ap.simulatorWc.send(E.NAV_BAR, {
1158
1147
  bridgeId: page.bridgeId,
1159
- name: name,
1148
+ name,
1160
1149
  params,
1161
- callbacks: extractCallbacks(params),
1162
- };
1163
- if (!ap.simulatorWc.isDestroyed()) {
1164
- ap.simulatorWc.send(E.NAV_ACTION, payload);
1165
- }
1166
- else {
1167
- const fail = { errMsg: `${name}:fail simulator window destroyed` };
1168
- sendCallback(ap, params.fail, fail);
1169
- sendCallback(ap, params.complete, fail);
1170
- }
1171
- return;
1150
+ });
1172
1151
  }
1173
- if (TAB_ACTION_NAMES.has(name)) {
1174
- const payload = {
1175
- appSessionId: ap.appSessionId,
1176
- bridgeId: page.bridgeId,
1177
- name: name,
1178
- params,
1179
- callbacks: extractCallbacks(params),
1180
- };
1181
- if (!ap.simulatorWc.isDestroyed()) {
1182
- ap.simulatorWc.send(E.TAB_ACTION, payload);
1183
- }
1184
- else {
1185
- const fail = { errMsg: `${name}:fail simulator window destroyed` };
1186
- sendCallback(ap, params.fail, fail);
1187
- sendCallback(ap, params.complete, fail);
1188
- }
1152
+ const successResult = { errMsg: `${name}:ok` };
1153
+ sendCallback(ap, params.success, successResult);
1154
+ sendCallback(ap, params.complete, successResult);
1155
+ }
1156
+ // Shared by handleNavActionApi/handleTabActionApi: both forward a same-shaped
1157
+ // payload to the simulator window, differing only in the event name and the
1158
+ // payload's static `name` union. Simulator-destroyed fails the callback
1159
+ // directly since there is no ack path to fail through otherwise.
1160
+ function sendActionOrFail(ap, eventName, payload, name, params) {
1161
+ if (!ap.simulatorWc.isDestroyed()) {
1162
+ ap.simulatorWc.send(eventName, payload);
1189
1163
  return;
1190
1164
  }
1191
- // App-level lifecycle listeners (wx.onAppShow / onAppHide / onError + off*).
1192
- // The service encodes the listener as a keep callback id in `params.success`;
1193
- // we store it and re-fire on main-window foreground/background (the driver in
1194
- // installBridgeRouter) and on serviceHostError. `off*` clears the event's
1195
- // listeners for the session.
1165
+ const fail = { errMsg: `${name}:fail simulator window destroyed` };
1166
+ sendCallback(ap, params.fail, fail);
1167
+ sendCallback(ap, params.complete, fail);
1168
+ }
1169
+ function handleNavActionApi(ap, page, name, params) {
1170
+ const payload = {
1171
+ appSessionId: ap.appSessionId,
1172
+ bridgeId: page.bridgeId,
1173
+ name: name,
1174
+ params,
1175
+ callbacks: extractCallbacks(params),
1176
+ };
1177
+ sendActionOrFail(ap, E.NAV_ACTION, payload, name, params);
1178
+ }
1179
+ function handleTabActionApi(ap, page, name, params) {
1180
+ const payload = {
1181
+ appSessionId: ap.appSessionId,
1182
+ bridgeId: page.bridgeId,
1183
+ name: name,
1184
+ params,
1185
+ callbacks: extractCallbacks(params),
1186
+ };
1187
+ sendActionOrFail(ap, E.TAB_ACTION, payload, name, params);
1188
+ }
1189
+ // App-level lifecycle listeners (wx.onAppShow / onAppHide / onError + off*).
1190
+ // The service encodes the listener as a keep callback id in `params.success`;
1191
+ // we store it and re-fire on main-window foreground/background (the driver in
1192
+ // installBridgeRouter) and on serviceHostError. `off*` clears the event's
1193
+ // listeners for the session. Returns true when `name` matched a lifecycle
1194
+ // register/unregister call (fully handled), false to fall through.
1195
+ function handleAppLifecycleToggle(state, ap, name, params) {
1196
1196
  const lifecycleRegister = APP_LIFECYCLE_REGISTER[name];
1197
1197
  if (lifecycleRegister) {
1198
1198
  state.appLifecycle.register(ap.appSessionId, lifecycleRegister, params.success);
1199
- return;
1199
+ return true;
1200
1200
  }
1201
1201
  const lifecycleUnregister = APP_LIFECYCLE_UNREGISTER[name];
1202
1202
  if (lifecycleUnregister) {
1203
1203
  // off(cb) carries the same evtId as the original on(cb) → removes just that
1204
1204
  // listener; off() with no callback carries no id → clears the whole event.
1205
1205
  state.appLifecycle.unregister(ap.appSessionId, lifecycleUnregister, params.success);
1206
+ return true;
1207
+ }
1208
+ return false;
1209
+ }
1210
+ // pageScrollTo acts on the page's render guest (scroll its document), which
1211
+ // only the main process can reach — run the scroll script in the invoking
1212
+ // page's render webContents rather than forwarding to the simulator.
1213
+ function handlePageScrollApi(ap, page, params) {
1214
+ const renderWc = page.renderWc;
1215
+ if (renderWc && !renderWc.isDestroyed()) {
1216
+ void renderWc.executeJavaScript(buildPageScrollScript(params)).catch(() => { });
1217
+ }
1218
+ const successResult = { errMsg: 'pageScrollTo:ok' };
1219
+ sendCallback(ap, params.success, successResult);
1220
+ sendCallback(ap, params.complete, successResult);
1221
+ }
1222
+ // Shared try/invoke/callback pattern used by the storageApi and
1223
+ // ctx.simulatorApis branches: relay the resolved value through
1224
+ // success+complete, or the thrown error through fail+complete with a
1225
+ // namespaced errMsg.
1226
+ async function invokeSimulatorApiAndCallback(ap, name, params, invoke) {
1227
+ try {
1228
+ const result = await invoke();
1229
+ sendCallback(ap, params.success, result);
1230
+ sendCallback(ap, params.complete, result);
1231
+ }
1232
+ catch (error) {
1233
+ const failResult = { errMsg: `${name}:fail ${error instanceof Error ? error.message : String(error)}` };
1234
+ sendCallback(ap, params.fail, failResult);
1235
+ sendCallback(ap, params.complete, failResult);
1236
+ }
1237
+ }
1238
+ async function handleSimulatorApi(state, ap, page, body, ctx) {
1239
+ const name = String(body.name ?? '');
1240
+ const params = normalizeParams(body.params);
1241
+ if (NAV_BAR_API_NAMES.has(name)) {
1242
+ handleNavBarApi(ap, page, name, params);
1243
+ return;
1244
+ }
1245
+ if (NAV_ACTION_NAMES.has(name)) {
1246
+ handleNavActionApi(ap, page, name, params);
1206
1247
  return;
1207
1248
  }
1208
- // pageScrollTo acts on the page's render guest (scroll its document), which
1209
- // only the main process can reach — run the scroll script in the invoking
1210
- // page's render webContents rather than forwarding to the simulator.
1249
+ if (TAB_ACTION_NAMES.has(name)) {
1250
+ handleTabActionApi(ap, page, name, params);
1251
+ return;
1252
+ }
1253
+ if (handleAppLifecycleToggle(state, ap, name, params))
1254
+ return;
1211
1255
  if (name === 'pageScrollTo') {
1212
- const renderWc = page.renderWc;
1213
- if (renderWc && !renderWc.isDestroyed()) {
1214
- void renderWc.executeJavaScript(buildPageScrollScript(params)).catch(() => { });
1215
- }
1216
- const successResult = { errMsg: 'pageScrollTo:ok' };
1217
- sendCallback(ap, params.success, successResult);
1218
- sendCallback(ap, params.complete, successResult);
1256
+ handlePageScrollApi(ap, page, params);
1219
1257
  return;
1220
1258
  }
1221
1259
  // Native-host storage unification: route async wx.setStorage/getStorage/etc.
@@ -1224,16 +1262,8 @@ async function handleSimulatorApi(state, ap, page, body, ctx) {
1224
1262
  // http:// origin. Without this, async writes and sync writes diverge across
1225
1263
  // two origins even for the running mini-app.
1226
1264
  if (ctx.storageApi && STORAGE_API_NAMES.has(name)) {
1227
- try {
1228
- const result = await ctx.storageApi.invoke(ap.appId, name, params);
1229
- sendCallback(ap, params.success, result);
1230
- sendCallback(ap, params.complete, result);
1231
- }
1232
- catch (error) {
1233
- const failResult = { errMsg: `${name}:fail ${error instanceof Error ? error.message : String(error)}` };
1234
- sendCallback(ap, params.fail, failResult);
1235
- sendCallback(ap, params.complete, failResult);
1236
- }
1265
+ const storageApi = ctx.storageApi;
1266
+ await invokeSimulatorApiAndCallback(ap, name, params, () => storageApi.invoke(ap.appId, name, params));
1237
1267
  return;
1238
1268
  }
1239
1269
  // Main-process registry (downstream-registered host APIs via
@@ -1242,16 +1272,7 @@ async function handleSimulatorApi(state, ap, page, body, ctx) {
1242
1272
  // MiniApp owns the DOM-touching defaults (wx.getSystemInfo, chooseImage,
1243
1273
  // chooseMedia, fs.*, …) and the bridge-router can't run those itself.
1244
1274
  if (ctx.simulatorApis.has(name)) {
1245
- try {
1246
- const result = await ctx.simulatorApis.invoke(name, params);
1247
- sendCallback(ap, params.success, result);
1248
- sendCallback(ap, params.complete, result);
1249
- }
1250
- catch (error) {
1251
- const failResult = { errMsg: `${name}:fail ${error instanceof Error ? error.message : String(error)}` };
1252
- sendCallback(ap, params.fail, failResult);
1253
- sendCallback(ap, params.complete, failResult);
1254
- }
1275
+ await invokeSimulatorApiAndCallback(ap, name, params, () => ctx.simulatorApis.invoke(name, params));
1255
1276
  return;
1256
1277
  }
1257
1278
  forwardApiCallToSimulator(state, ap, page, name, params);
@@ -1544,34 +1565,22 @@ function disposePageSession(state, ap, page) {
1544
1565
  ap.pages.delete(page.bridgeId);
1545
1566
  state.pageSessions.delete(page.bridgeId);
1546
1567
  }
1547
- async function disposeAppSession(state, appSessionId, opts = {}) {
1548
- const ap = state.appSessions.get(appSessionId);
1549
- if (!ap)
1550
- return;
1551
- state.appSessions.delete(appSessionId);
1552
- // Remove this session's shutdown-fallback registry entry so the registry does
1553
- // not accumulate one stale closure per respawn. Ordered AFTER the delete
1554
- // above: dispose() runs the wrapped fn (disposeAppSession again), which
1555
- // early-returns now that the session is gone — no recursion, no double-run.
1556
- // (At whole-app shutdown the registry marks the entry released before calling
1557
- // the fn, so this dispose() is a no-op then.)
1558
- const registryHandle = ap.registryHandle;
1559
- ap.registryHandle = null;
1560
- void registryHandle?.dispose();
1561
- state.appLifecycle.dispose(appSessionId);
1562
- // Evict AppData bridges FIRST — eviction enumerates `ap.pages`, which the
1563
- // page teardown below progressively empties (and finally clears).
1564
- state.evictAppDataBridges(ap);
1565
- // Drain any pending API calls owned by this app session. One-shot calls
1566
- // normally self-clean on response/timeout, but persistent (`keep: true`)
1567
- // subscriptions (e.g. audioListen) live with their timer cleared until
1568
- // teardown — without this they would leak in pendingApiCalls forever.
1568
+ // Drain any pending API calls owned by this app session. One-shot calls
1569
+ // normally self-clean on response/timeout, but persistent (`keep: true`)
1570
+ // subscriptions (e.g. audioListen) live with their timer cleared until
1571
+ // teardown — without this they would leak in pendingApiCalls forever.
1572
+ function drainPendingApiCallsForSession(state, appSessionId) {
1569
1573
  for (const [requestId, pending] of state.pendingApiCalls) {
1570
1574
  if (pending.appSessionId !== appSessionId)
1571
1575
  continue;
1572
1576
  clearTimeout(pending.timer);
1573
1577
  state.pendingApiCalls.delete(requestId);
1574
1578
  }
1579
+ }
1580
+ // Closes every page's render guest and drops its wc/pageSessions bindings.
1581
+ // Caller clears `ap.pages` itself right after — this only tears each page
1582
+ // down, it doesn't touch the map.
1583
+ function closeSessionPages(state, ap) {
1575
1584
  for (const page of ap.pages.values()) {
1576
1585
  if (page.renderWc && !page.renderWc.isDestroyed()) {
1577
1586
  state.wcIdToBridgeId.delete(page.renderWc.id);
@@ -1588,15 +1597,11 @@ async function disposeAppSession(state, appSessionId, opts = {}) {
1588
1597
  }
1589
1598
  state.pageSessions.delete(page.bridgeId);
1590
1599
  }
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();
1600
+ }
1601
+ // Three-way release of the session's service-host window: pool release (soft
1602
+ // reuse), pool reclaim of an externally-closed window, or a plain close for
1603
+ // the unpooled path.
1604
+ async function releaseServiceWindow(state, ap, opts) {
1600
1605
  if (ap.poolEntryId !== null && state.pool && !opts.serviceAlreadyClosed) {
1601
1606
  // Soft reuse (foundation.md §4.3): the pooled service-host webContents keeps
1602
1607
  // its wc.id but is about to run a NEW app session. Reset its Connection
@@ -1623,12 +1628,14 @@ async function disposeAppSession(state, appSessionId, opts = {}) {
1623
1628
  // pool only auto-reclaims on render-process-gone — so without this the in-use
1624
1629
  // entry leaks in the pool forever, permanently shrinking capacity. Reclaim
1625
1630
  // the slot explicitly (the window is already gone; releaseDestroyed won't
1626
- // touch it). (Audit A1)
1631
+ // touch it).
1627
1632
  state.pool.releaseDestroyed(ap.poolEntryId);
1628
1633
  }
1629
1634
  else if (!opts.serviceAlreadyClosed && !ap.serviceWindow.isDestroyed()) {
1630
1635
  ap.serviceWindow.close();
1631
1636
  }
1637
+ }
1638
+ function unbindSessionFromSharedMaps(state, ap, appSessionId) {
1632
1639
  // Value-checked unbind for the service wc: this delete runs after the
1633
1640
  // pool-release await above, so on the pool path the next spawn may have
1634
1641
  // already re-acquired the SAME window and rebound its wc id — only remove
@@ -1645,6 +1652,38 @@ async function disposeAppSession(state, appSessionId, opts = {}) {
1645
1652
  if (hosted.size === 0)
1646
1653
  state.simulatorWcIdToAppSessionIds.delete(ap.simulatorWc.id);
1647
1654
  }
1655
+ }
1656
+ async function disposeAppSession(state, appSessionId, opts = {}) {
1657
+ const ap = state.appSessions.get(appSessionId);
1658
+ if (!ap)
1659
+ return;
1660
+ state.appSessions.delete(appSessionId);
1661
+ // Remove this session's shutdown-fallback registry entry so the registry does
1662
+ // not accumulate one stale closure per respawn. Ordered AFTER the delete
1663
+ // above: dispose() runs the wrapped fn (disposeAppSession again), which
1664
+ // early-returns now that the session is gone — no recursion, no double-run.
1665
+ // (At whole-app shutdown the registry marks the entry released before calling
1666
+ // the fn, so this dispose() is a no-op then.)
1667
+ const registryHandle = ap.registryHandle;
1668
+ ap.registryHandle = null;
1669
+ void registryHandle?.dispose();
1670
+ state.appLifecycle.dispose(appSessionId);
1671
+ // Evict AppData bridges FIRST — eviction enumerates `ap.pages`, which the
1672
+ // page teardown below progressively empties (and finally clears).
1673
+ state.evictAppDataBridges(ap);
1674
+ drainPendingApiCallsForSession(state, appSessionId);
1675
+ closeSessionPages(state, ap);
1676
+ ap.pages.clear();
1677
+ // Detach every session-scoped hook on emitters that outlive this session —
1678
+ // the shared simulator WCV's 'destroyed' and the service window's 'closed' —
1679
+ // BEFORE the window is released/closed below: a pool-recycled window must
1680
+ // not re-trigger this session's teardown from a stale 'closed', and the
1681
+ // simulator wc must not accumulate one dead hook per soft reload until the
1682
+ // MaxListeners warning fires. Idempotent with teardown being triggered BY
1683
+ // one of these hooks (removing a fired once() is a no-op).
1684
+ ap.listenerBag.dispose();
1685
+ await releaseServiceWindow(state, ap, opts);
1686
+ unbindSessionFromSharedMaps(state, ap, appSessionId);
1648
1687
  // Only the local fallback server needs closing; the dev-server base is owned
1649
1688
  // by the workspace session, not this app session.
1650
1689
  if (ap.resourceServer) {
@@ -468,6 +468,40 @@ function writeFileSync(root, p, content) {
468
468
  nodeFs.closeSync(fd);
469
469
  }
470
470
  }
471
+ /**
472
+ * Reads one directory's entries. Returns `null` (caller skips the directory)
473
+ * for `EACCES`/`ENOENT`/`ENOTDIR` — a directory can disappear or become
474
+ * unreadable between being queued and being walked. Any other error is
475
+ * rethrown.
476
+ */
477
+ async function readDirEntriesSafe(dir) {
478
+ try {
479
+ return await fs.readdir(dir, { withFileTypes: true });
480
+ }
481
+ catch (err) {
482
+ const code = err.code;
483
+ if (code === 'EACCES' || code === 'ENOENT' || code === 'ENOTDIR')
484
+ return null;
485
+ throw err;
486
+ }
487
+ }
488
+ /**
489
+ * Classifies one readdir entry: a non-`SKIP_DIRS` directory is queued for
490
+ * walking; a plain file is recorded as a `safeRoot`-relative POSIX path.
491
+ * Skipped directories and non-file/non-directory entries (symlinks, sockets,
492
+ * etc.) are a no-op.
493
+ */
494
+ function collectListEntry(entry, dir, safeRoot, out, queue) {
495
+ const full = path.join(dir, entry.name);
496
+ if (entry.isDirectory()) {
497
+ if (!SKIP_DIRS.has(entry.name))
498
+ queue.push(full);
499
+ return;
500
+ }
501
+ if (entry.isFile()) {
502
+ out.push(path.relative(safeRoot, full).split(path.sep).join('/'));
503
+ }
504
+ }
471
505
  async function listFiles(root, rootArg) {
472
506
  const safeRoot = await resolveWithinProjectRoot(rootArg, root);
473
507
  const out = [];
@@ -476,29 +510,13 @@ async function listFiles(root, rootArg) {
476
510
  if (out.length >= LIST_FILE_LIMIT)
477
511
  break;
478
512
  const dir = queue.shift();
479
- let entries;
480
- try {
481
- entries = await fs.readdir(dir, { withFileTypes: true });
482
- }
483
- catch (err) {
484
- const code = err.code;
485
- if (code === 'EACCES' || code === 'ENOENT' || code === 'ENOTDIR')
486
- continue;
487
- throw err;
488
- }
489
- for (const e of entries) {
513
+ const entries = await readDirEntriesSafe(dir);
514
+ if (entries === null)
515
+ continue;
516
+ for (const entry of entries) {
490
517
  if (out.length >= LIST_FILE_LIMIT)
491
518
  break;
492
- const full = path.join(dir, e.name);
493
- if (e.isDirectory()) {
494
- if (SKIP_DIRS.has(e.name))
495
- continue;
496
- queue.push(full);
497
- continue;
498
- }
499
- if (!e.isFile())
500
- continue;
501
- out.push(path.relative(safeRoot, full).split(path.sep).join('/'));
519
+ collectListEntry(entry, dir, safeRoot, out, queue);
502
520
  }
503
521
  }
504
522
  return out;