rnxsim 0.1.451 → 0.1.453

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/cli/bridge-flow-runner.ts +41 -13
  2. package/cli/cloud-client.ts +10 -0
  3. package/cli/commands/control.ts +52 -7
  4. package/cli/commands/debug.ts +42 -11
  5. package/cli/commands/inspect/core.ts +33 -13
  6. package/cli/commands/inspect/settling.ts +35 -3
  7. package/cli/drivers/playwright.ts +2 -4
  8. package/cli/ws-bridge.ts +72 -5
  9. package/dist-lib/agent-daemon-client.cjs +1 -1
  10. package/dist-lib/agent-events.cjs +1 -1
  11. package/dist-lib/agent-identity.cjs +1 -1
  12. package/dist-lib/agent-sessions.cjs +1 -1
  13. package/dist-lib/attached-projects.cjs +1 -1
  14. package/dist-lib/auth/shared-session.cjs +1 -1
  15. package/dist-lib/backend-origin.cjs +1 -1
  16. package/dist-lib/beta.cjs +1 -1
  17. package/dist-lib/beta.mjs +1 -1
  18. package/dist-lib/bridge-constants.cjs +1 -1
  19. package/dist-lib/bridge-contract-input.cjs +1 -1
  20. package/dist-lib/bridge-contract-input.mjs +1 -1
  21. package/dist-lib/bridge-contract.cjs +1 -1
  22. package/dist-lib/bridge-contract.mjs +1 -1
  23. package/dist-lib/capture-contract.cjs +1 -1
  24. package/dist-lib/capture-contract.mjs +1 -1
  25. package/dist-lib/cli-constants.cjs +1 -1
  26. package/dist-lib/cloud-contract.cjs +1 -1
  27. package/dist-lib/cloud-contract.mjs +1 -1
  28. package/dist-lib/cloud.cjs +1 -1
  29. package/dist-lib/cloud.mjs +1 -1
  30. package/dist-lib/config.cjs +1 -1
  31. package/dist-lib/detox/index.cjs +1 -1
  32. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  33. package/dist-lib/home-paths.cjs +1 -1
  34. package/dist-lib/host/bridge-host.cjs +1 -1
  35. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  36. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  37. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  38. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  39. package/dist-lib/host/websocket-proxy.cjs +1 -1
  40. package/dist-lib/index.cjs +89 -8
  41. package/dist-lib/jump-to-source-babel.cjs +1 -1
  42. package/dist-lib/jump-to-source-native.cjs +1 -1
  43. package/dist-lib/menu.cjs +1 -1
  44. package/dist-lib/menu.mjs +1 -1
  45. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  46. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  47. package/dist-lib/metro-production-bundle.cjs +1 -1
  48. package/dist-lib/metro-production-bundle.mjs +1 -1
  49. package/dist-lib/metro.cjs +1 -1
  50. package/dist-lib/profiles.cjs +1 -1
  51. package/dist-lib/public-brand.cjs +1 -1
  52. package/dist-lib/react-native-host-modules.cjs +1 -1
  53. package/dist-lib/react-native-host-modules.mjs +1 -1
  54. package/dist-lib/render-mode.cjs +1 -1
  55. package/dist-lib/scripts/dev-server-scanner.cjs +1 -1
  56. package/dist-lib/sdk.cjs +59 -12
  57. package/dist-lib/sdk.mjs +58 -12
  58. package/dist-lib/skills.cjs +2349 -282
  59. package/dist-lib/vite.cjs +1 -1
  60. package/package.json +1 -1
  61. package/src/flow-timeout-scale.ts +15 -0
  62. package/src/playwright-host-log.ts +16 -0
  63. package/src/sim-client.ts +4 -0
@@ -6,10 +6,12 @@ import {
6
6
  REQUIRED_IDENTICAL_PROOF_FRAMES,
7
7
  framesIdentical,
8
8
  } from '../detox/proof-frame.cjs'
9
+ import { flowTimeoutScale } from '../src/flow-timeout-scale'
9
10
  import { composeFramedScreenshot } from '../src/screenshots/frame-compose'
10
11
  import { inspectWaitReady, waitReadyReason } from './commands/inspect/core'
11
12
  import { waitForSootsimIdle } from './commands/inspect/settling'
12
13
  import { callShellCommandWhenReady, getShellState } from './commands/inspect/shared'
14
+ import { saveCurrentSimId } from './current-sim'
13
15
  import { FlowLiveStatusReporter } from './flow-live-status'
14
16
  import { MaestroJsContext, scriptConditionIsTruthy } from './maestro-js'
15
17
  import { ensureCliRecordingEntitlement } from './recording-access'
@@ -21,16 +23,7 @@ import type {
21
23
  } from '@rnx/globals'
