rnxsim 0.1.370 → 0.1.372

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 (207) hide show
  1. package/cli/bin.ts +2 -2
  2. package/cli/commands/box/checkout-plane.ts +65 -14
  3. package/cli/commands/box/cloud-box.ts +294 -0
  4. package/cli/commands/box/plane-share.ts +208 -0
  5. package/cli/commands/box/rnx-command.ts +2 -2
  6. package/cli/commands/box/search-command.ts +157 -0
  7. package/cli/commands/box.ts +269 -13
  8. package/cli/commands/control.ts +6 -1
  9. package/cli/outbound-endpoints.ts +37 -1
  10. package/dist-cli/bin.js +5 -5
  11. package/dist-cli/chunks/{agent-3R4424LW.js → agent-RTAKJ5UM.js} +2 -2
  12. package/dist-cli/chunks/{agent-wrapper-4PPWZWSY.js → agent-wrapper-JWRDI26D.js} +2 -2
  13. package/dist-cli/chunks/{app-fonts-KYXB6CXQ.js → app-fonts-RKO3N4PB.js} +2 -2
  14. package/dist-cli/chunks/{assert-7EZOWGYQ.js → assert-Y5LXPWC6.js} +2 -2
  15. package/dist-cli/chunks/{auth-L7M36H6C.js → auth-4SPTRWTM.js} +2 -2
  16. package/dist-cli/chunks/{beta-FKAF6YWJ.js → beta-KYS22QPY.js} +2 -2
  17. package/dist-cli/chunks/{box-5Y5VBMHV.js → box-7CK72ZPF.js} +617 -596
  18. package/dist-cli/chunks/{camera-XHF2RPFG.js → camera-RUL426MQ.js} +2 -2
  19. package/dist-cli/chunks/{chunk-EQ3OE2AV.js → chunk-2MA3Q44D.js} +1 -1
  20. package/dist-cli/chunks/{chunk-DHYTRCIP.js → chunk-2OA2WE7G.js} +2 -2
  21. package/dist-cli/chunks/{chunk-DMHSD6YJ.js → chunk-3C6LTIDV.js} +2 -2
  22. package/dist-cli/chunks/{chunk-NR2V2ONN.js → chunk-4UMODU6C.js} +2 -2
  23. package/dist-cli/chunks/{chunk-Z2VBSNFI.js → chunk-5Q6ZEZFF.js} +1 -1
  24. package/dist-cli/chunks/{chunk-LUOHRBJG.js → chunk-7FYOZQBM.js} +1 -1
  25. package/dist-cli/chunks/{chunk-Q4MRJ6PO.js → chunk-BRRWRBW7.js} +2 -2
  26. package/dist-cli/chunks/chunk-CEQXZ3GA.js +11 -0
  27. package/dist-cli/chunks/{chunk-DUXHYPBP.js → chunk-CZ2FOZVC.js} +2 -2
  28. package/dist-cli/chunks/{chunk-CMVRTZ5G.js → chunk-D5AT7UT5.js} +2 -2
  29. package/dist-cli/chunks/{chunk-D2GZASRV.js → chunk-DQOQQFMP.js} +1 -1
  30. package/dist-cli/chunks/{chunk-3UXXYJVD.js → chunk-EKSSAC4L.js} +1 -1
  31. package/dist-cli/chunks/{chunk-5BR33NAZ.js → chunk-EY5OHL2M.js} +1 -1
  32. package/dist-cli/chunks/{chunk-FK4JSTZ3.js → chunk-FOW2LPM7.js} +2 -2
  33. package/dist-cli/chunks/{chunk-WFQHT4QD.js → chunk-G5G7PQKO.js} +2 -2
  34. package/dist-cli/chunks/{chunk-WPV4H5S4.js → chunk-GPYG2L7R.js} +2 -2
  35. package/dist-cli/chunks/{chunk-3SOPYG7S.js → chunk-H2SKPUSI.js} +2 -2
  36. package/dist-cli/chunks/{chunk-6A6L3VJR.js → chunk-H567W7JT.js} +2 -2
  37. package/dist-cli/chunks/{chunk-PAUEZO6F.js → chunk-HDGZAVH3.js} +2 -2
  38. package/dist-cli/chunks/{chunk-AALKIAUZ.js → chunk-I37LRPZ3.js} +1 -1
  39. package/dist-cli/chunks/{chunk-JZLDHPB7.js → chunk-I7LRVN5M.js} +2 -2
  40. package/dist-cli/chunks/{chunk-3BG6UCQY.js → chunk-IX25FFF3.js} +11 -11
  41. package/dist-cli/chunks/{chunk-72S5FCBY.js → chunk-J2TJRPDB.js} +2 -2
  42. package/dist-cli/chunks/{chunk-RRX52O37.js → chunk-JPRHJMUJ.js} +1 -1
  43. package/dist-cli/chunks/{chunk-BMIJZNKD.js → chunk-K4TGUDY4.js} +2 -2
  44. package/dist-cli/chunks/{chunk-7C7TJYZ3.js → chunk-KSE5XGYU.js} +2 -2
  45. package/dist-cli/chunks/{chunk-OJZHECGH.js → chunk-KVVNCIJA.js} +1 -1
  46. package/dist-cli/chunks/{chunk-LXPHL6FO.js → chunk-KWLUOLYQ.js} +2 -2
  47. package/dist-cli/chunks/{chunk-C7BA5CCM.js → chunk-KXAE64HF.js} +2 -2
  48. package/dist-cli/chunks/{chunk-EWSWOWXK.js → chunk-L5BEWXQZ.js} +2 -2
  49. package/dist-cli/chunks/{chunk-D24EOJ4C.js → chunk-LAQKTHRM.js} +2 -2
  50. package/dist-cli/chunks/{chunk-IDLFVRPN.js → chunk-LWLHVF6Y.js} +1 -1
  51. package/dist-cli/chunks/{chunk-EOIBLKZO.js → chunk-MKHW3JNL.js} +2 -2
  52. package/dist-cli/chunks/{chunk-64JAEJ6N.js → chunk-MSM2E74F.js} +1 -1
  53. package/dist-cli/chunks/{chunk-DXV37CJN.js → chunk-NBEOA72C.js} +1 -1
  54. package/dist-cli/chunks/{chunk-H5X5FJ63.js → chunk-NLMDMNO6.js} +2 -2
  55. package/dist-cli/chunks/{chunk-6W4HIWC7.js → chunk-NT43X5LI.js} +2 -2
  56. package/dist-cli/chunks/{chunk-PNOENVMJ.js → chunk-NWPYM54M.js} +1 -1
  57. package/dist-cli/chunks/{chunk-O6QLKSAM.js → chunk-OLHHB6LD.js} +2 -2
  58. package/dist-cli/chunks/{chunk-MDCY532E.js → chunk-P6K6XFWE.js} +2 -2
  59. package/dist-cli/chunks/{chunk-LIMWG54N.js → chunk-PETP6LVI.js} +1 -1
  60. package/dist-cli/chunks/{chunk-YIZGNT7A.js → chunk-PKY76S57.js} +1 -1
  61. package/dist-cli/chunks/chunk-POR7R3TP.js +4 -0
  62. package/dist-cli/chunks/chunk-PUH6RQBJ.js +7 -0
  63. package/dist-cli/chunks/{chunk-NJMU5BN6.js → chunk-Q5WPPOFV.js} +1 -1
  64. package/dist-cli/chunks/{chunk-HQQTS2CB.js → chunk-Q7JSU5DI.js} +3 -3
  65. package/dist-cli/chunks/{chunk-U63T6LIQ.js → chunk-QA2PTSOC.js} +1 -1
  66. package/dist-cli/chunks/{chunk-2FOGXVOK.js → chunk-R7HMFLXI.js} +2 -2
  67. package/dist-cli/chunks/{chunk-FA75F34Z.js → chunk-RXKZJAGY.js} +1 -1
  68. package/dist-cli/chunks/{chunk-5RKRVS5A.js → chunk-SC7PGULZ.js} +2 -2
  69. package/dist-cli/chunks/{chunk-UFVAAWGZ.js → chunk-SLTEYNAE.js} +1 -1
  70. package/dist-cli/chunks/{chunk-6GPS7U4X.js → chunk-SSQCKJQZ.js} +2 -2
  71. package/dist-cli/chunks/{chunk-ARMXC3C3.js → chunk-SZX6ARJX.js} +3 -3
  72. package/dist-cli/chunks/{chunk-6S5LJV2O.js → chunk-U3XYGLXG.js} +2 -2
  73. package/dist-cli/chunks/chunk-UA5WQCBU.js +7 -0
  74. package/dist-cli/chunks/{chunk-VVQCZ42C.js → chunk-UVKX6FQ4.js} +1 -1
  75. package/dist-cli/chunks/{chunk-Z3DENGXW.js → chunk-VQQXUXXF.js} +2 -2
  76. package/dist-cli/chunks/{chunk-I52NT6VG.js → chunk-WR7LRCYW.js} +1 -1
  77. package/dist-cli/chunks/{chunk-HEKHLQ3B.js → chunk-XA4YU3A4.js} +3 -3
  78. package/dist-cli/chunks/{chunk-GRO74J2X.js → chunk-YEGWC2OY.js} +1 -1
  79. package/dist-cli/chunks/{chunk-AVJBT5LF.js → chunk-YWTBN7SD.js} +1 -1
  80. package/dist-cli/chunks/{chunk-PIZXOXBL.js → chunk-ZRQKEKHX.js} +2 -2
  81. package/dist-cli/chunks/{chunk-X6WQ4YWE.js → chunk-ZTZTRD5R.js} +1 -1
  82. package/dist-cli/chunks/{cleanup-MXMMJ7JW.js → cleanup-7ATBNZHC.js} +2 -2
  83. package/dist-cli/chunks/cli-version-D2IDQO7X.js +4 -0
  84. package/dist-cli/chunks/{compat-E6KE5KDI.js → compat-4ZC6ZMMA.js} +2 -2
  85. package/dist-cli/chunks/{config-QSLLVZDS.js → config-EOBODQ32.js} +2 -2
  86. package/dist-cli/chunks/{connect-QE4VU6GB.js → connect-ZQ72CZ7O.js} +2 -2
  87. package/dist-cli/chunks/control-FKPKLOE2.js +4 -0
  88. package/dist-cli/chunks/daemon-WFHWS3JU.js +4 -0
  89. package/dist-cli/chunks/{debug-5FG3MBJS.js → debug-7Z325GNU.js} +3 -3
  90. package/dist-cli/chunks/{desktop-5EZX7NX5.js → desktop-CR7ALX7H.js} +3 -3
  91. package/dist-cli/chunks/{detox-WJGQP5TM.js → detox-RZ37736M.js} +2 -2
  92. package/dist-cli/chunks/{device-D7LKCAMS.js → device-TA3SQP6K.js} +2 -2
  93. package/dist-cli/chunks/{diagnose-A3W6ZTGA.js → diagnose-JFKFKR3S.js} +2 -2
  94. package/dist-cli/chunks/disk-cleanup-HZM7VM4S.js +4 -0
  95. package/dist-cli/chunks/{do-chain-RUJNRPPF.js → do-chain-MTT65YNX.js} +2 -2
  96. package/dist-cli/chunks/drivers-BXEXWRVJ.js +4 -0
  97. package/dist-cli/chunks/{env-U67SQVTV.js → env-SFI24PLS.js} +2 -2
  98. package/dist-cli/chunks/{film-XC6UAQ3V.js → film-GPHSJLIH.js} +3 -3
  99. package/dist-cli/chunks/flow-FBCU3TZN.js +4 -0
  100. package/dist-cli/chunks/help-6MLROWYO.js +4 -0
  101. package/dist-cli/chunks/{hidden-runtime-alias-7S2TNZXG.js → hidden-runtime-alias-7Z6PYH53.js} +2 -2
  102. package/dist-cli/chunks/{home-paths-LMO5LB4F.js → home-paths-MEGVA6ET.js} +2 -2
  103. package/dist-cli/chunks/inspect-EWT5SXBD.js +4 -0
  104. package/dist-cli/chunks/install-desktop-IFBUH7MH.js +4 -0
  105. package/dist-cli/chunks/{login-JIU42S4C.js → login-EHUZYRAM.js} +2 -2
  106. package/dist-cli/chunks/{logout-JTAWQXZO.js → logout-WPGT62N6.js} +2 -2
  107. package/dist-cli/chunks/{maestro-TYBQYOGB.js → maestro-4HVDLCTI.js} +3 -3
  108. package/dist-cli/chunks/{maestro-generate-YLAGWVKE.js → maestro-generate-VBS2EZMM.js} +3 -3
  109. package/dist-cli/chunks/{mode-DV24ZRZ6.js → mode-MGTS4QTF.js} +2 -2
  110. package/dist-cli/chunks/{optional-demo-registry-JZH66VIM.js → optional-demo-registry-F3FQT5WT.js} +2 -2
  111. package/dist-cli/chunks/{perf-LXXJ4EAX.js → perf-47R3OEI7.js} +2 -2
  112. package/dist-cli/chunks/{permissions-KAOVZUPW.js → permissions-ZOLFZWR2.js} +2 -2
  113. package/dist-cli/chunks/{platform-KW35446W.js → platform-C4E26ZBZ.js} +3 -3
  114. package/dist-cli/chunks/privacy-WXGGG4ZI.js +4 -0
  115. package/dist-cli/chunks/{record-EK3ETFHZ.js → record-OUXWCWUF.js} +3 -3
  116. package/dist-cli/chunks/{report-issue-NW5PCAZF.js → report-issue-LJJXONXQ.js} +2 -2
  117. package/dist-cli/chunks/reset-ZZYLPETS.js +4 -0
  118. package/dist-cli/chunks/resolve-target-MVZZR2JW.js +4 -0
  119. package/dist-cli/chunks/runtime-DEC5AF3M.js +4 -0
  120. package/dist-cli/chunks/{screenshot-command-TQSPTFT2.js → screenshot-command-CUHDOS5D.js} +3 -3
  121. package/dist-cli/chunks/{screenshot-layers-IZP3L3AQ.js → screenshot-layers-DVWQVKT6.js} +2 -2
  122. package/dist-cli/chunks/{screenshots-capture-K7LJOVAE.js → screenshots-capture-GWIUJ3A6.js} +2 -2
  123. package/dist-cli/chunks/{serve-MMX4WXXG.js → serve-TX5OKGJM.js} +19 -19
  124. package/dist-cli/chunks/setup-X7LLGW67.js +4 -0
  125. package/dist-cli/chunks/{sim-client-KLAYIFOC.js → sim-client-W4WZOZ3P.js} +2 -2
  126. package/dist-cli/chunks/{skills-N4D3POIS.js → skills-TDFQAZ6A.js} +2 -2
  127. package/dist-cli/chunks/{state-ES6TFQZF.js → state-P2Z25OLQ.js} +2 -2
  128. package/dist-cli/chunks/{storage-52DR5V2C.js → storage-DWEOFXEB.js} +2 -2
  129. package/dist-cli/chunks/store-TKBLMYCV.js +4 -0
  130. package/dist-cli/chunks/telemetry-E3CBKJMD.js +4 -0
  131. package/dist-cli/chunks/{timeline-NRVNB5JR.js → timeline-55XNA4GX.js} +2 -2
  132. package/dist-cli/chunks/{upgrade-LNED5YQ2.js → upgrade-3XRCVDVC.js} +2 -2
  133. package/dist-cli/chunks/upload-QB77GSMQ.js +4 -0
  134. package/dist-cli/chunks/{version-VEF2DYGG.js → version-WTYGLXBJ.js} +2 -2
  135. package/dist-cli/chunks/{web-CGDE6S73.js → web-ZWRY3VHM.js} +2 -2
  136. package/dist-cli/chunks/welcome-7ATM5EGX.js +5 -0
  137. package/dist-cli/chunks/{what-happened-FSIDWL32.js → what-happened-Q3IGIH4K.js} +2 -2
  138. package/dist-cli/chunks/{ws-bridge-QXFQLRE7.js → ws-bridge-KCPZSBBX.js} +2 -2
  139. package/dist-lib/agent-daemon-client.cjs +1 -1
  140. package/dist-lib/agent-events.cjs +1 -1
  141. package/dist-lib/agent-identity.cjs +1 -1
  142. package/dist-lib/agent-sessions.cjs +1 -1
  143. package/dist-lib/attached-projects.cjs +1 -1
  144. package/dist-lib/auth/shared-session.cjs +1 -1
  145. package/dist-lib/backend-origin.cjs +1 -1
  146. package/dist-lib/beta.cjs +1 -1
  147. package/dist-lib/beta.mjs +1 -1
  148. package/dist-lib/bridge-constants.cjs +1 -1
  149. package/dist-lib/bridge-contract-input.cjs +1 -1
  150. package/dist-lib/bridge-contract-input.mjs +1 -1
  151. package/dist-lib/bridge-contract.cjs +1 -1
  152. package/dist-lib/bridge-contract.mjs +1 -1
  153. package/dist-lib/cli-constants.cjs +1 -1
  154. package/dist-lib/config.cjs +1 -1
  155. package/dist-lib/detox/index.cjs +1 -1
  156. package/dist-lib/dev-bundle-resolution.cjs +69 -34
  157. package/dist-lib/home-paths.cjs +1 -1
  158. package/dist-lib/host/bridge-host.cjs +430 -106
  159. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  160. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  161. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  162. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  163. package/dist-lib/host/websocket-proxy.cjs +1 -1
  164. package/dist-lib/index.cjs +1 -1
  165. package/dist-lib/jump-to-source-babel.cjs +1 -1
  166. package/dist-lib/menu.cjs +1 -1
  167. package/dist-lib/menu.mjs +1 -1
  168. package/dist-lib/metro-production-bundle.cjs +1 -1
  169. package/dist-lib/metro-production-bundle.mjs +1 -1
  170. package/dist-lib/metro.cjs +1 -1
  171. package/dist-lib/profiles.cjs +1 -1
  172. package/dist-lib/public-brand.cjs +1 -1
  173. package/dist-lib/react-native-host-modules.cjs +1 -1
  174. package/dist-lib/react-native-host-modules.mjs +1 -1
  175. package/dist-lib/render-mode.cjs +1 -1
  176. package/dist-lib/scripts/dev-server-scanner.cjs +149 -93
  177. package/dist-lib/sdk.cjs +1 -1
  178. package/dist-lib/sdk.mjs +1 -1
  179. package/dist-lib/skills.cjs +679 -307
  180. package/dist-lib/vite.cjs +1 -1
  181. package/package.json +1 -1
  182. package/scripts/dev-server-scanner.ts +275 -92
  183. package/src/dev-bundle-resolution.ts +123 -40
  184. package/src/disk-cleanup.ts +44 -0
  185. package/src/host/bridge-host.ts +11 -0
  186. package/src/host/plane-host.ts +203 -0
  187. package/dist-cli/chunks/chunk-GJRLPJQL.js +0 -6
  188. package/dist-cli/chunks/chunk-NTC46LSA.js +0 -4
  189. package/dist-cli/chunks/chunk-OTFTMGAL.js +0 -7
  190. package/dist-cli/chunks/cli-version-MLS55UTY.js +0 -4
  191. package/dist-cli/chunks/control-XD5F6K5T.js +0 -4
  192. package/dist-cli/chunks/daemon-UN5SE2JO.js +0 -4
  193. package/dist-cli/chunks/disk-cleanup-BDRHX5ZG.js +0 -4
  194. package/dist-cli/chunks/drivers-3NJ5RL6X.js +0 -4
  195. package/dist-cli/chunks/flow-FHZZD3P6.js +0 -4
  196. package/dist-cli/chunks/help-BYYDUSXF.js +0 -4
  197. package/dist-cli/chunks/inspect-RQCTWDCC.js +0 -4
  198. package/dist-cli/chunks/install-desktop-GOWXCQUE.js +0 -4
  199. package/dist-cli/chunks/privacy-MIFE72CG.js +0 -4
  200. package/dist-cli/chunks/reset-RZ4O5AFJ.js +0 -4
  201. package/dist-cli/chunks/resolve-target-7R5BNYMM.js +0 -4
  202. package/dist-cli/chunks/runtime-MZHLSRS6.js +0 -4
  203. package/dist-cli/chunks/setup-YFP3KEQZ.js +0 -4
  204. package/dist-cli/chunks/store-HOL45WCI.js +0 -4
  205. package/dist-cli/chunks/telemetry-BV6OTYPF.js +0 -4
  206. package/dist-cli/chunks/upload-RXKHFIBU.js +0 -4
  207. package/dist-cli/chunks/welcome-FT2AMUWW.js +0 -5
