@3dsource/angular-unreal-module 0.0.123-dev.0 → 0.0.124

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.
@@ -1,12 +1,12 @@
1
1
  import * as i0 from '@angular/core';
2
2
  import { InjectionToken, inject, ChangeDetectionStrategy, Component, Injectable, DestroyRef, effect, untracked, computed, signal, Pipe, input, ElementRef, viewChild, afterNextRender, HostListener, output, makeEnvironmentProviders, provideEnvironmentInitializer } from '@angular/core';
3
3
  import { SourceButtonComponent, SourceLoadingComponent } from '@3dsource/source-ui-native';
4
- import { merge, map, scan, timestamp, startWith, pairwise, concatMap, of, animationFrameScheduler, interval, Observable, skip, switchMap, concat, delay, debounceTime, timer, take, timeout, mergeMap, EMPTY, Subject, share, takeUntil as takeUntil$1, shareReplay, from, fromEvent, throwError, defer, retry, tap as tap$1, throttleTime, NEVER, asyncScheduler, filter as filter$1, withLatestFrom as withLatestFrom$1, Subscription, bufferTime, combineLatestWith, distinctUntilChanged as distinctUntilChanged$1 } from 'rxjs';
4
+ import { merge, map, scan, timestamp, startWith, pairwise, concatMap, of, animationFrameScheduler, interval, Observable, skip, switchMap, concat, delay, debounceTime, timer, take, timeout, mergeMap, EMPTY, Subject, share, takeUntil as takeUntil$1, shareReplay, from, fromEvent, throwError, defer, retry, repeat, tap as tap$1, throttleTime, NEVER, asyncScheduler, filter as filter$1, withLatestFrom as withLatestFrom$1, Subscription, bufferTime, combineLatestWith, distinctUntilChanged as distinctUntilChanged$1 } from 'rxjs';
5
5
  import { createAction, props, createReducer, on, createFeature, createSelector, Store, provideState } from '@ngrx/store';
6
6
  import { concatLatestFrom } from '@ngrx/operators';
7
7
  import { Actions, createEffect, ofType, provideEffects } from '@ngrx/effects';
8
8
  import { whereNot, Logger, calculateMedian, clampf, lerp, getCanvasCached, getSnapshot, fitIntoRectangle, Truthy, tapLog, Falsy, generateUuid, COLOR_CODES, where, KeyboardNumericCode, InvertedKeyMap, Semaphore, HEXtoRGB, RGBtoHSV, inverseLerp, HSVtoRGB, RGBtoHEX, fpIsASameAsB } from '@3dsource/utils';
9
- import { map as map$1, distinctUntilChanged, takeWhile, filter, tap, takeUntil, catchError, withLatestFrom, exhaustMap, switchMap as switchMap$1, timeout as timeout$1, share as share$1, debounceTime as debounceTime$1, take as take$1, startWith as startWith$1 } from 'rxjs/operators';
9
+ import { map as map$1, distinctUntilChanged, takeWhile, filter, tap, takeUntil, catchError, withLatestFrom, exhaustMap, switchMap as switchMap$1, timeout as timeout$1, finalize, first, debounceTime as debounceTime$1, take as take$1, startWith as startWith$1 } from 'rxjs/operators';
10
10
  import { DialogRef, DIALOG_DATA, Dialog } from '@angular/cdk/dialog';
11
11
  import { HttpClient } from '@angular/common/http';
12
12
  import { ScrollStrategyOptions } from '@angular/cdk/overlay';
@@ -207,8 +207,31 @@ const CONSOLE_COMMAND_DISABLE_MESSAGES = 'DisableAllScreenMessages';
207
207
  const CONSOLE_COMMAND_PIXEL_QUALITY = 'PixelStreaming.FreezeFrameQuality 95';
208
208
  const FULL_HD_WIDTH = 1920;
209
209
  const FULL_HD_HEIGHT = 1080;
210
- const WS_TIMEOUT = 2000;
210
+ // Bound on a single WS upgrade attempt (`createWebSocket`). On cold
211
+ // connections the TCP+TLS+WS handshake can legitimately take 3-5 s
212
+ // (cold DNS, TLS session miss). 2 s was too aggressive — first
213
+ // attempts kept timing out and the second attempt (with warm caches)
214
+ // would connect in <500 ms. 10 s gives slow networks room without
215
+ // letting a truly hung handshake hold up `connectToCirrus` forever.
216
+ const WS_TIMEOUT = 6000;
211
217
  const POLLING_TIME = 4000;
218
+ // Staged retry policy for `SignallingService.connectToSignaling`:
219
+ // INNER_RETRY_LIMIT = max number of WS+orchestration attempts before
220
+ // the pipeline gives up on the current region and bubbles the error
221
+ // to the outer retry. URL rotation (`urlGen`) advances across these.
222
+ // OUTER_RETRY_DELAY = backoff between region re-picks. Intentionally
223
+ // longer than POLLING_TIME so region discovery is rare relative to
224
+ // WS retries.
225
+ const INNER_RETRY_LIMIT = 3;
226
+ const OUTER_RETRY_DELAY = POLLING_TIME * 2;
227
+ // Extra WS-only retries on the SAME wsUrl (cached AwsInstance) BEFORE
228
+ // falling through to a URL-rotating retry that re-polls orchestration.
229
+ // Why: a fresh `default` poll asks orchestrator to RESERVE a new
230
+ // instance — the previous reservation is orphaned (server-side leak).
231
+ // One quick same-wsUrl retry catches transient WS failures (network
232
+ // blip, brief unavailability) without burning a second reservation.
233
+ // Total WS attempts before URL rotation = 1 + WS_RETRY_PER_INSTANCE.
234
+ const WS_RETRY_PER_INSTANCE = 1;
212
235
  const WS_OPEN_STATE = 1;
213
236
  const DEFAULT_AFK_TIMEOUT_PERIOD = 15;
214
237
  const DEFAULT_AFK_TIMEOUT = 600; // 10 minutes