22
24
 
23
25
  const DEFAULT_TIMEOUT = 10000
24
- // hosted-runner previews reach first-render much slower than local dev: the
25
- // guest bundle (often 20+ MB) re-evals and re-hydrates on every launchApp hard
26
- // reload, and it is served over a cloudflared tunnel. SOOTSIM_FLOW_TIMEOUT_SCALE
27
- // multiplies every flow wait deadline so a slow-but-working app clears its own
28
- // `.maestro` waits without editing per-app timeouts. off (1x) locally; run.sh
29
- // sets it for the recording lane. clamped to 1..10.
30
- const FLOW_TIMEOUT_SCALE = Math.min(
31
- 10,
32
- Math.max(1, Number(process.env.SOOTSIM_FLOW_TIMEOUT_SCALE) || 1),
33
- )
26
+ const FLOW_TIMEOUT_SCALE = flowTimeoutScale()
34
27
  const scaledTimeout = (ms: number) => Math.round(ms * FLOW_TIMEOUT_SCALE)
35
28
  const SCREEN_W = 393
36
29
  const SCREEN_H = 852
@@ -496,11 +489,39 @@ export class SootSimBridgeFlowRunner {
496
489
  }
497
490
 
498
491
  async waitForTree(timeout: number = DEFAULT_TIMEOUT) {
499
- const status = await inspectWaitReady(this.bridge, scaledTimeout(timeout))
492
+ let status = await inspectWaitReady(this.bridge, scaledTimeout(timeout))
500
493
  if (status.ready) return
501
494
  if (status.externalError) {
502
495
  throw new Error(`app failed to load: ${status.externalError}`)
503
496
  }
497
+ if (status.bridgeError) {
498
+ // the guest re-registers under a new sim id whenever its page reloads,
499
+ // and every command here pins this.opts.simId, so a reload strands the
500
+ // whole flow on a disposed render host. re-pin to the same browser
501
+ // host's successor and read the tree again before calling it dead.
502
+ // the successor does not appear the instant the old sim is disposed —
503
+ // the page has to finish reloading and re-register — so give it the same
504
+ // budget the rest of the flow waits on rather than looking exactly once.
505
+ const pinned = this.opts.simId
506
+ let successor: string | null = null
507
+ if (pinned) {
508
+ const deadline = Date.now() + scaledTimeout(15000)
509
+ do {
510
+ successor = await this.bridge.resolveReloadedSim(pinned)
511
+ if (successor) break
512
+ await sleep(500)
513
+ } while (Date.now() < deadline)
514
+ }
515
+ if (successor) {
516
+ console.error(
517
+ ` note: sim ${pinned} reloaded and reconnected as ${successor}; following it`,
518
+ )
519
+ this.opts.simId = successor
520
+ saveCurrentSimId(successor)
521
+ status = await inspectWaitReady(this.bridge, scaledTimeout(timeout))
522
+ if (status.ready) return
523
+ }
524
+ }
504
525
  if (status.bridgeError) {
505
526
  throw new Error(
506
527
  `target sim is not responding to the bridge (${status.bridgeError}). ` +
@@ -1989,7 +2010,9 @@ export class SootSimBridgeFlowRunner {
1989
2010
  strict: true,
1990
2011
  })
1991
2012
  if (!result.settled) {
1992
- throw new Error(`visual state did not settle within ${result.elapsed}ms`)
2013
+ throw new Error(
2014
+ `visual state did not settle within ${result.elapsed}ms (${result.blockedBy})`,
2015
+ )
1993
2016
  }
1994
2017
  }
1995
2018
 
@@ -2290,7 +2313,12 @@ export class SootSimBridgeFlowRunner {
2290
2313
  step.runFlow ||
2291
2314
  // runScript http calls are synchronous with a 5-minute upstream
2292
2315
  // timeout — the 10s watchdog would kill legitimate setup scripts.
2293
- step.runScript
2316
+ step.runScript ||
2317
+ // takeScreenshot self-governs: it retries a bounded visual settle that
2318
+ // already fails with its own reason. sharing the watchdog's 10s budget
2319
+ // meant the watchdog always won that race, reporting "bridge probably
2320
+ // hung" for a screen that was merely still animating.
2321
+ step.takeScreenshot
2294
2322
  )
2295
2323
  const STEP_WATCHDOG_MS = 10_000
2296
2324
  const body = this.runStepInner(step)
@@ -1001,6 +1001,11 @@ class CloudBridge implements WsBridge {
1001
1001
  return Promise.reject(new Error('remote simulators have no browser window to focus'))
1002
1002
  }
1003
1003
 
1004
+ resolveReloadedSim(): Promise<string | null> {
1005
+ // cloud sims carry no local browser host, so there is no lineage to follow
1006
+ return Promise.resolve(null)
1007
+ }
1008
+
1004
1009
  closeSim(): Promise<never> {
1005
1010
  return Promise.reject(
1006
1011
  new Error('remote simulator close uses the simulator DELETE route'),
@@ -1091,6 +1096,11 @@ class CloudBoxBridge implements WsBridge {
1091
1096
  return Promise.reject(new Error('a box simulator has no browser window to focus'))
1092
1097
  }
1093
1098
 
1099
+ resolveReloadedSim(): Promise<string | null> {
1100
+ // cloud sims carry no local browser host, so there is no lineage to follow
1101
+ return Promise.resolve(null)
1102
+ }
1103
+
1094
1104
  closeSim(): Promise<never> {
1095
1105
  return Promise.reject(new Error('close a box simulator with rnx box stop'))
1096
1106
  }
@@ -22,6 +22,7 @@ import {
22
22
  resolveConnectionInput,
23
23
  type ResolvedDevBundle,
24
24
  } from '../../src/dev-bundle-resolution'
25
+ import { flowTimeoutScale } from '../../src/flow-timeout-scale'
25
26
  import {
26
27
  isDaemonLockfileFresh,
27
28
  isDevBridgeLockfileFresh,
@@ -844,10 +845,21 @@ async function waitForSimReady(
844
845
  minNodeCount?: number
845
846
  } = {},
846
847
  ) {
847
- const attempts = opts.attempts ?? 30
848
+ // the ceiling here is attempts * intervalMs. a hosted launchApp re-evals the
849
+ // whole guest bundle over a tunnel, which routinely outlasts the local
850
+ // default, so scale the poll ceiling by the same factor the flow runner
851
+ // scales its own waits. this still returns the moment the sim reports enough
852
+ // nodes; only the deadline moves, and locally the scale is 1.
848
853
  const intervalMs = opts.intervalMs ?? 500
854
+ const attempts = opts.attempts ?? Math.round(30 * flowTimeoutScale())
849
855
  const minNodeCount = opts.minNodeCount ?? 10
850
856
 
857
+ // a poll that only ever reports "timed out" cannot say whether the guest was
858
+ // still booting, never installed the test hook, or was unreachable, so keep
859
+ // the last observation and hand it to the caller for the failure message.
860
+ let lastCount: unknown
861
+ let lastError = ''
862
+
851
863
  for (let i = 0; i < attempts; i++) {
852
864
  const bridge = createBridge(wsPort, {
853
865
  commandTimeoutMs,
@@ -857,19 +869,21 @@ async function waitForSimReady(
857
869
  try {
858
870
  const count = await bridge.send({
859
871
  type: 'evaluate',
860
- code: '(async () => (await window.__sootsimTest?.getNodeCount()) || 0)()',
872
+ code: '(async () => (await window.__sootsimTest?.getNodeCount()) ?? null)()',
861
873
  })
874
+ lastCount = count
875
+ lastError = ''
862
876
  if (typeof count === 'number' && count > minNodeCount) {
863
877
  return { bridge, count }
864
878
  }
865
- } catch {
866
- // still loading or disconnected
879
+ } catch (err) {
880
+ lastError = err instanceof Error ? err.message : String(err)
867
881
  }
868
882
  bridge.close()
869
883
  await sleep(intervalMs)
870
884
  }
871
885
 
872
- return null
886
+ return { bridge: null, attempts, intervalMs, minNodeCount, lastCount, lastError }
873
887
  }
874
888
 
875
889
  export async function waitForSimMatch(
@@ -1511,6 +1525,7 @@ export async function runOpenCommand(
1511
1525
  const bridge = createBridgeFromParsed(parsed)
1512
1526
  const simHint = parsed.simId ? ` --sim ${parsed.simId}` : ''
1513
1527
  let fallBackToNewSim = false
1528
+ let preNavSimIds = new Set<string>()
1514
1529
 
1515
1530
  try {
1516
1531
  let targetSim: BridgeSimInfo | null = null
@@ -1570,6 +1585,11 @@ export async function runOpenCommand(
1570
1585
  }
1571
1586
  }
1572
1587
  if (!fallBackToNewSim && targetSim) {
1588
+ // a navigation that crosses origins tears down the page context, so
1589
+ // the guest reconnects as a NEW sim and the id we are holding stops
1590
+ // existing. record what was connected first, so the wait below can
1591
+ // follow the reload instead of polling an id the browser retired.
1592
+ preNavSimIds = new Set(sims.map((sim) => sim.id))
1573
1593
  const resolvedBaseUrl =
1574
1594
  hasExplicitBaseUrl || looksLikeSootsimUrl(target)
1575
1595
  ? baseUrl
@@ -1603,13 +1623,38 @@ export async function runOpenCommand(
1603
1623
 
1604
1624
  if (!fallBackToNewSim && targetSim) {
1605
1625
  await sleep(1500)
1626
+ const navSim = targetSim
1627
+ const reloadedSim = await waitForSimMatch(
1628
+ parsed.wsPort,
1629
+ parsed.commandTimeoutMs,
1630
+ (sim) =>
1631
+ sim.readyState === 'open' &&
1632
+ simUsesDriver(sim, driverId) &&
1633
+ (sim.id === navSim.id || (!preNavSimIds.has(sim.id) && sim.isPrimary)),
1634
+ { attempts: Math.round(30 * flowTimeoutScale()) },
1635
+ )
1636
+ if (reloadedSim && reloadedSim.id !== navSim.id) {
1637
+ targetSim = reloadedSim
1638
+ }
1606
1639
  const ready = await waitForSimReady(
1607
1640
  parsed.wsPort,
1608
1641
  parsed.commandTimeoutMs,
1609
1642
  targetSim.id,
1610
1643
  )
1611
- if (!ready) {
1612
- console.error(' timed out waiting for current sim to load target')
1644
+ if (!ready.bridge) {
1645
+ const observed = ready.lastError
1646
+ ? `last bridge error: ${ready.lastError}`
1647
+ : ready.lastCount === null
1648
+ ? 'guest never installed window.__sootsimTest'
1649
+ : `last node count: ${String(ready.lastCount)} (needs > ${ready.minNodeCount})`
1650
+ console.error(
1651
+ ` timed out waiting for current sim to load target after ${ready.attempts} polls over ${Math.round((ready.attempts * ready.intervalMs) / 1000)}s; ${observed}`,
1652
+ )
1653
+ await printBridgeFailureDiagnostics(bridge, {
1654
+ errorsCommand: `rnx get errors 5${simHint}`,
1655
+ warningsCommand: `rnx get warnings 5${simHint}`,
1656
+ requestsCommand: `rnx get requests 5${simHint}`,
1657
+ })
1613
1658
  rnxExit(1)
1614
1659
  }
1615
1660
  ready.bridge.close()
@@ -28,6 +28,7 @@ import {
28
28
  type WsBridge,
29
29
  } from '../ws-bridge'
30
30
  import {
31
+ callDebugBridge,
31
32
  inspectDebugFind,
32
33
  inspectDebugFlags,
33
34
  inspectDebugRecent,
@@ -63,15 +64,19 @@ rnx debug — drive __sootsimDebug from the terminal
63
64
  usage:
64
65
  rnx debug <subcommand> [args]
65
66
 
67
+ record, recent, and clear-events target the tenant; --host selects the page buffer.
68
+ enable/disable/toggle also update the tenant unless --host is passed.
69
+ shell event buffers are separate; shell console logs are forwarded to the host.
70
+
66
71
  subcommands:
67
- enable <channels> turn on one or more debug channels
72
+ enable <channels> turn on host, shell/compositor, and tenant channels
68
73
  channels: ${KNOWN_CHANNELS.slice(0, -1).join(', ')}
69
74
  or 'all' for every channel
70
75
  disable <channels> turn off one or more debug channels (or 'all')
71
76
  toggle <channel> flip a single channel
72
- status list currently-enabled channels
77
+ status list host channels forwarded to shell/compositor
73
78
  channels list every known channel name
74
- flags print the full DEBUG flag object
79
+ flags print the host DEBUG flag object
75
80
 
76
81
  state <kind> ... dump raw runtime state (diagnostic — not a getter)
77
82
  kinds: shell, worker, keyboard, node <id>,
@@ -393,7 +398,7 @@ function printImageAudit(
393
398
  export async function runDebug(args: string[], opts: DebugOptions) {
394
399
  const parsed = parseBridgeCliArgs(args, {
395
400
  port: opts.port,
396
- stripBooleanFlags: ['--pretty', '--json', '--help', '-h'],
401
+ stripBooleanFlags: ['--pretty', '--json', '--help', '-h', '--host'],
397
402
  })
398
403
  const positional = parsed.positional
399
404
  const subcommand = positional[0]
@@ -441,14 +446,24 @@ export async function runDebug(args: string[], opts: DebugOptions) {
441
446
  )
442
447
  rnxExit(1)
443
448
  }
444
- const active = await setDebugChannels(bridge, 'enable', channels)
449
+ const active = await setDebugChannels(
450
+ bridge,
451
+ 'enable',
452
+ channels,
453
+ args.includes('--host'),
454
+ )
445
455
  console.log(fmt({ active }, pretty))
446
456
  break
447
457
  }
448
458
 
449
459
  case 'disable': {
450
460
  const channels = parseChannelList(rest[0])
451
- const active = await setDebugChannels(bridge, 'disable', channels)
461
+ const active = await setDebugChannels(
462
+ bridge,
463
+ 'disable',
464
+ channels,
465
+ args.includes('--host'),
466
+ )
452
467
  console.log(fmt({ active }, pretty))
453
468
  break
454
469
  }
@@ -459,10 +474,17 @@ export async function runDebug(args: string[], opts: DebugOptions) {
459
474
  console.error(' usage: rnx debug toggle <channel>')
460
475
  rnxExit(1)
461
476
  }
462
- const result = await call(
477
+ const result = await callDebugBridge(
463
478
  bridge,
464
- `window.__sootsimDebug.toggle(${JSON.stringify(channel)})`,
479
+ `toggle(${JSON.stringify(channel)})`,
480
+ true,
465
481
  )
482
+ if (!args.includes('--host')) {
483
+ await callDebugBridge(
484
+ bridge,
485
+ `${result ? 'enable' : 'disable'}(${JSON.stringify(channel)})`,
486
+ )
487
+ }
466
488
  console.log(fmt({ [channel]: result }, pretty))
467
489
  break
468
490
  }
@@ -681,7 +703,11 @@ export async function runDebug(args: string[], opts: DebugOptions) {
681
703
  case 'record': {
682
704
  const target = rest[0]
683
705
  const on = target === 'on' ? 'true' : target === 'off' ? 'false' : 'undefined'
684
- const result = await call(bridge, `window.__sootsimDebug.record(${on})`)
706
+ const result = await callDebugBridge(
707
+ bridge,
708
+ `record(${on})`,
709
+ args.includes('--host'),
710
+ )
685
711
  console.log(fmt({ recording: result }, pretty))
686
712
  break
687
713
  }
@@ -689,13 +715,18 @@ export async function runDebug(args: string[], opts: DebugOptions) {
689
715
  case 'recent': {
690
716
  const channel = rest[0]
691
717
  const limit = rest[1] ? Number(rest[1]) : 50
692
- const result = await inspectDebugRecent(bridge, channel, limit)
718
+ const result = await inspectDebugRecent(
719
+ bridge,
720
+ channel,
721
+ limit,
722
+ args.includes('--host'),
723
+ )
693
724
  console.log(fmt(result, pretty))
694
725
  break
695
726
  }
696
727
 
697
728
  case 'clear-events': {
698
- await call(bridge, 'window.__sootsimDebug.clearEvents()')
729
+ await callDebugBridge(bridge, 'clearEvents()', args.includes('--host'))
699
730
  console.log(fmt({ cleared: true }, pretty))
700
731
  break
701
732
  }
@@ -1767,26 +1767,46 @@ export async function inspectDebugFind(
1767
1767
  })
1768
1768
  }
1769
1769
 
1770
- // `debug recent` recent debug events, optionally filtered to one channel.
1771
- // only channels enabled via `setDebugChannels` record events.
1770
+ // debug buffers belong to their execution context. tenant calls use the
1771
+ // existing test bridge; host calls keep shell/compositor flag forwarding intact.
1772
+ export async function callDebugBridge(
1773
+ bridge: InspectBridge,
1774
+ expression: string,
1775
+ host = false,
1776
+ ): Promise<unknown> {
1777
+ const code = `globalThis.__sootsimDebug.${expression}`
1778
+ if (host) return bridge.send({ type: 'evaluate', code })
1779
+ return bridge.send({
1780
+ type: 'evaluate',
1781
+ code: `(async () => {
1782
+ const result = await window.__sootsimTest.evalInTenant(${JSON.stringify(code)})
1783
+ if (!result.ok) throw new Error(result.error)
1784
+ return result.value
1785
+ })()`,
1786
+ })
1787
+ }
1788
+
1789
+ // `debug recent` reads tenant events; --host explicitly selects the page buffer.
1772
1790
  export async function inspectDebugRecent(
1773
1791
  bridge: InspectBridge,
1774
1792
  channel?: string,
1775
1793
  limit = 50,
1794
+ host = false,
1776
1795
  ): Promise<unknown> {
1777
- const code =
1778
- channel && channel !== 'all'
1779
- ? `window.__sootsimDebug.recent(${JSON.stringify(channel)}, ${limit})`
1780
- : `window.__sootsimDebug.recent(undefined, ${limit})`
1781
- return bridge.send({ type: 'evaluate', code })
1796
+ return callDebugBridge(
1797
+ bridge,
1798
+ `recent(${channel && channel !== 'all' ? JSON.stringify(channel) : 'undefined'}, ${limit})`,
1799
+ host,
1800
+ )
1782
1801
  }
1783
1802
 
1784
- // `debug enable|disable <channels>` turn debug channels on or off. returns
1785
- // the resulting active-channel set. an empty list with `disable` clears all.
1803
+ // channel controls reach the host (and its shell/compositor subscribers) plus
1804
+ // the active tenant. await the tenant reply before reporting success.
1786
1805
  export async function setDebugChannels(
1787
1806
  bridge: InspectBridge,
1788
1807
  action: 'enable' | 'disable',
1789
1808
  channels: string[],
1809
+ host = false,
1790
1810
  ): Promise<unknown> {
1791
1811
  const args =
1792
1812
  channels.length > 0
@@ -1794,10 +1814,10 @@ export async function setDebugChannels(
1794
1814
  : action === 'disable'
1795
1815
  ? "'all'"
1796
1816
  : ''
1797
- return bridge.send({
1798
- type: 'evaluate',
1799
- code: `window.__sootsimDebug.${action}(${args})`,
1800
- })
1817
+ const expression = `${action}(${args})`
1818
+ const active = await callDebugBridge(bridge, expression, true)
1819
+ if (host) return active
1820
+ return callDebugBridge(bridge, expression)
1801
1821
  }
1802
1822
 
1803
1823
  // ─── memory ───
@@ -12,6 +12,10 @@ export type WaitForSootsimIdleOptions = {
12
12
  export type WaitForSootsimIdleResult = {
13
13
  elapsed: number
14
14
  settled: boolean
15
+ // what the last poll still saw moving. a settle that only reports "did not
16
+ // settle" cannot tell a slow screen from one that animates forever, and the
17
+ // two need opposite fixes.
18
+ blockedBy: string
15
19
  }
16
20
 
17
21
  export async function waitForSootsimIdle({
@@ -117,8 +121,19 @@ export async function waitForSootsimIdle({
117
121
  let lastRequestTotal = -1
118
122
  let stable = 0
119
123
  let visuallyStillSince = 0
124
+ // counts across the whole budget: a flag that is true on every poll is a
125
+ // different bug from one that is true intermittently, and the two need
126
+ // opposite fixes (engine vs budget).
127
+ let polls = 0
128
+ let dirtyPolls = 0
129
+ let animatingPolls = 0
130
+ let layoutChangedPolls = 0
120
131
  while (Date.now() < deadline) {
121
132
  const snapshot = await readSnapshot()
133
+ polls++
134
+ if (snapshot.layoutDirty) dirtyPolls++
135
+ if (snapshot.animating) animatingPolls++
136
+ if (polls > 1 && snapshot.layout !== lastLayout) layoutChangedPolls++
122
137
  const strictOk =
123
138
  !strict ||
124
139
  (snapshot.renderStatsAvailable && !snapshot.animating && !snapshot.layoutDirty)
@@ -139,7 +154,7 @@ export async function waitForSootsimIdle({
139
154
  stable >= requiredStablePolls &&
140
155
  (networkQuiet || Date.now() - visuallyStillSince >= networkQuietGraceMs)
141
156
  ) {
142
- return { settled: true, elapsed: Date.now() - start }
157
+ return { settled: true, elapsed: Date.now() - start, blockedBy: '' }
143
158
  }
144
159
  } else {
145
160
  stable = 0
@@ -149,7 +164,22 @@ export async function waitForSootsimIdle({
149
164
  lastRequestTotal = snapshot.requestTotal
150
165
  await sleep(pollMs)
151
166
  }
152
- return { settled: false, elapsed: Date.now() - start }
167
+ const last = await readSnapshot()
168
+ const blockers = []
169
+ if (strict && !last.renderStatsAvailable) blockers.push('render stats unavailable')
170
+ if (last.animating) blockers.push('animations still running')
171
+ if (last.layoutDirty) blockers.push('layout still dirty')
172
+ if (last.pendingFetches > 0) blockers.push(last.pendingFetches + ' image fetches pending')
173
+ if (last.layout !== lastLayout) blockers.push('layout still changing')
174
+ if (last.requestInFlight > 0) blockers.push(last.requestInFlight + ' requests in flight')
175
+ return {
176
+ settled: false,
177
+ elapsed: Date.now() - start,
178
+ blockedBy:
179
+ (blockers.length ? blockers.join(', ') : 'unknown') +
180
+ ' over ' + polls + ' polls (layout changed ' + layoutChangedPolls +
181
+ 'x, layoutDirty ' + dirtyPolls + 'x, animating ' + animatingPolls + 'x)',
182
+ }
153
183
  })()`,
154
184
  },
155
185
  {
@@ -157,9 +187,11 @@ export async function waitForSootsimIdle({
157
187
  },
158
188
  )
159
189
 
160
- const { elapsed, settled } = (result ?? {}) as Partial<WaitForSootsimIdleResult>
190
+ const { elapsed, settled, blockedBy } = (result ??
191
+ {}) as Partial<WaitForSootsimIdleResult>
161
192
  return {
162
193
  elapsed: typeof elapsed === 'number' ? elapsed : maxMs,
163
194
  settled: settled === true,
195
+ blockedBy: typeof blockedBy === 'string' ? blockedBy : 'unknown',
164
196
  }
165
197
  }
@@ -16,6 +16,7 @@ import { spawn } from 'child_process'
16
16
  import { closeSync, mkdtempSync, openSync, readFileSync } from 'fs'
17
17
  import { tmpdir } from 'os'
18
18
  import { join } from 'path'
19
+ import { playwrightHostLogName } from '../../src/playwright-host-log'
19
20
  import { ensureProfile, playwrightProfileUserDataDir } from '../../src/profiles'
20
21
  import { RNX_INTERNAL_CHILDREN, RNX_INTERNAL_COMMAND } from '../internal-child'
21
22
  import { getProcessComm } from '../parent-pid'
@@ -102,10 +103,7 @@ async function launch(opts: DriverLaunchOptions): Promise<DriverLaunchResult> {
102
103
  }
103
104
  }
104
105
 
105
- const errLog = join(
106
- tmpdir(),
107
- `rnx-playwright-host-${Date.now().toString(36)}-${process.pid}.log`,
108
- )
106
+ const errLog = join(tmpdir(), playwrightHostLogName(Date.now(), process.pid))
109
107
  const connectAckFile = `${errLog}.connected`
110
108
  const errFd = openSync(errLog, 'a')
111
109
  try {
package/cli/ws-bridge.ts CHANGED
@@ -28,6 +28,7 @@ import {
28
28
  getCliIdentity,
29
29
  readCurrentSim,
30
30
  readCurrentSimId,
31
+ saveCurrentSimId,
31
32
  } from './current-sim'
32
33
 
33
34
  // --- bridge world resolution ---------------------------------------------
@@ -323,6 +324,10 @@ export interface WsBridge {
323
324
  opts?: { timeoutMs?: number },
324
325
  ): Promise<any>
325
326
  listSims(): Promise<BridgeSimInfo[]>
327
+ // a guest reload retires the sim id and re-registers the same page under a
328
+ // new one. a caller that pins an id resolves the successor here; null means
329
+ // nothing proves what replaced it.
330
+ resolveReloadedSim(retiredSimId: string): Promise<string | null>
326
331
  focusSim(simId?: string): Promise<any>
327
332
  closeSim(simId?: string): Promise<any>
328
333
  claim(simId?: string, opts?: { force?: boolean }): Promise<BridgeClaimResult>
@@ -611,6 +616,24 @@ export function createBridge(wsPort: number, opts: WsBridgeOptions = {}): WsBrid
611
616
 
612
617
  let warnedAboutContention = false
613
618
  let noticedTarget = false
619
+ // the browser host behind the current pin, learned while the pinned sim is
620
+ // still connected. a reload changes the sim id but not the host.
621
+ let pinnedHostPid: number | null = null
622
+ let lineageLookupInFlight = false
623
+
624
+ // a reload retires the sim id but keeps the browser host, so a successor
625
+ // sharing that host pid IS the same page lineage rather than a guess. only
626
+ // an unambiguous match counts; callers fail closed on null.
627
+ function pickReloadedSuccessor(sims: unknown, retiredSimId: string): string | null {
628
+ if (pinnedHostPid === null) return null
629
+ const successors = (Array.isArray(sims) ? (sims as BridgeSimInfo[]) : []).filter(
630
+ (sim) =>
631
+ sim.id !== retiredSimId &&
632
+ sim.readyState === 'open' &&
633
+ sim.meta?.sootsimHostPid === pinnedHostPid,
634
+ )
635
+ return successors.length === 1 ? successors[0].id : null
636
+ }
614
637
 
615
638
  // print a one-line notice on first sim-scoped command so agents/users see
616
639
  // which sim the command actually hit. suppressed for bridge:*
@@ -682,7 +705,10 @@ export function createBridge(wsPort: number, opts: WsBridgeOptions = {}): WsBrid
682
705
  // NOTE: `opts` (the createBridge closure arg) carries simId; do not
683
706
  // shadow it with the per-call arg.
684
707
  const effectiveTimeoutMs = callOpts?.timeoutMs ?? commandTimeoutMs
685
- const sendOnce = async (defaultSimId?: string) => {
708
+ const sendOnce = async (
709
+ defaultSimId?: string,
710
+ cmdOverride?: Record<string, any>,
711
+ ) => {
686
712
  await ready
687
713
  const id = nextId++
688
714
  return new Promise((resolve, reject) => {
@@ -706,7 +732,7 @@ export function createBridge(wsPort: number, opts: WsBridgeOptions = {}): WsBrid
706
732
  },
707
733
  })
708
734
 
709
- const payload = { ...cmd, id } as Record<string, any>
735
+ const payload = { ...(cmdOverride ?? cmd), id } as Record<string, any>
710
736
  if (payload.simId === undefined && defaultSimId) {
711
737
  payload.simId = defaultSimId
712
738
  }
@@ -725,7 +751,26 @@ export function createBridge(wsPort: number, opts: WsBridgeOptions = {}): WsBrid
725
751
  const sentSimId =
726
752
  opts.simIdSource === 'flag' ? opts.simId : (readCurrentSimId() ?? opts.simId)
727
753
  try {
728
- return await sendOnce(sentSimId)
754
+ const result = await sendOnce(sentSimId)
755
+ // the guest re-registers under a NEW sim id whenever its page reloads,
756
+ // so remember which browser host is behind the pin while it still
757
+ // exists. that lineage is what lets the fail-closed path below follow
758
+ // the reload without ever guessing at an unrelated sim.
759
+ if (sentSimId && pinnedHostPid === null && !lineageLookupInFlight) {
760
+ lineageLookupInFlight = true
761
+ void sendOnce(undefined, { type: 'bridge:list-sims' })
762
+ .then((sims) => {
763
+ if (!Array.isArray(sims)) return
764
+ const pinned = (sims as BridgeSimInfo[]).find((sim) => sim.id === sentSimId)
765
+ const hostPid = pinned?.meta?.sootsimHostPid
766
+ if (typeof hostPid === 'number') pinnedHostPid = hostPid
767
+ })
768
+ .catch(() => {})
769
+ .finally(() => {
770
+ lineageLookupInFlight = false
771
+ })
772
+ }
773
+ return result
729
774
  } catch (error) {
730
775
  const message = error instanceof Error ? error.message : String(error)
731
776
  const missingSimMessage = `no sim connected with id ${sentSimId}`
@@ -735,9 +780,24 @@ export function createBridge(wsPort: number, opts: WsBridgeOptions = {}): WsBrid
735
780
  cmd.simId === undefined &&
736
781
  (message === missingSimMessage || message.startsWith(`${missingSimMessage};`))
737
782
  ) {
783
+ // a reload retires the sim id but keeps the browser host, so the
784
+ // successor with the same host pid IS the same page lineage — not a
785
+ // guess. adopt only that, and only when it is unambiguous.
786
+ const successor = pickReloadedSuccessor(
787
+ await sendOnce(undefined, { type: 'bridge:list-sims' }),
788
+ sentSimId,
789
+ )
790
+ if (successor) {
791
+ saveCurrentSimId(successor)
792
+ process.stderr.write(
793
+ ` note: sim ${sentSimId} reloaded and reconnected as ${successor}; following it\n`,
794
+ )
795
+ return sendOnce(successor)
796
+ }
738
797
  clearCurrentSimId()
739
- // the saved sim is gone. fail closed instead of driving whichever
740
- // sim is primary; that is how agents end up recording the wrong app.
798
+ // the saved sim is gone and nothing proves what replaced it. fail
799
+ // closed instead of driving whichever sim is primary; that is how
800
+ // agents end up recording the wrong app.
741
801
  throw new Error(
742
802
  `saved sim ${sentSimId} is gone${message.slice(missingSimMessage.length)}; ` +
743
803
  'run `rnx list` and ' +
@@ -751,6 +811,13 @@ export function createBridge(wsPort: number, opts: WsBridgeOptions = {}): WsBrid
751
811
  const sims = await this.send({ type: 'bridge:list-sims' })
752
812
  return Array.isArray(sims) ? (sims as BridgeSimInfo[]) : []
753
813
  },
814
+ // a caller holding its own pinned sim id (a flow runner sets simId on every
815
+ // command, so it never reaches the pin-following path in send) asks here
816
+ // after a reload retires that id. null means nothing proves what replaced
817
+ // it, and the caller must fail rather than guess.
818
+ async resolveReloadedSim(retiredSimId: string) {
819
+ return pickReloadedSuccessor(await this.listSims(), retiredSimId)
820
+ },
754
821
  async focusSim(simId?: string) {
755
822
  return this.send({ type: 'focus', simId })
756
823
  },
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.451 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.453 | (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;
@@ -1,4 +1,4 @@
1
- /*! rnx v0.1.451 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
1
+ /*! rnx v0.1.453 | (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 __defProp = Object.defineProperty;