capybara-simulated 0.4.0 → 0.5.0

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.
@@ -106,8 +106,18 @@ module Capybara
106
106
  @ctx = @iso.context
107
107
  @attached = []
108
108
  @generation = 0
109
+ # rusty's heap-accounting API (heap_statistics / low_memory_notification)
110
+ # landed in 0.1.9. Probe the real Isolate once here — the Ctx wrapper
111
+ # delegates these unconditionally, so a `respond_to?` on the wrapper
112
+ # would always be true and never gate the version. Cached so the
113
+ # per-visit pressure check (rule 3) is an ivar read, not a dispatch.
114
+ @heap_accounting = @iso.respond_to?(:heap_statistics)
109
115
  end
110
116
 
117
+ # True iff the underlying rusty Isolate exposes the 0.1.9 heap-accounting
118
+ # API. Drives `relieve_heap_pressure`'s version gate.
119
+ def heap_accounting? = @heap_accounting
120
+
111
121
  # ── Context surface ─────────────────────────────────────────
112
122
  # rusty drains microtasks at call-depth zero (V8's default kAuto
113
123
  # policy), so a returned eval/call has already run its end-of-script
@@ -152,6 +162,10 @@ module Capybara
152
162
  def terminate = @iso.terminate
153
163
  def dispose = @iso.dispose
154
164
  def perform_microtask_checkpoint = @iso.perform_microtask_checkpoint
165
+ # rusty >= 0.1.9: V8 heap accounting + a forced full GC. Used by the
166
+ # per-visit heap-pressure relief in `rebuild_ctx` (see there).
167
+ def heap_statistics = @iso.heap_statistics
168
+ def low_memory_notification = @iso.low_memory_notification
155
169
 
156
170
  def dynamic_import_resolver=(prc)
157
171
  @iso.dynamic_import_resolver = prc
@@ -398,6 +412,7 @@ module Capybara
398
412
 
399
413
  def dispose_frame_realms
400
414
  @realm_module_handles&.clear
415
+ @frame_realm_depths&.clear
401
416
  return if @frame_realms.nil?
402
417
  @frame_realms.each_value {|fr| fr.dispose rescue nil }
403
418
  @frame_realms.clear
@@ -419,7 +434,11 @@ module Capybara
419
434
  # ancestor frame re-navigates). No-op for nil/0/unknown ids.
420
435
  def dispose_frame_realm(id)
421
436
  return if id.nil? || id.zero?
437
+ # The frame's browsing context is going away — revoke the blob URLs it
438
+ # created (url-lifetime "Removing an iframe").
439
+ @browser.revoke_realm_blobs(id) rescue nil
422
440
  @realm_module_handles&.delete(id)
441
+ @frame_realm_depths&.delete(id)
423
442
  fr = frame_realms.delete(id)
424
443
  fr.dispose rescue nil if fr
425
444
  nil
@@ -499,6 +518,16 @@ module Capybara
499
518
  if @ctx
500
519
  begin
501
520
  @ctx.reset
521
+ # `@ctx.reset` swaps in a snapshot-fresh realm and `dispose_frame_realms`
522
+ # above tore down the visit's iframe realms — but V8 keeps those dead
523
+ # contexts as RECLAIMABLE GARBAGE: a per-frame realm (within_frame /
524
+ # `Isolate#create_context`) is a large GC root that V8's incremental
525
+ # GC won't collect between visits. Over a long run (esp. iframe-heavy
526
+ # pages) they pile toward the old-space cap, where the near-heap-limit
527
+ # GC thrashes instead of reclaiming. A full GC under pressure drops the
528
+ # used heap back to baseline (measured: ~450 MB -> ~150 MB, native
529
+ # contexts N -> 1). See `relieve_heap_pressure`.
530
+ relieve_heap_pressure
502
531
  attach_host_fns(@ctx)
503
532
  @ctx.eval('__csim_installWorker();')
504
533
  return @ctx
@@ -520,14 +549,7 @@ module Capybara
520
549
  # `ctx`), which rusty_racer's thread-confined isolates require. This
521
550
  # cold path is only the rare reset-failure fallback, so the inline
522
551
  # teardown isn't on the steady-state path.
523
- if old
524
- @@live_lock.synchronize { @@live.delete(old) }
525
- begin
526
- old.terminate rescue nil
527
- old.dispose
528
- rescue StandardError
529
- end
530
- end
552
+ dispose_ctx(old) if old
531
553
  @ctx = build_and_track_ctx
532
554
  end
533
555
 
@@ -536,6 +558,48 @@ module Capybara
536
558
  # path is the same operation.
537
559
  def reset_page = rebuild_ctx
538
560
 