@@ -1672,9 +1695,9 @@ class AFKService extends SubService {
1672
1695
  resetAfkWarningTimer() {
1673
1696
  if (this.active) {
1674
1697
  this.clearTimers();
1675
- this.warnTimer = setTimeout(() => {
1676
- this.showAfkOverlay();
1677
- }, (this.warnTimeout() || DEFAULT_AFK_TIMEOUT) * 1000);
1698
+ // `selectWarnTimeout` already falls back to DEFAULT_AFK_TIMEOUT when
1699
+ // config is missing, so warnTimeout() always returns a positive number.
1700
+ this.warnTimer = setTimeout(() => this.showAfkOverlay(), this.warnTimeout() * 1000);
1678
1701
  }
1679
1702
  }
1680
1703
  /**
@@ -1694,7 +1717,8 @@ class AFKService extends SubService {
1694
1717
  }
1695
1718
  reset() {
1696
1719
  this.hideOverlay();
1697
- clearInterval(this.countdownTimer);
1720
+ // startAfkWarningTimer → resetAfkWarningTimer → clearTimers() already
1721
+ // clears the countdown interval, no separate clearInterval needed.
1698
1722
  this.startAfkWarningTimer();
1699
1723
  }
1700
1724
  showAfkOverlay() {
@@ -1917,10 +1941,7 @@ class VideoService extends SubService {
1917
1941
  interval(250).pipe(map(() => track.pcClient), switchMap$1((pcClient) => from(this.getStats(pcClient))), filter(Truthy))
1918
1942
  : of(null)), filter(Truthy), share());
1919
1943
  this.destroyAbort = new AbortController();
1920
- this.destroyRef.onDestroy(() => {
1921
- this.destroyAbort.abort();
1922
- this.destroy();
1923
- });
1944
+ this.destroyRef.onDestroy(() => this.destroy());
1924
1945
  }
1925
1946
  setContainer(container = null) {
1926
1947
  this.container = container;
@@ -1932,6 +1953,9 @@ class VideoService extends SubService {
1932
1953
  this.VideoEncoderQP = Number(new TextDecoder('utf-16').decode(data));
1933
1954
  }
1934
1955
  create() {
1956
+ if (!this.container) {
1957
+ Logger.warn('VideoService.create(): no container set; <video> will be built but never attached to the DOM, which suppresses autoplay.');
1958
+ }
1935
1959
  this.disconnect$
1936
1960
  .pipe(take(1), takeUntil$1(this.destroy$))
1937
1961
  .subscribe(() => this.destroy());
@@ -1940,26 +1964,35 @@ class VideoService extends SubService {
1940
1964
  this.createWebRtcAudio();
1941
1965
  }
1942
1966
  attachVideoStream(stream, pcClient) {
1943
- if (this.video) {
1944
- this.video.srcObject = stream;
1945
- this.videoTrack$.next({ stream, pcClient });
1946
- }
1947
- else {
1967
+ if (!this.video) {
1948
1968
  Logger.error('Video element is not defined.');
1969
+ return;
1970
+ }
1971
+ // Safari refuses muted-autoplay for MediaStreams that carry unmuted
1972
+ // audio tracks, even when the <video> is muted. Route video tracks to
1973
+ // the video element and audio tracks (if any) to the audio element.
1974
+ this.video.srcObject = new MediaStream(stream.getVideoTracks());
1975
+ const audioTracks = stream.getAudioTracks();
1976
+ if (audioTracks.length && this.audio) {
1977
+ this.audio.srcObject = new MediaStream(audioTracks);
1978
+ this.playAudio(this.audio);
1949
1979
  }
1980
+ this.videoTrack$.next({ stream, pcClient });
1981
+ // Safari does not reliably honor the autoplay attribute when srcObject
1982
+ // is bound post-creation for a WebRTC MediaStream; trigger play()
1983
+ // explicitly and let safePlay() fall back if Safari refuses.
1984
+ void this.safePlay(this.video);
1950
1985
  }
1951
1986
  attachAudioStream(stream) {
1952
- // do nothing the video has the same media stream as the audio track we have here (they are linked)
1953
- if (this.video?.srcObject === stream) {
1987
+ if (!this.audio) {
1954
1988
  return;
1955
1989
  }
1956
- // the video element has some other media stream not associated with this audio track
1957
- else if (this.audio &&
1958
- this.video?.srcObject &&
1959
- this.video?.srcObject !== stream) {
1960
- this.audio.srcObject = stream;
1961
- this.playAudio(this.audio);
1990
+ const audioTracks = stream.getAudioTracks();
1991
+ if (!audioTracks.length) {
1992
+ return;
1962
1993
  }
1994
+ this.audio.srcObject = new MediaStream(audioTracks);
1995
+ this.playAudio(this.audio);
1963
1996
  }
1964
1997
  play() {
1965
1998
  void this.safePlay(this.video);
@@ -1977,7 +2010,8 @@ class VideoService extends SubService {
1977
2010
  typeof error === 'object' &&
1978
2011
  'name' in error &&
1979
2012
  error?.name !== 'NotAllowedError') {
1980
- throw error;
2013
+ Logger.warn('safePlay() rejected with non-autoplay error:', error);
2014
+ return;
1981
2015
  }
1982
2016
  // Try silent inline autoplay
1983
2017
  video.muted = true;
@@ -2022,6 +2056,11 @@ class VideoService extends SubService {
2022
2056
  }
2023
2057
  try {
2024
2058
  const stats = await pcClient.getStats(null);
2059
+ // The service may have been destroyed while pcClient.getStats() was
2060
+ // pending; bail before writing back to this.aggregatedStats.
2061
+ if (!this.video) {
2062
+ return null;
2063
+ }
2025
2064
  return this.generateAggregatedStatsFunction(stats);
2026
2065
  }
2027
2066
  catch (err) {
@@ -2101,12 +2140,14 @@ class VideoService extends SubService {
2101
2140
  if (report.type === 'transport' &&
2102
2141
  Object.hasOwn(report, 'selectedCandidatePairId')) {
2103
2142
  const selectedPair = stats.get(report.selectedCandidatePairId);
2104
- const local = stats.get(selectedPair.localCandidateId);
2105
- const remote = stats.get(selectedPair.remoteCandidateId);
2106
- newStat.selectedPair = {
2107
- local: { type: local?.candidateType, url: local?.url },
2108
- remote: { type: remote?.candidateType, url: remote?.url },
2109
- };
2143
+ if (selectedPair) {
2144
+ const local = stats.get(selectedPair.localCandidateId);
2145
+ const remote = stats.get(selectedPair.remoteCandidateId);
2146
+ newStat.selectedPair = {
2147
+ local: { type: local?.candidateType, url: local?.url },
2148
+ remote: { type: remote?.candidateType, url: remote?.url },
2149
+ };
2150
+ }
2110
2151
  }
2111
2152
  // Store mimetype of each codec
2112
2153
  if (Object.hasOwn(newStat, 'codecs') &&
@@ -2146,6 +2187,19 @@ class VideoService extends SubService {
2146
2187
  return;
2147
2188
  }
2148
2189
  this.changeStatusMainVideoOnScene(false);
2190
+ // Cancel any gesture listeners armed by safePlay/playAudio in this
2191
+ // session, then arm a fresh controller for the next one. Without this,
2192
+ // listeners survive mid-session disconnect$ → destroy() cycles.
2193
+ this.destroyAbort.abort();
2194
+ this.destroyAbort = new AbortController();
2195
+ // Pause and detach streams before nulling — Safari can otherwise hold
2196
+ // on to the RTCRtpReceiver until GC eventually clears srcObject.
2197
+ this.video.pause();
2198
+ this.video.srcObject = null;
2199
+ if (this.audio) {
2200
+ this.audio.pause();
2201
+ this.audio.srcObject = null;
2202
+ }
2149
2203
  this.video = null;
2150
2204
  this.audio = null;
2151
2205
  this.videoTrack$.next(null);
@@ -2170,7 +2224,14 @@ class VideoService extends SubService {
2170
2224
  video.id = STREAMING_VIDEO_ID;
2171
2225
  video.playsInline = true;
2172
2226
  video.muted = true;
2173
- video.autoplay = true;
2227
+ // Safari occasionally resets `muted` when srcObject is rebound; the
2228
+ // `defaultMuted` reflection makes the muted state sticky and keeps the
2229
+ // muted-autoplay policy intact.
2230
+ video.defaultMuted = true;
2231
+ // No `autoplay` attribute on purpose: attachVideoStream() always calls
2232
+ // safePlay() explicitly, and the attribute would create a second
2233
+ // browser-internal play() Promise that we can't catch — which surfaces
2234
+ // as an unhandled rejection when destroy() pauses the element.
2174
2235
  video.preload = 'auto';
2175
2236
  video.disablePictureInPicture = true;
2176
2237
  this.video = video;
@@ -2187,13 +2248,36 @@ class VideoService extends SubService {
2187
2248
  audio.id = STREAMING_AUDIO_ID;
2188
2249
  this.audio = audio;
2189
2250
  }
2190
- playAudio(audio) {
2191
- from(audio.play())
2192
- .pipe(catchError(() => fromEvent(document, 'click')), take(1))
2193
- .subscribe({
2194
- next: () => audio.play(),
2195
- error: () => ({}),
2196
- });
2251
+ async playAudio(audio) {
2252
+ try {
2253
+ await audio.play();
2254
+ return;
2255
+ }
2256
+ catch {
2257
+ // Wait for the next real user gesture and retry.
2258
+ const resume = async () => {
2259
+ try {
2260
+ await audio.play();
2261
+ }
2262
+ catch {
2263
+ /* ignore — caller may need another gesture */
2264
+ }
2265
+ finally {
2266
+ window.removeEventListener('pointerdown', resume, true);
2267
+ window.removeEventListener('keydown', resume, true);
2268
+ }
2269
+ };
2270
+ window.addEventListener('pointerdown', resume, {
2271
+ once: true,
2272
+ capture: true,
2273
+ signal: this.destroyAbort.signal,
2274
+ });
2275
+ window.addEventListener('keydown', resume, {
2276
+ once: true,
2277
+ capture: true,
2278
+ signal: this.destroyAbort.signal,
2279
+ });
2280
+ }
2197
2281
  }
2198
2282
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: VideoService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
2199
2283
  static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: VideoService }); }
@@ -2202,11 +2286,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
2202
2286
  type: Injectable
2203
2287
  }], ctorParameters: () => [] });
2204
2288
 