package/dist-lib/vite.cjs CHANGED
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.370 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.372 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
2
  let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
3
  "use strict";
4
4
  var __create = Object.create;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rnxsim",
3
- "version": "0.1.370",
3
+ "version": "0.1.372",
4
4
  "description": "rnx CLI, Vite and Metro plugins, and agent skills for driving React Native apps in the browser.",
5
5
  "author": "Tamagui LLC",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -18,10 +18,6 @@ import { readlink } from 'fs/promises'
18
18
  import http from 'http'
19
19
  import net from 'net'
20
20
  import { resolve } from 'node:path'
21
- import { promisify } from 'util'
22
- import type { AppSettingsSpecifier } from '@sootsim/globals'
23
-
24
- const execP = promisify(exec)
25
21
  import {
26
22
  buildLoopbackProbeBaseUrl,
27
23
  LOOPBACK_PROBE_HOSTNAMES,
@@ -37,6 +33,50 @@ import {
37
33
  readAppSettingsBundle,
38
34
  } from '../src/settings-bundle.ts'
39
35
  import { loadOptionalDemoApps, optionalDemoAppsSync } from './optional-demo-registry.ts'
36
+ import type { AppSettingsSpecifier } from '@sootsim/globals'
37
+
38
+ // how long past a command's own budget we wait before deciding its exit event is
39
+ // never coming. generous, because this only fires when the child is already
40
+ // overdue and we are choosing between a wrong answer and a hang.
41
+ const EXEC_EXIT_GRACE_MS = 2_000
42
+
43
+ /** every subprocess in this file goes through here.
44
+ *
45
+ * `exec`'s own `timeout` option is NOT a backstop. it kills the child and then
46
+ * STILL waits for a 'close' event to settle the promise, so when that event is
47
+ * lost the promise never settles — the timeout is already spent and nothing is
48
+ * left to rescue it. (same failure m10843 pinned in `CheckoutFilePlane.refresh`.)
49
+ * `lsof` walks every open fd on the machine, which makes it a realistic victim.
50
+ *
51
+ * a hung scan is worse than a slow one: `inflightScan` never clears, so
52
+ * `/__server-scan` stops answering at all instead of answering "could not
53
+ * look". so we race the child against a real wall-clock deadline and reject
54
+ * with `killed`, which callers already read as could-not-look. */
55
+ function execGuarded(
56
+ command: string,
57
+ timeout: number,
58
+ extra: { maxBuffer?: number } = {},
59
+ ): Promise<{ stdout: string }> {
60
+ let child: ReturnType<typeof exec> | undefined
61
+ const ran = new Promise<{ stdout: string }>((resolve, reject) => {
62
+ child = exec(command, { encoding: 'utf8', timeout, ...extra }, (err, stdout) => {
63
+ if (err) reject(err)
64
+ else resolve({ stdout })
65
+ })
66
+ })
67
+ const deadline = new Promise<never>((_resolve, reject) => {
68
+ const timer = setTimeout(() => {
69
+ child?.kill('SIGKILL')
70
+ const err = new Error(`\`${command}\` did not exit within ${timeout}ms`)
71
+ // matches the shape `wasKilledByTimeout` reads off a real exec timeout, so
72
+ // a lost exit event and a genuine timeout reach callers as the same fact.
73
+ Object.assign(err, { killed: true })
74
+ reject(err)
75
+ }, timeout + EXEC_EXIT_GRACE_MS)
76
+ timer.unref?.()
77
+ })
78
+ return Promise.race([ran, deadline])
79
+ }
40
80
 
41
81
  export interface DiscoveredServer {
42
82
  port: number
@@ -51,6 +91,11 @@ export interface DiscoveredServer {
51
91
  settingsBundle?: readonly AppSettingsSpecifier[]
52
92
  settingsStorageScopeUrl?: string
53
93
  patched?: boolean
94
+ /** true when `bundleUrl` was NOT derived from the server's own manifest —
95
+ * the manifest probe got no answer, so this is the generic `/index.bundle`
96
+ * guess. good enough to LIST the server; never good enough to open it. see
97
+ * `manifestState` below for why the two are different answers. */
98
+ bundleUrlProvisional?: boolean
54
99
  /** absolute cwd of the owning node/bun process — resolved from `lsof -d cwd`
55
100
  * when the scanner can see the pid. used to auto-attach the project for
56
101
  * agent sessions without requiring a manual `sootsim agent attach`. */
@@ -58,6 +103,18 @@ export interface DiscoveredServer {
58
103
  pid?: number
59
104
  }
60
105
 
106
+ /** how the manifest probe (`GET /` with expo-platform) actually went. the same
107
+ * three answers `dev-bundle-resolution.ts` uses, deliberately spelled the same
108
+ * way: a second vocabulary for one question is how the two resolvers drift.
109
+ *
110
+ * the rule this encodes, which the whole scanner depends on: A PROBE THAT
111
+ * COULD NOT LOOK MUST NEVER ANSWER AS THOUGH IT LOOKED. silence, timeout and
112
+ * empty are three different answers, and only `no-manifest` means "there is
113
+ * nothing there". reading `no-answer` as `no-manifest` is what made
114
+ * `rnx open` hand back `<base>/index.bundle` for a One app whose manifest
115
+ * names a different entry point entirely. */
116
+ type ManifestState = 'resolved' | 'no-manifest' | 'no-answer'
117
+
61
118
  // localhost dev servers respond in well under 100ms when alive. drop dead
62
119
  // branches fast so a non-bundler process doesn't drag scan latency.
63
120
  const TIMEOUT_MS = 250
@@ -74,6 +131,11 @@ const MANIFEST_TIMEOUT_MS = 1500
74
131
  // within this budget the port isn't actually reachable, and all 5 HTTP probes
75
132
  // would just time out for nothing.
76
133
  const TCP_GATE_MS = 120
134
+ // once a server has sent response headers we never abort it (see httpGet). this
135
+ // only bounds a response that never ends, so it is deliberately far above any
136
+ // real manifest: aborting a slow-but-answering server is the defect this whole
137
+ // path exists to avoid.
138
+ const BODY_TIMEOUT_MS = 30_000
77
139
 
78
140
  interface HttpResult {
79
141
  statusCode: number
@@ -122,33 +184,65 @@ function httpGet(
122
184
  headers: Record<string, string> = {},
123
185
  ): Promise<HttpResult | null> {
124
186
  return new Promise((resolve) => {
125
- const req = http.request(
126
- { hostname, port, path, method, timeout, headers },
127
- (res) => {
128
- let body = ''
129
- res.on('data', (c: Buffer) => (body += c.toString()))
130
- const contentType = (() => {
131
- const raw = res.headers['content-type']
132
- if (typeof raw === 'string') return raw
133
- if (Array.isArray(raw)) return raw[0]
134
- return undefined
135
- })()
136
- res.on('end', () =>
137
- resolve({
138
- statusCode: res.statusCode || 0,
139
- body,
140
- contentType,
141
- hostname,
142
- }),
143
- )
144
- },
145
- )
146
- req.on('error', () => resolve(null))
147
- req.setTimeout(timeout, () => {
148
- req.destroy()
149
- resolve(null)
187
+ // first answer wins: the response, the deadline, and the connection closing
188
+ // all race, and every one of them can arrive after another has decided.
189
+ let settled = false
190
+ const settle = (value: HttpResult | null) => {
191
+ if (settled) return
192
+ settled = true
193
+ resolve(value)
194
+ }
195
+ // NO socket timeout here, deliberately. `options.timeout` / `req.setTimeout`
196
+ // make node DESTROY the connection when the budget expires, and destroying a
197
+ // dev server's connection while it is still assembling its manifest fires
198
+ // "Cannot pipe to a closed or destroyed stream" into One's manifest
199
+ // middleware as an unhandled rejection — the hazard the knownOne comment
200
+ // below already documents, which can exit the user's dev server. measured:
201
+ // six probes with a socket timeout produced six of those errors, six probes
202
+ // that give up in JS produced none, and the request completes later with the
203
+ // server none the wiser.
204
+ //
205
+ // giving up WAITING and KILLING THE REQUEST are different acts, and only the
206
+ // first one is ours to take. the deadline below resolves the scan on time;
207
+ // the request runs itself out in the background and its result is discarded.
208
+ const req = http.request({ hostname, port, path, method, headers }, (res) => {
209
+ let body = ''
210
+ res.on('data', (c: Buffer) => (body += c.toString()))
211
+ const contentType = (() => {
212
+ const raw = res.headers['content-type']
213
+ if (typeof raw === 'string') return raw
214
+ if (Array.isArray(raw)) return raw[0]
215
+ return undefined
216
+ })()
217
+ res.on('end', () =>
218
+ settle({
219
+ statusCode: res.statusCode || 0,
220
+ body,
221
+ contentType,
222
+ hostname,
223
+ }),
224
+ )
150
225
  })
226
+ req.on('error', () => settle(null))
151
227
  req.end()
228
+ const giveUp = setTimeout(() => {
229
+ settle(null)
230
+ // nothing is waiting on it now, so it must not hold a CLI process open.
231
+ req.socket?.unref()
232
+ }, timeout)
233
+ // the only thing that ever destroys a connection here. by BODY_TIMEOUT_MS a
234
+ // server has long since answered or died, so there is no live write to cut.
235
+ const abandon = setTimeout(() => req.destroy(), BODY_TIMEOUT_MS)
236
+ abandon.unref?.()
237
+ req.on('close', () => {
238
+ clearTimeout(giveUp)
239
+ clearTimeout(abandon)
240
+ // `close` on the request fires BEFORE the response's own `end` handler,
241
+ // so settling synchronously here turns every successful probe into a
242
+ // null. defer one tick and let a real answer win; this only settles the
243
+ // case where the connection closed without one.
244
+ setImmediate(() => settle(null))
245
+ })
152
246
  })
153
247
  }
154
248
 
@@ -165,70 +259,143 @@ const FALLBACK_PORTS: ListeningProcess[] = [
165
259
  8081, 8082, 8083, 8084, 8085, 8086, 3000, 3001, 19000,
166
260
  ].map((port) => ({ port, pid: 0 }))
167
261
 
262
+ // sootsim's own dev server range at PORT_OFFSET=0. an isolated stack shifts its
263
+ // shell by the same offset as every other port it owns, so the guard has to
264
+ // shift with it: at PORT_OFFSET=90 the shell listens on :5263 and a range fixed
265
+ // at 5170-5200 does not cover it, which lets a scan discover ITSELF and offer
266
+ // the shell as though it were an app's dev server. a guard that is only correct
267
+ // at offset 0 is wrong for everyone running an isolated stack, which is exactly
268
+ // who runs concurrent scans.
269
+ const SELF_PORT_RANGE_START = 5170
270
+ const SELF_PORT_RANGE_END = 5200
271
+
272
+ function isOwnShellPort(port: number): boolean {
273
+ const offset = Number(process.env.PORT_OFFSET) || 0
274
+ // the unshifted range still counts: another checkout's default-offset shell is
275
+ // no more an app dev server than our own is.
276
+ if (port >= SELF_PORT_RANGE_START && port <= SELF_PORT_RANGE_END) return true
277
+ return (
278
+ offset > 0 &&
279
+ port >= SELF_PORT_RANGE_START + offset &&
280
+ port <= SELF_PORT_RANGE_END + offset
281
+ )
282
+ }
283
+
168
284
  function acceptPort(port: number, excluded: Set<number>): boolean {
169
285
  if (port <= 0 || port >= 20000) return false
170
286
  if (excluded.has(port)) return false
171
- // sootsim's own dev server range — we never want to discover ourselves.
172
- if (port >= 5170 && port <= 5200) return false
287
+ if (isOwnShellPort(port)) return false
173
288
  return true
174
289
  }
175
290
 
291
+ /** ran the listing tool and these are the listeners, versus could not run it at
292
+ * all. an empty `processes` here means the tool looked and there was genuinely
293
+ * nothing; `could-not-look` means we have no information. substituting the
294
+ * hardcoded FALLBACK_PORTS for the second case is what made `/__server-scan`
295
+ * answer `[]` — "there are no dev servers" — to a user running three of them. */
296
+ export type ListeningProcessScan =
297
+ | { state: 'looked'; processes: ListeningProcess[] }
298
+ | { state: 'could-not-look' }
299
+
300
+ /** thrown by `scanDevServers` when listener enumeration itself failed. callers
301
+ * must treat it as "no information" — keep whatever list they already had, or
302
+ * answer their own caller with a failure — never as an empty result. */
303
+ export class DevServerScanUnavailableError extends Error {
304
+ constructor() {
305
+ super('could not enumerate listening processes (lsof/ss unavailable or timed out)')
306
+ this.name = 'DevServerScanUnavailableError'
307
+ }
308
+ }
309
+
310
+ // lsof walks every open fd on the machine, so on a busy host it is genuinely
311
+ // slow — measured between 1.2s and 5.0s here with 58 matching listeners. the
312
+ // budget has to fit the real worst case, because the alternative is not a
313
+ // faster answer, it is a WRONG one.
314
+ const LISTING_TIMEOUT_MS = 10_000
315
+
316
+ /** true when a failed exec was killed by our own timeout rather than by the
317
+ * command being absent or erroring — the difference between "could not look"
318
+ * and "this tool is not available here". */
319
+ function wasKilledByTimeout(err: unknown): boolean {
320
+ return !!(err && typeof err === 'object' && (err as { killed?: boolean }).killed)
321
+ }
322
+
176
323
  export async function discoverListeningProcesses(
177
324
  excludePorts: number[] = [],
178
325
  ): Promise<ListeningProcess[]> {
326
+ const scan = await scanListeningProcesses(excludePorts)
327
+ return scan.state === 'looked' ? scan.processes : []
328
+ }
329
+
330
+ export async function scanListeningProcesses(
331
+ excludePorts: number[] = [],
332
+ ): Promise<ListeningProcessScan> {
179
333
  const excluded = new Set(excludePorts)
334
+ let timedOut = false
180
335
 
181
336
  // lsof layout: COMMAND PID USER FD TYPE DEVICE SIZE NODE NAME
182
337
  // NAME looks like "*:8081" or "127.0.0.1:8081" for a LISTEN line.
183
338
  // async exec — sync blocked CrBrowserMain for ~600ms/scan on macOS.
184
339
  try {
185
- const { stdout } = await execP(
340
+ const { stdout } = await execGuarded(
186
341
  `lsof -iTCP -sTCP:LISTEN -P -n 2>/dev/null | grep -E '^(node|bun)'`,
187
- { encoding: 'utf8', timeout: 2000 },
342
+ LISTING_TIMEOUT_MS,
188
343
  )
189
- if (stdout.trim()) {
190
- const seen = new Map<number, number>()
191
- for (const line of stdout.trim().split('\n')) {
192
- const parts = line.trim().split(/\s+/)
193
- if (parts.length < 9) continue
194
- const pid = Number(parts[1])
195
- const addr = parts[8]
196
- const m = addr.match(/:(\d+)$/)
197
- if (!m) continue
198
- const port = Number(m[1])
199
- if (!acceptPort(port, excluded)) continue
200
- if (!seen.has(port)) seen.set(port, pid)
201
- }
202
- if (seen.size > 0) {
203
- return [...seen.entries()].map(([port, pid]) => ({ port, pid }))
204
- }
344
+ const seen = new Map<number, number>()
345
+ for (const line of stdout.trim() ? stdout.trim().split('\n') : []) {
346
+ const parts = line.trim().split(/\s+/)
347
+ if (parts.length < 9) continue
348
+ const pid = Number(parts[1])
349
+ const addr = parts[8]
350
+ const m = addr.match(/:(\d+)$/)
351
+ if (!m) continue
352
+ const port = Number(m[1])
353
+ if (!acceptPort(port, excluded)) continue
354
+ if (!seen.has(port)) seen.set(port, pid)
205
355
  }
206
- } catch {}
356
+ // lsof ran. an empty result is a real answer: nothing is listening.
357
+ return {
358
+ state: 'looked',
359
+ processes: [...seen.entries()].map(([port, pid]) => ({ port, pid })),
360
+ }
361
+ } catch (err) {
362
+ if (wasKilledByTimeout(err)) timedOut = true
363
+ }
207
364
 
208
365
  // ss layout: State Recv-Q Send-Q Local-Address:Port Peer-Addr:Port users:(("node",pid=1234,fd=5))
209
366
  try {
210
- const { stdout } = await execP(`ss -tlnp 2>/dev/null | grep -E '"(node|bun)"'`, {
211
- encoding: 'utf8',
212
- timeout: 2000,
213
- })
214
- if (stdout.trim()) {
215
- const seen = new Map<number, number>()
216
- for (const line of stdout.trim().split('\n')) {
217
- const portMatch = line.match(/:(\d+)\s/)
218
- const pidMatch = line.match(/pid=(\d+)/)
219
- if (!portMatch) continue
220
- const port = Number(portMatch[1])
221
- const pid = pidMatch ? Number(pidMatch[1]) : 0
222
- if (!acceptPort(port, excluded)) continue
223
- if (!seen.has(port)) seen.set(port, pid)
224
- }
225
- if (seen.size > 0) {
226
- return [...seen.entries()].map(([port, pid]) => ({ port, pid }))
227
- }
367
+ const { stdout } = await execGuarded(
368
+ `ss -tlnp 2>/dev/null | grep -E '"(node|bun)"'`,
369
+ LISTING_TIMEOUT_MS,
370
+ )
371
+ const seen = new Map<number, number>()
372
+ for (const line of stdout.trim() ? stdout.trim().split('\n') : []) {
373
+ const portMatch = line.match(/:(\d+)\s/)
374
+ const pidMatch = line.match(/pid=(\d+)/)
375
+ if (!portMatch) continue
376
+ const port = Number(portMatch[1])
377
+ const pid = pidMatch ? Number(pidMatch[1]) : 0
378
+ if (!acceptPort(port, excluded)) continue
379
+ if (!seen.has(port)) seen.set(port, pid)
228
380
  }
229
- } catch {}
381
+ return {
382
+ state: 'looked',
383
+ processes: [...seen.entries()].map(([port, pid]) => ({ port, pid })),
384
+ }
385
+ } catch (err) {
386
+ if (wasKilledByTimeout(err)) timedOut = true
387
+ }
388
+
389
+ // a tool that timed out told us nothing, so say so rather than substituting
390
+ // the guess list below and reporting its emptiness as fact.
391
+ if (timedOut) return { state: 'could-not-look' }
230
392
 
231
- return FALLBACK_PORTS.filter((p) => acceptPort(p.port, excluded))
393
+ // neither tool exists here, so probe the conventional bundler ports directly.
394
+ // that is a real (if narrow) look, not a substitute for one.
395
+ return {
396
+ state: 'looked',
397
+ processes: FALLBACK_PORTS.filter((p) => acceptPort(p.port, excluded)),
398
+ }
232
399
  }
233
400
 
234
401
  // kept for backwards compatibility with any external callers that only need
@@ -284,9 +451,7 @@ export async function resolveListeningPid(port: number): Promise<number | null>
284
451
  // the local address uses a dot before the port, and the owner column is the
285
452
  // only token shaped "<name>:<pid>".
286
453
  try {
287
- const { stdout } = await execP('netstat -anv -p tcp 2>/dev/null', {
288
- encoding: 'utf8',
289
- timeout: 8000,
454
+ const { stdout } = await execGuarded('netstat -anv -p tcp 2>/dev/null', 8000, {
290
455
  maxBuffer: 16 * 1024 * 1024,
291
456
  })
292
457
  const pid = pidFromNetstat(stdout, port)
@@ -295,9 +460,7 @@ export async function resolveListeningPid(port: number): Promise<number | null>
295
460
 
296
461
  // linux ss: 'LISTEN 0 511 0.0.0.0:8081 0.0.0.0:* users:(("node",pid=1234,fd=5))'
297
462
  try {
298
- const { stdout } = await execP(`ss -tlnp 2>/dev/null`, {
299
- encoding: 'utf8',
300
- timeout: 8000,
463
+ const { stdout } = await execGuarded(`ss -tlnp 2>/dev/null`, 8000, {
301
464
  maxBuffer: 16 * 1024 * 1024,
302
465
  })
303
466
  for (const line of stdout.split('\n')) {
@@ -344,10 +507,10 @@ export async function resolveProcessCwd(pid: number): Promise<string | null> {
344
507
  // p<pid>
345
508
  // fcwd
346
509
  // n<absolute path>
347
- const { stdout } = await execP(`lsof -b -w -p ${pid} -a -d cwd -Fn 2>/dev/null`, {
348
- encoding: 'utf8',
349
- timeout: 8000,
350
- })
510
+ const { stdout } = await execGuarded(
511
+ `lsof -b -w -p ${pid} -a -d cwd -Fn 2>/dev/null`,
512
+ 8000,
513
+ )
351
514
  for (const line of stdout.split('\n')) {
352
515
  if (line.startsWith('n') && line.length > 1) {
353
516
  const cwd = line.slice(1).trim()
@@ -672,7 +835,16 @@ export async function probePort(
672
835
  (manifestParsed?.extra?.expoClient as { name?: unknown } | undefined) ||
673
836
  (manifestParsed?.extra as { name?: unknown } | undefined) ||
674
837
  {}
675
- if (manifestParsed && (manifestLaunchUrl || typeof manifestClient.name === 'string')) {
838
+ // `manifestRes === null` means the probe got NO answer — it timed out or the
839
+ // connection failed. that is not the same fact as a server that answered and
840
+ // turned out to have no manifest, and only the second one licenses the
841
+ // generic `/index.bundle` entry point below.
842
+ const manifestState: ManifestState = !manifestRes
843
+ ? 'no-answer'
844
+ : manifestParsed && (manifestLaunchUrl || typeof manifestClient.name === 'string')
845
+ ? 'resolved'
846
+ : 'no-manifest'
847
+ if (manifestState === 'resolved') {
676
848
  knownNonPatched.add(port)
677
849
  const launchUrl =
678
850
  manifestLaunchUrl ||
@@ -708,17 +880,23 @@ export async function probePort(
708
880
  // never fire the HEAD against them.
709
881
  if (statusRes && statusRes.body.includes('packager-status:running')) {
710
882
  knownNonPatched.add(port)
883
+ // `makeResult` builds the generic `<base>/index.bundle` entry point. that is
884
+ // the RIGHT answer for a vanilla Metro or plain Expo packager, which is a
885
+ // server that ANSWERED `/` and had no manifest to give. it is a guess when
886
+ // the manifest simply never answered: this server may well be a One app
887
+ // whose manifest names `node_modules/one/metro-entry.bundle`, and opening
888
+ // the generic path instead makes metro build the whole app a second time
889
+ // under a module graph the app never named. flag it so `open` refuses to
890
+ // act on it, while the dock still lists the server it can plainly see.
891
+ const result = makeResult(
892
+ probeHostname,
893
+ port,
894
+ expoRes && expoRes.statusCode === 200 ? 'expo' : 'metro',
895
+ owner,
896
+ )
897
+ if (manifestState === 'no-answer') result.bundleUrlProvisional = true
711
898
  return enrichWithManifest(
712
- applyManifest(
713
- makeResult(
714
- probeHostname,
715
- port,
716
- expoRes && expoRes.statusCode === 200 ? 'expo' : 'metro',
717
- owner,
718
- ),
719
- manifestRes,
720
- buildIconProxyUrl,
721
- ),
899
+ applyManifest(result, manifestRes, buildIconProxyUrl),
722
900
  buildIconProxyUrl,
723
901
  )
724
902
  }
@@ -914,7 +1092,12 @@ export async function scanDevServers(
914
1092
  // disk contention delays the import. memoized, so this is free after the
915
1093
  // first call.
916
1094
  await loadOptionalDemoApps()
917
- const processes = await discoverListeningProcesses(opts.excludePorts)
1095
+ const scan = await scanListeningProcesses(opts.excludePorts)
1096
+ // we could not enumerate listeners, so we know nothing about what is running.
1097
+ // returning [] here would evict every cache entry and report "no dev servers"
1098
+ // to a user who has several — the caller must be able to tell the two apart.
1099
+ if (scan.state === 'could-not-look') throw new DevServerScanUnavailableError()
1100
+ const processes = scan.processes
918
1101
  const currentPorts = new Set(processes.map((p) => p.port))
919
1102
 
920
1103
  // evict cache entries for ports that disappeared. a restarted process may