561
+ # Memory-pressure threshold (MB) above which `rebuild_ctx` forces a full
562
+ # GC to reclaim dead per-frame realms (see the call site). Measured
563
+ # against used heap **+ external** (ArrayBuffer backing stores, image
564
+ # pixel buffers) — `used_heap_size` alone misses the external component,
565
+ # which on image-heavy specs is the bulk of the footprint. Default 1 GB:
566
+ # far above a normal single visit (~200-500 MB) so ordinary specs never
567
+ # trigger it, far below the 4 GB old-space cap so a multi-visit spec
568
+ # reclaims long before the near-heap-limit GC would thrash. `0` disables.
569
+ GC_PRESSURE_MB = (ENV['CSIM_V8_GC_PRESSURE_MB'] || '1024').to_i
570
+
571
+ # Opt-in heap accounting on every rebuild (CSIM_HEAP_DIAG). Resolved once
572
+ # at load (rule 3: don't re-read env per call); zero cost when off.
573
+ HEAP_DIAG = !ENV['CSIM_HEAP_DIAG'].nil?
574
+
575
+ # Forced full GC when V8-managed memory (used heap + external) crosses
576
+ # GC_PRESSURE_MB. The stat read is cheap (a counter snapshot every visit);
577
+ # the GC itself only fires once a multi-visit spec has actually piled up
578
+ # dead realms — measured at ~once per 25-50 iframe-heavy visits, reclaiming
579
+ # native contexts back to 1 and the heap to baseline — so the amortized
580
+ # cost is negligible while memory stays bounded. No-op on rusty < 0.1.9
581
+ # (no heap_statistics) or when disabled.
582
+ def relieve_heap_pressure
583
+ return unless GC_PRESSURE_MB.positive? && @ctx.heap_accounting?
584
+ s = @ctx.heap_statistics
585
+ over = (s[:used_heap_size].to_i + s[:external_memory].to_i) > GC_PRESSURE_MB * 1_048_576
586
+ if HEAP_DIAG
587
+ warn(format('[csim heap] used=%dMB ext=%dMB native_ctx=%d over=%s',
588
+ s[:used_heap_size].to_i >> 20, s[:external_memory].to_i >> 20,
589
+ s[:number_of_native_contexts].to_i, over))
590
+ end
591
+ return unless over
592
+ @ctx.low_memory_notification
593
+ if HEAP_DIAG
594
+ a = @ctx.heap_statistics
595
+ warn(format('[csim heap] -> after GC used=%dMB native_ctx=%d',
596
+ a[:used_heap_size].to_i >> 20, a[:number_of_native_contexts].to_i))
597
+ end
598
+ rescue StandardError
599
+ # Older rusty without the diagnostics API, or a transient read failure —
600
+ # heap relief is best-effort, never fail a visit over it.
601
+ end
602
+
539
603
  # Built lazily on first use, on the calling (main) thread. There is no
540
604
  # pool / background pre-warm: under warm-compile the steady-state visit
541
605
  # reuses this one isolate via `Context#reset` (rebuild_ctx) and never
@@ -545,6 +609,7 @@ module Capybara
545
609
  # every isolate confined to one thread for its whole life. The one-time
546
610
  # synchronous build is ~3 ms.
547
611
  def ctx
612
+ return @ctx if @disposed # don't resurrect a disposed runtime (closed window)
548
613
  @ctx ||= build_and_track_ctx
549
614
  end
550
615
 
@@ -555,11 +620,46 @@ module Capybara
555
620
  c
556
621
  end
557
622
 
623
+ # Terminate + dispose a tracked isolate and drop it from the at-exit
624
+ # `@@live` registry. Dispose FIRST and de-register only on success: if
625
+ # `dispose` raises (rescued), the isolate stays in `@@live` so the at_exit
626
+ # sweep retries it instead of leaking it un-disposed. Shared by the
627
+ # cold-rebuild fallback (`rebuild_ctx`) and `#dispose`.
628
+ def dispose_ctx(c)
629
+ return unless c
630
+ c.terminate rescue nil
631
+ c.dispose
632
+ @@live_lock.synchronize { @@live.delete(c) }
633
+ rescue StandardError
634
+ end
635
+
636
+ # Tear this runtime's isolate down for good. Each auxiliary window
637
+ # (`window.open` / a switched-into `target=_blank`) is its own Browser +
638
+ # V8Runtime + isolate; without this, closing the window reaped its
639
+ # background threads (Browser#dispose) but left the isolate ALIVE — the
640
+ # `@@live` at-exit registry holds a strong reference, so a bare GC never
641
+ # reclaimed it. Over a long suite those isolates (and their RSS)
642
+ # accumulated (measured: V8 isolate count 2 → 10, RSS ~2.7 → 6.6 GB across
643
+ # the Discourse suite). Idempotent; only ever called on teardown
644
+ # (Browser#dispose) — never on the per-test `reset_page` path, which
645
+ # reuses the isolate via `Context#reset`. The `ctx` getter stops rebuilding
646
+ # once `@disposed`, so a stray post-close call can't resurrect the isolate.
647
+ def dispose
648
+ return if @disposed
649
+ @disposed = true
650
+ dispose_frame_realms rescue nil
651
+ c, @ctx = @ctx, nil
652
+ dispose_ctx(c)
653
+ end
654
+
558
655
  # Per-call wall-clock cap (ms). Off by default. Opt in via