2205
- class CommandTelemetryService {
2289
+ class CommandTelemetryService extends SubService {
2206
2290
  constructor() {
2207
- this.actions$ = inject(Actions);
2208
- this.destroyRef = inject(DestroyRef);
2209
- this.destroy$ = new Subject();
2291
+ super();
2210
2292
  this.appId = 'metabox-telemetry';
2211
2293
  this.commandsSent = {};
2212
2294
  this.exileTimeout = 60000;
@@ -2214,13 +2296,7 @@ class CommandTelemetryService {
2214
2296
  this.commandSequenceNumber = 0;
2215
2297
  this.payloads = [];
2216
2298
  this.httpClient = inject(HttpClient);
2217
- this.unrealConfig = inject(UNREAL_CONFIG, {
2218
- optional: true,
2219
- });
2220
- this.destroyRef.onDestroy(() => {
2221
- this.destroy$.next();
2222
- this.destroy$.complete();
2223
- });
2299
+ this.unrealConfig = inject(UNREAL_CONFIG, { optional: true });
2224
2300
  this.init();
2225
2301
  }
2226
2302
  init() {
@@ -2654,32 +2730,35 @@ class SignallingService extends SubService {
2654
2730
  this.onConfig$ = new Subject();
2655
2731
  this.onWebRtcIce$ = new Subject();
2656
2732
  this.onWebRtcAnswer$ = new Subject();
2657
- // Fan-in produced by abortEstablishingConnection$ in unreal.effects.ts
2658
- // whenever cirrus drops, dropConnection fires, or the scene is destroyed.
2659
- // `share()` deduplicates the tap side effect across all takeUntil(abort$)
2660
- // subscribers — close() runs once per action, not once per subscriber.
2661
- this.abort$ = this.actions$.pipe(ofType(abortEstablishingConnection), tap(() => this.close({
2662
- code: 1000,
2663
- reason: DisconnectReason.AbortEstablishingConnection,
2664
- })), share$1());
2733
+ // Pure signal — no side-effects. The `close()` is wired ONCE in init()
2734
+ // below so a single abort event triggers exactly one close, regardless
2735
+ // of how many pipelines have `takeUntil(this.abort$)` attached.
2736
+ this.abort$ = this.actions$.pipe(ofType(abortEstablishingConnection));
2665
2737
  this.ws = null;
2666
2738
  this.wsMsgHandlers = {};
2667
2739
  this.init();
2668
- this.destroyRef.onDestroy(() => this.close({
2669
- code: 1000,
2670
- reason: DisconnectReason.AbortEstablishingConnection,
2671
- }));
2672
2740
  }
2673
2741
  init() {
2674
2742
  if (this.unrealConfig?.playwright) {
2675
2743
  return;
2676
2744
  }
2677
2745
  this.setHandlersFromStream();
2678
- // Subscriptions persist across sessions (use destroy$ only, not abort$).
2679
- // dataChannelReady is a per-session event; abort$ fires on every
2680
- // destroyUnrealScene, so binding to it kills the subs after the first
2681
- // session within a persistent injector. send() is already a no-op when
2682
- // the WS is closed, so there's no risk of leaking messages during teardown.
2746
+ // Single owner of the "close on abort" side-effect. Previously this
2747
+ // lived as a `tap` inside `abort$` itself, which made every
2748
+ // `takeUntil(this.abort$)` subscriber fire its own close().
2749
+ this.abort$.pipe(takeUntil(this.destroy$)).subscribe(() => {
2750
+ this.close({
2751
+ code: 1000,
2752
+ reason: DisconnectReason.AbortEstablishingConnection,
2753
+ });
2754
+ });
2755
+ // `dataChannelReady` is a per-session event. Bind ONLY to destroy$, not
2756
+ // to abort$ — `abort$` fires on every destroyUnrealScene (route swap),
2757
+ // and `init()` runs once in the constructor. If these subs were torn
2758
+ // down by abort$, a second in-injector session (e.g. navigating between
2759
+ // two routes that both host <app-unreal-scene>) would never send
2760
+ // `p2pEstablished` / `streamPreparedForUserInteraction`. `send()` is
2761
+ // already a no-op on a closed WS, so there's no risk of stray messages.
2683
2762
  this.actions$
2684
2763
  .pipe(ofType(dataChannelReady), takeUntil(this.destroy$))
2685
2764
  .subscribe(() => {
@@ -2709,36 +2788,93 @@ class SignallingService extends SubService {
2709
2788
  return EMPTY;
2710
2789
  }
2711
2790
  Logger.log('CONNECT TO SIGNALLING');
2791
+ // Side-effects that belong to one logical `connectToSignaling`
2792
+ // invocation (NOT one retry attempt). Wrapping them in a `defer`
2793
+ // makes them fire on subscribe (lazy), but only ONCE — the retry
2794
+ // below sits INSIDE this defer's projection so re-subscribing the
2795
+ // inner pipeline does not re-trigger these.
2712
2796
  return defer(() => {
2713
- // Preparation for WebSocket Orchestration. Inside defer so these
2714
- // side effects only fire once the caller actually subscribes.
2715
2797
  this.correlationId = generateUuid();
2716
2798
  this.commandTelemetryService.reset();
2717
2799
  this.startEstablishingConnection();
2718
- return this.getRegion().pipe(tap((region) => this.region.set(region ?? '')), map$1((region) => this.adaptUrlsToRegion(urlList, region)), switchMap((urls) => this.getAwsInstance(urls).pipe(take(1))), tap((awsInstance) => this.store.dispatch(setAwsInstance(awsInstance))), switchMap(({ wsUrl }) => this.connectToCirrus(wsUrl ?? '').pipe(filter(Truthy), take(1))), takeUntil(merge(this.abort$, this.destroy$)));
2719
- }).pipe(
2720
- // Retry the whole attempt on error, advancing to the next URL each time.
2721
- // Note: this currently re-runs `getRegion()` on every retry — see
2722
- // `SIGNALLING.md` for the leak description and proposed fix. The
2723
- // dispatch below is observability-only so the viewer can show the
2724
- // retry storm in the Anomalies table without the fix being applied.
2800
+ // Staged retry:
2801
+ // * `getRegion()` is pulled in front of the retry — region
2802
+ // discovery happens once per outer cycle.
2803
+ // * `connectAttempt(urls)` owns inner retry + URL rotation +
2804
+ // WS connect. WS/orch failures stay inside it (capped at
2805
+ // `INNER_RETRY_LIMIT`).
2806
+ // * If the inner retry exhausts, the error propagates to the
2807
+ // OUTER retry below which re-runs `getRegion()` (rare, real
2808
+ // "region down" case). Outer delay > inner so escalation is
2809
+ // bounded.
2810
+ const connectCycle$ = this.getRegion().pipe(tap((region) => this.region.set(region ?? '')), map$1((region) => this.adaptUrlsToRegion(urlList, region)), switchMap((urls) => this.connectAttempt(urls)), takeUntil(this.abort$));
2811
+ return connectCycle$.pipe(retry({
2812
+ delay: (err, attempt) => {
2813
+ this.store.dispatch(signalingRetryAttempted({
2814
+ attempt,
2815
+ reason: err?.message ?? 'unknown',
2816
+ kind: 'region-cycle',
2817
+ }));
2818
+ Logger.info(`Outer retry (region re-pick) in ${OUTER_RETRY_DELAY}ms. Attempt# ${attempt}.`, err?.message);
2819
+ return timer(OUTER_RETRY_DELAY);
2820
+ },
2821
+ }));
2822
+ }).pipe(takeUntil(this.abort$));
2823
+ }
2824
+ /**
2825
+ * One inner cycle: rotate `urlGen` over the pool, resolve via
2826
+ * {@link getAwsInstance}, open WS via {@link connectToCirrus},
2827
+ * retry up to {@link INNER_RETRY_LIMIT} on any hard failure. The
2828
+ * urlGen lives in this closure so retries advance through the
2829
+ * pool. Exhaustion bubbles to {@link connectToSignaling}'s outer
2830
+ * retry which re-pings the region.
2831
+ */
2832
+ connectAttempt(urls) {
2833
+ const urlGen = getActiveUrl(urls);
2834
+ return defer(() => this.getAwsInstance(`${urlGen.next().value}`)).pipe(tap((awsInstance) => this.store.dispatch(setAwsInstance(awsInstance))), switchMap(({ wsUrl }) =>
2835
+ // ── WS-only retry tier ─────────────────────────────────────────────
2836
+ // Tries the SAME `wsUrl` up to `WS_RETRY_PER_INSTANCE` extra times
2837
+ // before letting the error bubble up to the outer (URL-rotating)
2838
+ // retry. This is important because the outer retry calls
2839
+ // `getAwsInstance(url)` again, which polls orchestration again,
2840
+ // which causes orchestrator to RESERVE A NEW instance — the
2841
+ // previous reservation is orphaned (server-side leak).
2842
+ //
2843
+ // The same-wsUrl retry catches transient WS failures (network
2844
+ // blip, brief region unavailability, slow cold handshake) without
2845
+ // burning a second reservation. If WS still fails after these
2846
+ // extra attempts, the instance is genuinely unusable and we
2847
+ // accept the cost of rotating.
2848
+ this.connectToCirrus(wsUrl ?? '').pipe(filter(Truthy), take(1), retry({
2849
+ count: WS_RETRY_PER_INSTANCE,
2850
+ delay: (err, attempt) => {
2851
+ this.store.dispatch(signalingRetryAttempted({
2852
+ attempt,
2853
+ reason: err?.message ?? 'unknown',
2854
+ kind: 'ws-retry',
2855
+ }));
2856
+ this.showStatusMessage(UnrealStatusMessage.CONNECTING_TO_SESSION);
2857
+ Logger.info(`WS-only retry on SAME url in ${POLLING_TIME}ms. Attempt# ${attempt}.`, err?.message);
2858
+ return timer(POLLING_TIME);
2859
+ },
2860
+ }))),
2861
+ // ── URL-rotating retry tier ─────────────────────────────────────────
2862
+ // Triggers only when the WS-only retries above are exhausted. Each
2863
+ // attempt advances `urlGen` → new `getAwsInstance(url)` → new
2864
+ // orchestration poll → new AwsInstance reservation.
2725
2865
  retry({
2866
+ count: INNER_RETRY_LIMIT,
2726
2867
  delay: (err, attempt) => {
2727
2868
  this.store.dispatch(signalingRetryAttempted({
2728
2869
  attempt,
2729
2870
  reason: err?.message ?? 'unknown',
2730
2871
  kind: 'connect',
2731
2872
  }));
2732
- Logger.info(`Retrying Signalling Connection in ${POLLING_TIME}ms. Attempt# ${attempt}.`, err?.message);
2873
+ this.showStatusMessage(UnrealStatusMessage.CONNECTING_TO_SESSION);
2874
+ Logger.info(`Inner retry (WS+orch) in ${POLLING_TIME}ms. Attempt# ${attempt}.`, err?.message);
2733
2875
  return timer(POLLING_TIME);
2734
2876
  },
2735
- // count: 10, // Optional: cap the number of attempts.
2736
- }),
2737
- // abort$ sits OUTSIDE retry, so it kills the entire retry loop,
2738
- // not just the current attempt. destroy$ is the defense-in-depth
2739
- // signal for injector teardown — if the abort action chain doesn't
2740
- // run for any reason, destroy$ still stops the loop.
2741
- takeUntil(merge(this.abort$, this.destroy$)));
2877
+ }));
2742
2878
  }
2743
2879
  startEstablishingConnection() {
2744
2880
  this.showStatusMessage(UnrealStatusMessage.STARTING_YOUR_SESSION);
@@ -2753,177 +2889,117 @@ class SignallingService extends SubService {
2753
2889
  });
2754
2890
  }
2755
2891
  /**
2756
- * Resolves an active AWS *signaling* instance and returns a WebSocket endpoint.
2757
- *
2758
- * The observable produced by this method is **cold** and executes inside a `defer`,
2759
- * so each subscription (including those triggered by `retry`) pulls the **next**
2760
- * URL candidate from a generator created by `getActiveUrl(urlsPool)`.
2761
- *
2762
- * ### Execution flow
2763
- *
2764
- * 1. **Telemetry** — Starts a `getSignaling` span for end-to-end timing.
2765
- * 2. **Candidate URL selection** — A stateful generator (`urlGen`) is created
2766
- * from `urlsPool`. Each resubscription (e.g., via `retry`) advances to the next URL.
2767
- * 3. **Short-circuit for WS URLs** — If the picked candidate already matches
2768
- * `ws://` or `wss://`, emit `{ wsUrl, pollingUrl: null, instanceName: null }`
2769
- * immediately and complete (no HTTP request).
2770
- * 4. **Await client/view identifiers** — `this.store.select(selectClientAndViewIds)`
2771
- * is tapped for logging (missing IDs) and then filtered to require both `clientId`
2772
- * and `viewId` to be truthy.
2773
- * 5. **Single in-flight HTTP orchestration call** — Uses `exhaustMap` to issue
2774
- * `GET {signalingUrl}{clientId}/{viewId}`. New `{clientId, viewId}` emissions
2775
- * while the request is in flight are **ignored** until completion, preventing overlap.
2776
- * 6. **Per-attempt timeout** — `timeout(WS_TIMEOUT)` caps how long we wait for a response.
2777
- * On timeout, the attempt errors and the outer `retry` schedules the next URL.
2778
- * 7. **Response validation and progress reporting** — A `filter`:
2779
- * - Calls `TelemetryStop('getSignaling', { ...data, multi: true })` on receipt.
2780
- * - If `data.signallingServer === ''` or `data.error`:
2781
- * shows a status message, optionally dispatches `setStatusPercentSignallingServer`
2782
- * with `data.info.percent`, and **throws** to fail the attempt (triggers `retry`).
2783
- * 8. **Mapping to `AwsInstance`** — Converts the validated orchestration payload into:
2784
- * - `wsUrl`: via `httpUrlToWs(\`\${location.protocol}//\${data.signallingServer}\`)`
2785
- * - `pollingUrl`: the base `signalingUrl` that succeeded
2786
- * - `instanceName`: the (validated) `data.signallingServer`
2787
- * 9. **Retry policy** — On *any* upstream error (timeout, HTTP error, invalid payload),
2788
- * `retry({ delay })`:
2789
- * - Shows a “connecting” status,
2790
- * - Logs the attempt number,
2791
- * - Waits `WS_TIMEOUT` ms (via `timer`) and **resubscribes**, advancing `urlGen`.
2792
- *
2793
- * ### Concurrency semantics
2794
- *
2795
- * - `exhaustMap` guarantees a **single** HTTP request per attempt; later
2796
- * `{clientId, viewId}` emissions are ignored until the current request completes.
2797
- *
2798
- * ### Telemetry semantics
2799
- *
2800
- * - `trackStartCommand('EXT-getSignaling')` begins before work.
2801
- * - `trackStopCommand('getSignaling', {...})` runs on receipt of an orchestration response.
2802
- * If an attempt fails (e.g., timeout), stopping the span is the responsibility of
2803
- * your telemetry layer or a `finalize` elsewhere if desired.
2804
- *
2805
- * ### Error & retry semantics
2806
- *
2807
- * - Missing/never-emitted IDs → timeout → retry with next URL.
2808
- * - HTTP/network error → retry with next URL.
2809
- * - Invalid orchestration payload (`error` set or empty `signallingServer`) → throw in
2810
- * validation filter → retry with next URL.
2811
- * - Short-circuit WS path **does not** retry (emits once, completes).
2812
- *
2813
- * @param urlsPool - Ordered list of base URLs to probe. Retries advance through this list
2814
- * until a working signaling server is found. A candidate may also be a direct `ws://`/`wss://`
2815
- * URL to short-circuit HTTP orchestration.
2816
- *
2817
- * @returns Observable that emits exactly one {@link AwsInstance} on success and then completes.
2818
- * On failure, the stream errors; the built-in `retry` operator re-subscribes after `WS_TIMEOUT`
2819
- * and advances to the next URL candidate.
2820
- *
2821
- * @throws Emits an error within the observable chain (not a thrown synchronous exception) when:
2822
- * - The HTTP request fails or times out.
2823
- * - The orchestration response indicates an error or an empty `signallingServer`.
2824
- * - Any other operator in the chain surfaces an error.
2825
- *
2826
- * @remarks
2827
- * - The URL generator (`urlGen`) is created **outside** `defer`, so resubscriptions
2828
- * advance the pool. Creating it inside `defer` would restart from the first URL each retry.
2829
- * - `location.protocol` is used to derive `ws` vs. `wss`. If this may run in non-browser
2830
- * contexts (e.g., SSR), guard or abstract this logic.
2831
- * - Consider capping retries with `count` in `retry({ count, delay })` when appropriate.
2832
- *
2833
- * @example
2834
- * ```ts
2835
- * getAwsInstance(['https://signaling.example.com/', 'wss://direct.example.com'])
2836
- * .subscribe({
2837
- * next: ({ wsUrl }) => connect(wsUrl),
2838
- * error: (e) => console.error('Unable to establish signaling:', e),
2839
- * });
2840
- * ```
2841
- *
2842
- * @see httpUrlToWs
2843
- * @see selectClientAndViewIds
2844
- * @see setStatusPercentSignallingServer
2892
+ * Resolve one URL to an `AwsInstance`. Dispatches to
2893
+ * {@link directWsInstance} for `ws://` / `wss://` URLs, otherwise
2894
+ * to {@link pollOrchestration}.
2895
+ */
2896
+ getAwsInstance(url) {
2897
+ return url.match(/^ws(s?):\/\//i)
2898
+ ? this.directWsInstance(url)
2899
+ : this.pollOrchestration(url);
2900
+ }
2901
+ /**
2902
+ * Direct `ws://` / `wss://` short-circuit — no HTTP orchestration.
2903
+ * Waits for `clientId` and `viewId` to be truthy in the store, then
2904
+ * emits a `Direct Connection` `AwsInstance` and completes.
2905
+ */
2906
+ directWsInstance(url) {
2907
+ return this.store.select(selectClientAndViewIds).pipe(filter(({ clientId, viewId }) => !!clientId && !!viewId), take(1), map$1(() => ({
2908
+ wsUrl: url,
2909
+ pollingUrl: null,
2910
+ instanceName: 'Direct Connection',
2911
+ })));
2912
+ }
2913
+ /**
2914
+ * HTTP-orchestration path. Brackets {@link pollLoop} with the
2915
+ * `pollingStarted`/`pollingEnded` actions and the `EXT-getSignaling`
2916
+ * telemetry span (start in `defer`, stop in `finalize` with the
2917
+ * last response captured in the closure). Maps the final ready
2918
+ * response to an `AwsInstance`.
2845
2919
  */
2846
- getAwsInstance(urlsPool) {
2847
- this.commandTelemetryService.trackStartCommand('EXT-getSignaling');
2848
- // Stateful iterator over candidate URLs; retries advance this sequence.
2849
- const urlGen = getActiveUrl(urlsPool);
2920
+ pollOrchestration(url) {
2850
2921
  return defer(() => {
2851
- const signalingUrl = `${urlGen.next().value}`;
2852
- // Short-circuit if the candidate is already a WS endpoint.
2853
- if (signalingUrl.match(/^ws(s?):\/\//i)) {
2854
- return this.store.select(selectClientAndViewIds).pipe(filter(({ clientId, viewId }) => !!clientId && !!viewId), map$1(() => {
2855
- return {
2856
- wsUrl: signalingUrl,
2857
- pollingUrl: null,
2858
- instanceName: 'Direct Connection',
2859
- };
2860
- }));
2861
- }
2862
- // Old (HTTP-polling) orchestration flow — mark the round trip so
2863
- // the signaling-phase timeline can show it as a distinct segment.
2922
+ let lastResp;
2864
2923
  this.store.dispatch(pollingStarted());
2865
- return this.store.select(selectClientAndViewIds).pipe(
2866
- // Log missing IDs without altering the stream.
2867
- tap(({ clientId, viewId }) => {
2924
+ this.commandTelemetryService.trackStartCommand('EXT-getSignaling');
2925
+ return this.store.select(selectClientAndViewIds).pipe(tap(({ clientId, viewId }) => {
2868
2926
  if (!clientId || !viewId) {
2869
2927
  Logger.error('Client ID or View ID is not set');
2870
2928
  }
2871
- }),
2872
- // Only proceed when both identifiers are available.
2873
- filter(({ clientId, viewId }) => !!clientId && !!viewId),
2874
- // Take first value
2875
- take(1),
2876
- // Ensure a single in-flight HTTP request per attempt.
2877
- exhaustMap(({ clientId, viewId }) => this.httpClient.get(`${signalingUrl}${clientId}/${viewId}`)),
2878
- // Bound the time spent waiting for the orchestration response.
2879
- timeout(POLLING_TIME),
2880
- // Validate response; report progress; fail fast on invalid payloads.
2881
- filter((data) => {
2882
- this.commandTelemetryService.trackStopCommand('EXT-getSignaling', true, { ...data, multi: true });
2883
- if (data.signallingServer === '' || data.error) {
2884
- this.showStatusMessage(data?.mm_message || 'Server not found');
2885
- if (data?.info) {
2886
- this.store.dispatch(setStatusPercentSignallingServer({
2887
- percent: data.info.percent,
2888
- }));
2889
- }
2890
- // Throwing here fails the attempt and activates the retry strategy.
2891
- throw new Error(`Orchestration message => ${data?.mm_message || 'Polling retry'}`);
2892
- }
2893
- return true;
2894
- }),
2895
- // Map a valid orchestration payload into the final AwsInstance.
2896
- map$1((data) => {
2929
+ }), filter(({ clientId, viewId }) => !!clientId && !!viewId), take(1), switchMap(({ clientId, viewId }) => this.pollLoop(`${url}${clientId}/${viewId}`).pipe(tap((data) => {
2930
+ lastResp = data;
2931
+ }))), map$1((data) => ({
2932
+ wsUrl: httpUrlToWs(`${location.protocol}//${data.signallingServer}`),
2933
+ pollingUrl: url,
2934
+ instanceName: data.signallingServer ?? '',
2935
+ })), finalize(() => {
2936
+ this.commandTelemetryService.trackStopCommand('EXT-getSignaling', true, { ...(lastResp ?? {}), multi: true });
2897
2937
  this.store.dispatch(pollingEnded());
2898
- const wsUrl = httpUrlToWs(`${location.protocol}//${data.signallingServer}`);
2899
- return {
2900
- wsUrl,
2901
- pollingUrl: signalingUrl,
2902
- instanceName: data.signallingServer ?? '',
2903
- };
2904
2938
  }));
2905
- }).pipe(
2906
- // Retry the whole attempt on error, advancing to the next URL each time.
2907
- retry({
2908
- delay: (err, attempt) => {
2909
- this.store.dispatch(signalingRetryAttempted({
2910
- attempt,
2911
- reason: err?.message ?? 'unknown',
2912
- kind: 'orchestration',
2939
+ });
2940
+ }
2941
+ /**
2942
+ * Poll the SAME `url` until ready, or until HTTP 4xx says the URL
2943
+ * is dead.
2944
+ *
2945
+ * | Response | Class | Action |
2946
+ * | ---------------------------------------- | ---------- | ---------------------------- |
2947
+ * | 200 `{ signallingServer: 'cirrus', ... }`| ✅ Ready | `first()` emits, loop ends |
2948
+ * | 200 empty `signallingServer` (any error/info/mm_message) | ⏳ Progress | Dispatch status+percent, re-poll same URL |
2949
+ * | HTTP 4xx (except 429) | ❌ Hard | Re-throw → caller rotates URL |
2950
+ * | HTTP 5xx / 429 / timeout / network | ⚠️ Soft | `EMPTY` → `repeat` same URL |
2951
+ *
2952
+ * `data.error` is NOT treated as fatal — the backend reuses it for
2953
+ * progress strings like `"Ready in ~2m 23s"`. The only fatal exits
2954
+ * are HTTP 4xx and `abort$` upstream.
2955
+ */
2956
+ pollLoop(url) {
2957
+ return defer(() => this.httpClient.get(url)).pipe(timeout(POLLING_TIME), catchError((err) => {
2958
+ // HTTP 4xx (except 429) = the URL itself is dead → rotate.
2959
+ const status = err?.status;
2960
+ if (typeof status === 'number' &&
2961
+ status >= 400 &&
2962
+ status < 500 &&
2963
+ status !== 429) {
2964
+ Logger.warn(`Polling HARD error on ${url} (HTTP ${status}); rotating URL`);
2965
+ return throwError(() => err);
2966
+ }
2967
+ // Everything else = soft → re-poll same URL.
2968
+ Logger.warn(`Polling soft error on ${url} (${err?.message ?? err}); re-polling same URL`);
2969
+ return EMPTY;
2970
+ }), tap((data) => {
2971
+ // Ready response is handled by `first()` below — nothing to
2972
+ // dispatch here. Empty `signallingServer` = progress.
2973
+ if (data.signallingServer)
2974
+ return;
2975
+ this.showStatusMessage(data?.mm_message || 'Server not found');
2976
+ if (data?.info) {
2977
+ this.store.dispatch(setStatusPercentSignallingServer({
2978
+ percent: data.info.percent,
2913
2979
  }));
2914
- this.showStatusMessage(UnrealStatusMessage.CONNECTING_TO_SESSION);
2915
- Logger.log(`Retrying with new URL in ${POLLING_TIME}ms. Attempt# ${attempt}`, err?.message);
2916
- return timer(POLLING_TIME);
2917
- },
2918
- // count: 10, // Optional: cap the number of attempts.
2919
- }));
2980
+ }
2981
+ }),
2982
+ // Re-subscribe (= poll again) after each completed response OR
2983
+ // after `catchError` swallowed a transient failure.
2984
+ repeat({ delay: () => timer(POLLING_TIME) }),
2985
+ // Stop the poll loop as soon as we get a ready response.
2986
+ first((data) => !!data.signallingServer));
2920
2987
  }
2921
2988
  connectToCirrus(wsUrl) {
2922
- this.close({ code: 1000, reason: DisconnectReason.NewConnectionAttempt });
2923
- return createWebSocket(wsUrl).pipe(tapLog('WS CREATED'), tap((ws) => {
2989
+ // Wrapped in `defer` so each subscription (including ws-retry
2990
+ // re-subscriptions from `connectAttempt`) starts a FRESH WS attempt:
2991
+ // the previous socket is closed and a new one is created. Without
2992
+ // this, the WS-only retry would just re-subscribe the SAME WS
2993
+ // observable, which in production-defer'd `createWebSocket` would
2994
+ // still create a new socket, but in tests would re-emit the same
2995
+ // (already-errored) mock observable.
2996
+ return defer(() => {
2997
+ this.close({ code: 1000, reason: DisconnectReason.NewConnectionAttempt });
2998
+ return createWebSocket(wsUrl);
2999
+ }).pipe(takeUntil(this.abort$), tapLog('WS CREATED'), tap((ws) => {
2924
3000
  this.commandTelemetryService.trackStartCommand('EXT-iceCandidate');
2925
3001
  this.addWsHandlers(ws);
2926
- }), takeUntil(merge(this.abort$, this.destroy$)));
3002
+ }));
2927
3003
  }
2928
3004
  addWsHandlers(ws) {
2929
3005
  ws.onmessage = OnMessageHandler.bind(this);
@@ -2942,9 +3018,6 @@ class SignallingService extends SubService {
2942
3018
  */
2943
3019
  close(data) {
2944
3020
  const { code, reason } = data;
2945
- // Signal "no answer in flight anymore" to any subscriber that observes
2946
- // raw emissions (today's subscribers filter Truthy, but the Subject's
2947
- // `| null` type is the contract — keep it honored).
2948
3021
  this.onWebRtcAnswer$.next(null);
2949
3022
  this.orchestrationRetrySub?.unsubscribe();
2950
3023
  if (this.ws) {
@@ -4046,25 +4119,18 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
4046
4119
  }]
4047
4120
  }] });
4048
4121
 
4049
- class UnrealCommunicatorService {
4122
+ class UnrealCommunicatorService extends SubService {
4050
4123
  constructor() {
4051
- this.store = inject(Store);
4052
- this.actions$ = inject(Actions);
4124
+ super();
4053
4125
  this.commandTelemetryService = inject(CommandTelemetryService);
4054
4126
  this.webRtcPlayerService = inject(WebRtcPlayerService);
4055
4127
  this.unrealConfig = inject(UNREAL_CONFIG, { optional: true });
4056
- this.destroyRef = inject(DestroyRef);
4057
- this.destroy$ = new Subject();
4058
4128
  this.cirrusConnected = this.store.selectSignal(unrealFeature.selectCirrusConnected);
4059
4129
  this.afkIgnoredCommands = new Set(['executeConsoleCommand']);
4060
4130
  this.skipAfkReset = false;
4061
4131
  // Throttle: input events fire 60-200x/sec, but AFK timeouts are measured in
4062
4132
  // seconds. Leading-edge throttle dispatches once, then suppresses for 1s.
4063
4133
  this.resetAfk$ = new Subject();
4064
- this.destroyRef.onDestroy(() => {
4065
- this.destroy$.next();
4066
- this.destroy$.complete();
4067
- });
4068
4134
  this.resetAfk$
4069
4135
  .pipe(throttleTime(1000), takeUntil$1(this.destroy$))
4070
4136
  .subscribe(() => this.store.dispatch(resetAfk()));
@@ -4670,20 +4736,21 @@ class RegionsPingService {
4670
4736
  constructor() {
4671
4737
  this.httpClient = inject(HttpClient);
4672
4738
  this.store = inject(Store);
4673
- this.unrealInitialConfig = inject(UNREAL_CONFIG, { optional: true });
4739
+ this.unrealConfig = inject(UNREAL_CONFIG, { optional: true });
4674
4740
  this.destroyRef = inject(DestroyRef);
4675
- // Aborted on injector destruction. Cascades through every in-flight
4676
- // fetch so background pings stop the moment the service dies.
4677
- this.destroyAbort = new AbortController();
4741
+ // Aborted when the injector is destroyed. Forwarded into every
4742
+ // `startPinging` call's winnerController so in-flight fetches cancel
4743
+ // immediately on route exit instead of running to natural completion.
4744
+ this.destroyController = new AbortController();
4678
4745
  this.config = {
4679
4746
  ping_count: 2,
4680
4747
  ping_timeout: 2000,
4681
4748
  max_parallel_requests: 10,
4682
4749
  bust_cache: true,
4683
4750
  };
4684
- this.destroyRef.onDestroy(() => this.destroyAbort.abort());
4751
+ this.destroyRef.onDestroy(() => this.destroyController.abort());
4685
4752
  }
4686
- getFastest(regionListUrl = this.unrealInitialConfig?.regionsPingUrl || '') {
4753
+ getFastest(regionListUrl = this.unrealConfig?.regionsPingUrl || '') {
4687
4754
  // Self-announce every lifecycle waypoint of the region-discovery
4688
4755
  // flow (start / providers-fetched / resolved). The final
4689
4756
  // `regionResolved` forwards the winner's full ping-sample array
@@ -4700,31 +4767,34 @@ class RegionsPingService {
4700
4767
  const defaultTimeout = providers.timeout ?? this.config.ping_timeout;
4701
4768
  return from(this.startPinging(providers.regions, defaultTimeout));
4702
4769
  }
4703
- getProviders(regionListUrl = this.unrealInitialConfig?.regionsPingUrl || '') {
4770
+ getProviders(regionListUrl = this.unrealConfig?.regionsPingUrl || '') {
4704
4771
  return this.httpClient.get(regionListUrl);
4705
4772
  }
4706
4773
  async startPinging(endpoints, defaultTimeout) {
4707
- // Two-tier cancellation:
4708
- // - destroyAbort: service teardown (injector destroy)
4709
- // - winnerAbort: first successful region resolves Promise.any
4710
- // Both aborts cascade into every in-flight fetch so losing pings stop
4711
- // immediately instead of running their full ping_count × timeout budget.
4712
- const winnerAbort = new AbortController();
4713
- const stopSignal = AbortSignal.any([
4714
- winnerAbort.signal,
4715
- this.destroyAbort.signal,
4716
- ]);
4717
4774
  const semaphore = new Semaphore(this.config.max_parallel_requests);
4775
+ // Shared "stop" signal. Aborted when EITHER:
4776
+ // - one region finishes successfully (winner found → cancel losers); OR
4777
+ // - the service's injector is destroyed (route exit → cancel everything).
4778
+ // Without this, losing regions keep pinging to their own `ping_timeout`,
4779
+ // generating up to `(endpoints.length - 1) × ping_count` wasted requests
4780
+ // every time `getFastest` runs, and in-flight fetches outlive the route.
4781
+ const winnerController = new AbortController();
4782
+ const destroySignal = this.destroyController.signal;
4783
+ let onDestroyAbort;
4784
+ if (destroySignal.aborted) {
4785
+ // Service already destroyed — bail every task synchronously.
4786
+ winnerController.abort(destroySignal.reason);
4787
+ }
4788
+ else {
4789
+ onDestroyAbort = () => winnerController.abort(destroySignal.reason);
4790
+ destroySignal.addEventListener('abort', onDestroyAbort, { once: true });
4791
+ }
4718
4792
  const tasks = endpoints.map(async ({ region_code, url, timeout }) => {
4719
4793
  await semaphore.acquire();
4720
4794
  try {
4721
- if (stopSignal.aborted) {
4722
- throw new Error('aborted');
4723
- }
4724
- const result = await this.checkLatency(url, timeout ?? defaultTimeout, stopSignal);
4725
- if (result.length < 2) {
4795
+ const result = await this.checkLatency(url, timeout ?? defaultTimeout, winnerController.signal);
4796
+ if (result.length < 2)
4726
4797
  throw new Error('insufficient pings');
4727
- }
4728
4798
  return { region_code, result };
4729
4799
  }
4730
4800
  finally {
@@ -4732,18 +4802,31 @@ class RegionsPingService {
4732
4802
  }
4733
4803
  });
4734
4804
  try {
4735
- const winner = await Promise.any(tasks);
4736
- winnerAbort.abort();
4737
- return winner;
4805
+ return await Promise.any(tasks);
4738
4806
  }
4739
4807
  catch {
4740
4808
  return null;
4741
4809
  }
4810
+ finally {
4811
+ // Cancel losing in-flight pings. Safe to call on success (we already
4812
+ // captured the winner) AND on all-failed (no-op, all tasks settled).
4813
+ winnerController.abort();
4814
+ // Detach the destroy listener so it doesn't linger on the long-lived
4815
+ // destroyController.signal across multiple `getFastest` invocations.
4816
+ if (onDestroyAbort) {
4817
+ destroySignal.removeEventListener('abort', onDestroyAbort);
4818
+ }
4819
+ }
4742
4820
  }
4743
- async checkLatency(endpoint, timeout, externalSignal) {
4821
+ async checkLatency(endpoint, timeout, abortSignal) {
4744
4822
  const pingResults = [];
4745
4823
  const sep = endpoint.includes('?') ? '&' : '?';
4746
4824
  for (let i = 0; i < this.config.ping_count; i++) {
4825
+ // Bail out as soon as another region's task has won — no point
4826
+ // burning further `ping_timeout` budget here.
4827
+ if (abortSignal?.aborted) {
4828
+ break;
4829
+ }
4747
4830
  const startTime = performance.now();
4748
4831
  try {
4749
4832
  await this.fetchWithTimeout(this.config.bust_cache
@@ -4757,7 +4840,7 @@ class RegionsPingService {
4757
4840
  Expires: '0',
4758
4841
  },
4759
4842
  credentials: 'omit',
4760
- }, timeout, externalSignal);
4843
+ }, timeout, abortSignal);
4761
4844
  }
4762
4845
  catch {
4763
4846
  // intentional: finally pushes the elapsed time as the latency sample
@@ -4769,20 +4852,31 @@ class RegionsPingService {
4769
4852
  }
4770
4853
  return pingResults;
4771
4854
  }
4772
- async fetchWithTimeout(url, options = {}, timeout, externalSignal) {
4855
+ async fetchWithTimeout(url, options = {}, timeout, abortSignal) {
4773
4856
  const controller = new AbortController();
4774
- options.signal = externalSignal
4775
- ? AbortSignal.any([controller.signal, externalSignal])
4776
- : controller.signal;
4857
+ options.signal = controller.signal;
4858
+ // Forward an external abort into our local controller so a winner-found signal cancels the in-flight `fetch` immediately. If the
4859
+ // external signal is already aborted, abort synchronously before
4860
+ // even starting the fetch.
4861
+ let onExternalAbort;
4862
+ if (abortSignal) {
4863
+ if (abortSignal.aborted) {
4864
+ controller.abort(abortSignal.reason);
4865
+ }
4866
+ else {
4867
+ onExternalAbort = () => controller.abort(abortSignal.reason);
4868
+ abortSignal.addEventListener('abort', onExternalAbort, { once: true });
4869
+ }
4870
+ }
4777
4871
  const timeoutId = setTimeout(() => controller.abort(new DOMException('Request timed out', 'TimeoutError')), timeout);
4778
4872
  try {
4779
- const response = await fetch(url, options);
4780
- clearTimeout(timeoutId);
4781
- return response;
4873
+ return await fetch(url, options);
4782
4874
  }
4783
- catch (error) {
4875
+ finally {
4784
4876
  clearTimeout(timeoutId);
4785
- throw error;
4877
+ if (abortSignal && onExternalAbort) {
4878
+ abortSignal.removeEventListener('abort', onExternalAbort);
4879
+ }
4786
4880
  }
4787
4881
  }
4788
4882
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: RegionsPingService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
@@ -7085,5 +7179,5 @@ function provideUnrealModule() {
7085
7179
  * Generated bundle index. Do not edit.
7086
7180
  */
7087
7181
 
7088
- export { AFKService, AfkTimeoutModalComponent, AggregatorService, AnalyticsService, AnswerHandler, CONSOLE_COMMAND_DISABLE_MESSAGES, CONSOLE_COMMAND_ENABLE_MESSAGES, CONSOLE_COMMAND_PIXEL_QUALITY, CommandTelemetryService, ConfigHandler, ConsoleExtensionsService, ControlScheme, DATA_CHANNEL_CONNECTION_TIMEOUT, DEBOUNCE_TO_MANY_RESIZE_CALLS, DEFAULT_AFK_TIMEOUT, DEFAULT_AFK_TIMEOUT_PERIOD, DEFAULT_RECONNECT_DELAY_MS, DEFAULT_RECONNECT_ENABLED, DEFAULT_RECONNECT_MAX_ATTEMPTS, DEFAULT_RECONNECT_ON_DATACHANNEL_CLOSE, DEFAULT_RECONNECT_ON_ICE_FAILURE, DESTROY_DISCONNECT_REASONS, DataFlowMonitor, DevModeService, DisconnectReason, FULL_HD_HEIGHT, FULL_HD_WIDTH, FileHandlerService, FileReceiverService, FilterSettingsComponent, FpsMonitorService, FreezeFrameComponent, FreezeFrameService, IceCandidateHandler, ImageLoadingSrcComponent, InputOptions, InputService, InstanceReadyHandler, InstanceReservedHandler, IntroSrcComponent, KalmanFilter1D, LatencyTimings, LoadingProgressService, LowBandwidthDetectorComponent, LowBandwidthModalComponent, MINIMAL_FPS, MIN_CONTAINER_SIZE, MessageType, MouseButton, MouseButtonsMask, OnCloseHandler, OnErrorHandler, OnMessageHandler, OnOpenHandler, OrchestrationMessageTypes, POLLING_TIME, PingHandler, PlayerCountHandler, RegionsPingService, SAME_SIZE_THRESHOLD, SCREEN_LOCKER_CONTAINER_ID, SIGNALLING_PERCENT_VALUE, SSInfoHandler, STREAMING_AUDIO_ID, STREAMING_VIDEO_ID, SafePipe, SignallingService, SpecialKeyCodes, StatGraphComponent, StreamStatusTelemetryService, StreamStatusTelemetryService2, SubService, ToClientMessage, UNREAL_CONFIG, UnrealCallbackService, UnrealCommunicatorService, UnrealEffects, UnrealSceneComponent, UnrealStatusMessage, VideoService, VideoStatsComponent, WSCloseCode_CIRRUS_ABNORMAL_CLOSURE, WSCloseCode_CIRRUS_MAX_PLAYERS_ERROR, WSCloseCode_CIRRUS_PLAYER_DISCONNECTED, WSCloseCode_CIRRUS_STREAMER_KIKED_PLAYER, WSCloseCode_FORCE_CIRRUS_CLOSE, WSCloseCode_NORMAL_AFK_TIMEOUT, WSCloseCode_NORMAL_CLOSURE, WSCloseCode_NORMAL_MANUAL_DISCONNECT, WSCloseCode_UNKNOWN, WSCloseCodes, WS_OPEN_STATE, WS_TIMEOUT, WebRtcPlayerService, WebrtcErrorModalComponent, abortEstablishingConnection, answerSent, changeStatusMainVideoOnScene, changeStreamResolutionAction, commandCompleted, commandStarted, dataChannelConnected, dataChannelReady, decodeData, destroyRemoteConnections, destroyUnrealScene, disconnectStream, dispatchResize, dropConnection, dropDC, dropPC, floatToPercents, floatToSmoothPercents, forceResizeViewport, fromResizeObserver, getActiveUrl, getImageFromVideoStream, getResizeStudioValues, getResizeValuesUniversal, getRtcErrorMessage, iceConnectionFailed, initSignalling, instanceReady, keepMaxUntilReset, makeEven, pollingEnded, pollingStarted, provideUnrealModule, qpToQuality, reconnectPeer, reconnectPeerFailed, reconnectPeerSuccess, regionPingFetchEnd, regionPingFetchStart, regionResolved, resetAfk, resetConfig, resetDataChannelForReconnect, resetIntroSrc, resetUserInactivityTimeout, resetWarnTimeout, saveAnalyticsEvent, selectCirrusConnectionId, selectClientAndViewIds, selectCommandProgress, selectFreezeFrameCombinedDataUrl, selectFreezeFrameDataUrl, selectFreezeFrameDataUrlFromVideo, selectFreezeFrameProgressMessageFromVideo, selectIsAutostart, selectIsExistMatchUrls, selectIsFreezeFrameLoading, selectIsFullyConnected, selectIsLoaderScreenVisible, selectIsVideoPlayingAndDataChannelConnected, selectShowLoader, selectShowReconnectPopup, selectSignalingParameters, selectStreamConfig, selectTotalProgress, selectUserInactivityDetected, selectWarnTimeout, setAfkTimerCountdown, setAfkTimerHide, setAfkTimerVisible, setAwsInstance, setCirrusConnected, setCirrusDisconnected, setConfig, setEstablishingConnection, setFreezeFrame, setFreezeFrameFromVideo, setIntroImageSrc, setIntroVideoSrc, setLoadingImageSrc, setLoopBackCommandIsCompleted, setMaxFps, setOrchestrationContext, setOrchestrationMessage, setOrchestrationParameters, setOrchestrationProgress, setSignalingName, setStatusMessage, setStatusPercentSignallingServer, setStreamClientCompanyId, setStreamViewId, setUnrealPlaywrightConfig, setUserInactivityTimeout, setViewportNotReady, setViewportReady, showLoadingProgress, showResumeStreamPopup, showUnrealErrorMessage, showUserInactivityTimeoutPopup, signalingRetryAttempted, smoothTransition, startStream, trackMixpanelEvent, trackTelemetryData, unrealCommandCallback, unrealFeature, unrealReducer, updateCirrusInfo, videoAdaptedToContainer, videoStreamInitialized };
7182
+ export { AFKService, AfkTimeoutModalComponent, AggregatorService, AnalyticsService, AnswerHandler, CONSOLE_COMMAND_DISABLE_MESSAGES, CONSOLE_COMMAND_ENABLE_MESSAGES, CONSOLE_COMMAND_PIXEL_QUALITY, CommandTelemetryService, ConfigHandler, ConsoleExtensionsService, ControlScheme, DATA_CHANNEL_CONNECTION_TIMEOUT, DEBOUNCE_TO_MANY_RESIZE_CALLS, DEFAULT_AFK_TIMEOUT, DEFAULT_AFK_TIMEOUT_PERIOD, DEFAULT_RECONNECT_DELAY_MS, DEFAULT_RECONNECT_ENABLED, DEFAULT_RECONNECT_MAX_ATTEMPTS, DEFAULT_RECONNECT_ON_DATACHANNEL_CLOSE, DEFAULT_RECONNECT_ON_ICE_FAILURE, DESTROY_DISCONNECT_REASONS, DataFlowMonitor, DevModeService, DisconnectReason, FULL_HD_HEIGHT, FULL_HD_WIDTH, FileHandlerService, FileReceiverService, FilterSettingsComponent, FpsMonitorService, FreezeFrameComponent, FreezeFrameService, INNER_RETRY_LIMIT, IceCandidateHandler, ImageLoadingSrcComponent, InputOptions, InputService, InstanceReadyHandler, InstanceReservedHandler, IntroSrcComponent, KalmanFilter1D, LatencyTimings, LoadingProgressService, LowBandwidthDetectorComponent, LowBandwidthModalComponent, MINIMAL_FPS, MIN_CONTAINER_SIZE, MessageType, MouseButton, MouseButtonsMask, OUTER_RETRY_DELAY, OnCloseHandler, OnErrorHandler, OnMessageHandler, OnOpenHandler, OrchestrationMessageTypes, POLLING_TIME, PingHandler, PlayerCountHandler, RegionsPingService, SAME_SIZE_THRESHOLD, SCREEN_LOCKER_CONTAINER_ID, SIGNALLING_PERCENT_VALUE, SSInfoHandler, STREAMING_AUDIO_ID, STREAMING_VIDEO_ID, SafePipe, SignallingService, SpecialKeyCodes, StatGraphComponent, StreamStatusTelemetryService, StreamStatusTelemetryService2, SubService, ToClientMessage, UNREAL_CONFIG, UnrealCallbackService, UnrealCommunicatorService, UnrealEffects, UnrealSceneComponent, UnrealStatusMessage, VideoService, VideoStatsComponent, WSCloseCode_CIRRUS_ABNORMAL_CLOSURE, WSCloseCode_CIRRUS_MAX_PLAYERS_ERROR, WSCloseCode_CIRRUS_PLAYER_DISCONNECTED, WSCloseCode_CIRRUS_STREAMER_KIKED_PLAYER, WSCloseCode_FORCE_CIRRUS_CLOSE, WSCloseCode_NORMAL_AFK_TIMEOUT, WSCloseCode_NORMAL_CLOSURE, WSCloseCode_NORMAL_MANUAL_DISCONNECT, WSCloseCode_UNKNOWN, WSCloseCodes, WS_OPEN_STATE, WS_RETRY_PER_INSTANCE, WS_TIMEOUT, WebRtcPlayerService, WebrtcErrorModalComponent, abortEstablishingConnection, answerSent, changeStatusMainVideoOnScene, changeStreamResolutionAction, commandCompleted, commandStarted, dataChannelConnected, dataChannelReady, decodeData, destroyRemoteConnections, destroyUnrealScene, disconnectStream, dispatchResize, dropConnection, dropDC, dropPC, floatToPercents, floatToSmoothPercents, forceResizeViewport, fromResizeObserver, getActiveUrl, getImageFromVideoStream, getResizeStudioValues, getResizeValuesUniversal, getRtcErrorMessage, iceConnectionFailed, initSignalling, instanceReady, keepMaxUntilReset, makeEven, pollingEnded, pollingStarted, provideUnrealModule, qpToQuality, reconnectPeer, reconnectPeerFailed, reconnectPeerSuccess, regionPingFetchEnd, regionPingFetchStart, regionResolved, resetAfk, resetConfig, resetDataChannelForReconnect, resetIntroSrc, resetUserInactivityTimeout, resetWarnTimeout, saveAnalyticsEvent, selectCirrusConnectionId, selectClientAndViewIds, selectCommandProgress, selectFreezeFrameCombinedDataUrl, selectFreezeFrameDataUrl, selectFreezeFrameDataUrlFromVideo, selectFreezeFrameProgressMessageFromVideo, selectIsAutostart, selectIsExistMatchUrls, selectIsFreezeFrameLoading, selectIsFullyConnected, selectIsLoaderScreenVisible, selectIsVideoPlayingAndDataChannelConnected, selectShowLoader, selectShowReconnectPopup, selectSignalingParameters, selectStreamConfig, selectTotalProgress, selectUserInactivityDetected, selectWarnTimeout, setAfkTimerCountdown, setAfkTimerHide, setAfkTimerVisible, setAwsInstance, setCirrusConnected, setCirrusDisconnected, setConfig, setEstablishingConnection, setFreezeFrame, setFreezeFrameFromVideo, setIntroImageSrc, setIntroVideoSrc, setLoadingImageSrc, setLoopBackCommandIsCompleted, setMaxFps, setOrchestrationContext, setOrchestrationMessage, setOrchestrationParameters, setOrchestrationProgress, setSignalingName, setStatusMessage, setStatusPercentSignallingServer, setStreamClientCompanyId, setStreamViewId, setUnrealPlaywrightConfig, setUserInactivityTimeout, setViewportNotReady, setViewportReady, showLoadingProgress, showResumeStreamPopup, showUnrealErrorMessage, showUserInactivityTimeoutPopup, signalingRetryAttempted, smoothTransition, startStream, trackMixpanelEvent, trackTelemetryData, unrealCommandCallback, unrealFeature, unrealReducer, updateCirrusInfo, videoAdaptedToContainer, videoStreamInitialized };
7089
7183
  //# sourceMappingURL=3dsource-angular-unreal-module.mjs.map