559
656
  # `CSIM_V8_CALL_TIMEOUT_MS=30000` for long-running suites where an
560
657
  # occasional JS-side infinite loop would otherwise stall the whole
561
658
  # run; the timeout converts the hang into a
562
- # `RustyRacer::ScriptTerminatedError` on that one example. The
659
+ # `RustyRacer::ScriptTerminatedError` on that one example — whose
660
+ # `#message` / `#js_backtrace` (rusty >= 0.1.10) name the looping JS
661
+ # frame (function + source position), so an in-V8 hang is diagnosable
662
+ # from the failure alone, no live debugger attach needed. The
563
663
  # terminate escalates through any nested frames (it is
564
664
  # isolate-global by design), and the isolate itself stays healthy
565
665
  # for subsequent calls — csim treats a terminated call as fatal to
@@ -599,17 +699,15 @@ module Capybara
599
699
  # id (or nil on failure — then the bridge keeps its same-realm fallback).
600
700
  # The bridge maps `iframe.contentWindow` to `RustyRacer.contextGlobal(id)`.
601
701
  def attach_frame_realm_loader(c)
602
- c.attach('__csim_createFrameRealm', ->(url, body, content_type, parent_id = 0) {
603
- RuntimeShared.safe_call { create_frame_realm(c, url, body, content_type, parent_id) }
702
+ c.attach('__csim_createFrameRealm', ->(url, body, content_type, parent_id = 0, frame_name = nil, frame_doc_origin = nil, frame_location_origin = nil, js_url_source = nil) {
703
+ RuntimeShared.safe_call { create_frame_realm(c, url, body, content_type, parent_id, frame_name, frame_doc_origin, frame_location_origin, js_url_source) }
604
704
  })
605
705
  # Re-navigating an iframe (src/srcdoc reassigned) builds a fresh realm;
606
706
  # the bridge calls this to tear down the superseded one so it doesn't
607
707
  # linger in @frame_realms and get re-drained on every poll tick.
608
708
  # Disposing a non-executing child realm mid-callback is safe.
609
709
  c.attach('__csim_disposeFrameRealm', ->(id) {
610
- @realm_module_handles&.delete(id)
611
- fr = frame_realms.delete(id)
612
- fr.dispose rescue nil if fr
710
+ dispose_frame_realm(id) # also revokes the realm's blob URLs
613
711
  nil
614
712
  })
615
713
  end
@@ -620,8 +718,27 @@ module Capybara
620
718
  # state, point it at its own URL with the top frame as parent/top, then
621
719
  # load its document (running its scripts in the realm). Tracked for
622
720
  # event-loop draining + teardown.
623
- def create_frame_realm(parent_ctx, url, body, content_type, parent_id = 0)
721
+ # Browsers cap nested browsing-context depth; with eager frame building a
722
+ # self-referential or pathologically nested iframe (`<iframe src=self>`)
723
+ # would otherwise build realms without bound and stall the settle loop.
724
+ # Depth is one more than the parent realm's (the main frame is depth 0).
725
+ MAX_FRAME_DEPTH = 16
726
+
727
+ def frame_realm_depths = (@frame_realm_depths ||= {})
728
+
729
+ def create_frame_realm(parent_ctx, url, body, content_type, parent_id = 0, frame_name = nil, frame_doc_origin = nil, frame_location_origin = nil, js_url_source = nil)
730
+ depth = (frame_realm_depths[parent_id] || 0) + 1
731
+ if depth > MAX_FRAME_DEPTH
732
+ @browser.log_console('warn', "iframe nesting depth #{depth} exceeds #{MAX_FRAME_DEPTH}; not building #{url}")
733
+ return nil
734
+ end
624
735
  realm = parent_ctx.create_context
736
+ # Record depth BEFORE loading the document: the frame's own scripts run
737
+ # during __csimLoadDocument below and may synchronously build NESTED frames
738
+ # (their create_frame_realm looks up this realm's depth as their parent's),
739
+ # so it must already be set or the nested depth undercounts and the cap
740
+ # never trips.
741
+ frame_realm_depths[realm.id] = depth
625
742
  # Re-evaling the snapshot source would redefine snapshot globals (e.g.
626
743
  # the `scrollX` accessor) and throw — re-entrantly. Only eval the
627
744
  # source on a bare no-snapshot dev ctx, where the realm boots empty.
@@ -645,8 +762,22 @@ module Capybara
645
762
  if (globalThis.#{HOST_NAMESPACE_NAME} && typeof globalThis.#{HOST_NAMESPACE_NAME}.contextGlobal === 'function') {
646
763
  var __parentWin = globalThis.#{HOST_NAMESPACE_NAME}.contextGlobal(#{parent_id.to_i});
647
764
  if (__parentWin) {
648
- globalThis.parent = __parentWin;
649
- globalThis.top = __parentWin.top || __parentWin;
765
+ // Expose `parent`/`top` as THIS realm's WindowProxy for them (not the
766
+ // raw parent global) so `e.source === parent` holds and a frame's
767
+ // `parent.postMessage(...)` attributes the sender. `top` resolves
768
+ // through the parent's own top (already a proxy if the parent is a
769
+ // frame), unwrapped to its raw global then re-proxied for this realm.
770
+ var __NS = globalThis.#{HOST_NAMESPACE_NAME};
771
+ var __pf = globalThis.__csimFrameWindowProxyFor;
772
+ if (__pf && __NS && typeof __NS.contextOf === 'function') {
773
+ var __topRaw = __parentWin.top || __parentWin;
774
+ if (__topRaw && __topRaw.__csimRawWindow) __topRaw = __topRaw.__csimRawWindow;
775
+ globalThis.parent = __pf(__NS.contextOf(__parentWin)) || __parentWin;
776
+ globalThis.top = __pf(__NS.contextOf(__topRaw)) || __topRaw;
777
+ } else {
778
+ globalThis.parent = __parentWin;
779
+ globalThis.top = __parentWin.top || __parentWin;
780
+ }
650
781
  }
651
782
  }
652
783
  JS
@@ -655,8 +786,42 @@ module Capybara
655
786
  # HTML / control bytes survive (Ruby's String#inspect is NOT a faithful
656
787
  # JS string escaper — it mangles \a, \e, and binary bytes).
657
788
  realm.call('__csimUpdateLocation', url.to_s) unless url.to_s.empty?
789
+ # Set window.name from the container's `name` attribute BEFORE the document
790
+ # loads, so a frame whose load handler reads window.name to identify itself
791
+ # (declarative-shadow declarative-child-frame) sees it.
792
+ realm.call('__csimSetWindowName', frame_name.to_s) unless frame_name.nil?
793
+ # Seed the frame's document origin (opaque/inherited) BEFORE the document
794
+ # loads, so its load-time scripts read the right self.origin. nil → a
795
+ # real-URL frame whose origin is its own location origin.
796
+ realm.call('__csimSetDocumentOrigin', frame_doc_origin.to_s) unless frame_doc_origin.nil?
797
+ # The frame's location.origin (opaque "null" for about:blank / srcdoc /
798
+ # javascript:); decoupled from the location string so navigation is intact.
799
+ realm.call('__csimSetLocationOrigin', frame_location_origin.to_s) unless frame_location_origin.nil?
658
800
  realm.call('__csimLoadDocument', body.to_s, content_type.to_s)
801
+ # A `javascript:` URL frame: the initial empty document is now loaded and
802
+ # parent/top are wired, so evaluate the URL's script in the realm (global
803
+ # scope). Per HTML, only a STRING result navigates the frame to a new
804
+ # document built from it; any other result (incl. the common undefined)
805
+ # leaves the about:blank document, so only its side effects (e.g.
806
+ # `parent.foo()`) take effect. A throwing script is reported and left as a
807
+ # no-op rather than aborting the frame build.
808
+ unless js_url_source.nil?
809
+ begin
810
+ result = realm.eval(js_url_source.to_s)
811
+ realm.call('__csimLoadDocument', result, 'text/html') if result.is_a?(String)
812
+ rescue StandardError => e
813
+ @browser.log_console('warn', "javascript: URL frame threw: #{e.message}")
814
+ end
815
+ end
659
816
  frame_realms[realm.id] = realm
817
+ # Fire the nested document's window `load`. The frame's inline scripts ran
818
+ # during __csimLoadDocument and registered their `window.onload` (the usual
819
+ # `window.onload = () => parent.postMessage(...)` a frame reports back
820
+ # through); without firing it, an eagerly-built frame that the parent never
821
+ # touches would never run its load handler. Safe if no handler is set
822
+ # (dispatches to an empty listener list). Guarded so a frame whose load
823
+ # handler throws doesn't abort the build.
824
+ realm.call('__csimFireWindowLoad') rescue nil
660
825
  realm.id
661
826
  rescue StandardError => e
662
827
  @browser.log_console('warn', "frame realm load failed: #{e.message}")
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Capybara
4
4
  module Simulated
5
- VERSION = '0.4.0'
5
+ VERSION = '0.5.0'
6
6
  end
7
7
  end