@3dsource/angular-unreal-module 0.0.122 → 0.0.123

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, 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
@@ -1276,12 +1299,6 @@ class UnrealEffects {
1276
1299
  onDataChannelClose: this.unrealInitialConfig?.reconnect?.onDataChannelClose ??
1277
1300
  DEFAULT_RECONNECT_ON_DATACHANNEL_CLOSE,
1278
1301
  };
1279
- this.destroyConnectionsCleanup$ = createEffect(() => {
1280
- return this.actions$.pipe(ofType(destroyUnrealScene), tap(() => {
1281
- this.commandsSender.destroy();
1282
- this.videoService.setContainer(null);
1283
- }));
1284
- }, { dispatch: false });
1285
1302
  this.destroyConnections$ = createEffect(() => {
1286
1303
  return this.actions$.pipe(ofType(destroyUnrealScene), map(() => destroyRemoteConnections({ reason: DisconnectReason.Destroy })));
1287
1304
  });
@@ -1678,9 +1695,9 @@ class AFKService extends SubService {
1678
1695
  resetAfkWarningTimer() {
1679
1696
  if (this.active) {
1680
1697
  this.clearTimers();
1681
- this.warnTimer = setTimeout(() => {
1682
- this.showAfkOverlay();
1683
- }, (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);
1684
1701
  }
1685
1702
  }
1686
1703
  /**
@@ -1700,7 +1717,8 @@ class AFKService extends SubService {
1700
1717
  }
1701
1718
  reset() {
1702
1719
  this.hideOverlay();
1703
- clearInterval(this.countdownTimer);
1720
+ // startAfkWarningTimer → resetAfkWarningTimer → clearTimers() already
1721
+ // clears the countdown interval, no separate clearInterval needed.
1704
1722
  this.startAfkWarningTimer();
1705
1723
  }
1706
1724
  showAfkOverlay() {
@@ -1903,7 +1921,7 @@ const BITRATE_MONITOR = new DataFlowMonitor(FilterModel.yellowFlag, FilterModel.
1903
1921
 
1904
1922
  class VideoService extends SubService {
1905
1923
  constructor() {
1906
- super(...arguments);
1924
+ super();
1907
1925
  this.isVideoPlaying = this.store.selectSignal(unrealFeature.selectIsVideoPlaying);
1908
1926
  this.video = null;
1909
1927
  this.audio = null;
@@ -1922,6 +1940,11 @@ class VideoService extends SubService {
1922
1940
  ? // IMPORTANT! DO NOT CHANGE THOSE NUMBERS, LBM Stats are based on those values
1923
1941
  interval(250).pipe(map(() => track.pcClient), switchMap$1((pcClient) => from(this.getStats(pcClient))), filter(Truthy))
1924
1942
  : of(null)), filter(Truthy), share());
1943
+ this.destroyAbort = new AbortController();
1944
+ this.destroyRef.onDestroy(() => {
1945
+ this.destroyAbort.abort();
1946
+ this.destroy();
1947
+ });
1925
1948
  }
1926
1949
  setContainer(container = null) {
1927
1950
  this.container = container;
@@ -1933,7 +1956,9 @@ class VideoService extends SubService {
1933
1956
  this.VideoEncoderQP = Number(new TextDecoder('utf-16').decode(data));
1934
1957
  }
1935
1958
  create() {
1936
- this.disconnect$.pipe(take(1)).subscribe(() => this.destroy());
1959
+ this.disconnect$
1960
+ .pipe(take(1), takeUntil$1(this.destroy$))
1961
+ .subscribe(() => this.destroy());
1937
1962
  this.destroy();
1938
1963
  this.createWebRtcVideo();
1939
1964
  this.createWebRtcAudio();
@@ -2006,8 +2031,13 @@ class VideoService extends SubService {
2006
2031
  window.addEventListener('pointerdown', resume, {
2007
2032
  once: true,
2008
2033
  capture: true,
2034
+ signal: this.destroyAbort.signal,
2035
+ });
2036
+ window.addEventListener('keydown', resume, {
2037
+ once: true,
2038
+ capture: true,
2039
+ signal: this.destroyAbort.signal,
2009
2040
  });
2010
- window.addEventListener('keydown', resume, { once: true, capture: true });
2011
2041
  }
2012
2042
  }
2013
2043
  async getStats(pcClient) {
@@ -2173,7 +2203,7 @@ class VideoService extends SubService {
2173
2203
  .pipe(takeUntil$1(merge(this.disconnect$, this.destroy$)))
2174
2204
  .subscribe(() => this.changeStatusMainVideoOnScene(true));
2175
2205
  fromEvent(this.video, 'loadedmetadata')
2176
- .pipe(take(1), filter((data) => this.video === data.target), tapLog('VideoService loadedmetadata:'), filter(() => !!this.video))
2206
+ .pipe(take(1), filter((data) => this.video === data.target), tapLog('VideoService loadedmetadata:'), filter(() => !!this.video), takeUntil$1(this.destroy$))
2177
2207
  .subscribe(() => this.store.dispatch(videoStreamInitialized()));
2178
2208
  }
2179
2209
  createWebRtcAudio() {
@@ -2189,18 +2219,16 @@ class VideoService extends SubService {
2189
2219
  error: () => ({}),
2190
2220
  });
2191
2221
  }
2192
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: VideoService, deps: null, target: i0.ɵɵFactoryTarget.Injectable }); }
2222
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: VideoService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
2193
2223
  static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: VideoService }); }
2194
2224
  }
2195
2225
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: VideoService, decorators: [{
2196
2226
  type: Injectable
2197
- }] });
2227
+ }], ctorParameters: () => [] });
2198
2228
 
2199
- class CommandTelemetryService {
2229
+ class CommandTelemetryService extends SubService {
2200
2230
  constructor() {
2201
- this.actions$ = inject(Actions);
2202
- this.destroyRef = inject(DestroyRef);
2203
- this.destroy$ = new Subject();
2231
+ super();
2204
2232
  this.appId = 'metabox-telemetry';
2205
2233
  this.commandsSent = {};
2206
2234
  this.exileTimeout = 60000;
@@ -2208,13 +2236,7 @@ class CommandTelemetryService {
2208
2236
  this.commandSequenceNumber = 0;
2209
2237
  this.payloads = [];
2210
2238
  this.httpClient = inject(HttpClient);
2211
- this.unrealConfig = inject(UNREAL_CONFIG, {
2212
- optional: true,
2213
- });
2214
- this.destroyRef.onDestroy(() => {
2215
- this.destroy$.next();
2216
- this.destroy$.complete();
2217
- });
2239
+ this.unrealConfig = inject(UNREAL_CONFIG, { optional: true });
2218
2240
  this.init();
2219
2241
  }
2220
2242
  init() {
@@ -2648,12 +2670,11 @@ class SignallingService extends SubService {
2648
2670
  this.onConfig$ = new Subject();
2649
2671
  this.onWebRtcIce$ = new Subject();
2650
2672
  this.onWebRtcAnswer$ = new Subject();
2651
- // Fan-in produced by abortEstablishingConnection$ in unreal.effects.ts
2652
- // whenever cirrus drops, dropConnection fires, or the scene is destroyed.
2653
- this.abort$ = this.actions$.pipe(ofType(abortEstablishingConnection), tap(() => this.close({
2654
- code: 1000,
2655
- reason: DisconnectReason.AbortEstablishingConnection,
2656
- })));
2673
+ // Pure signal — no side-effects. The `close()` is wired ONCE in init()
2674
+ // below so a single abort event triggers exactly one close, regardless
2675
+ // of how many pipelines have `takeUntil(this.abort$)` attached.
2676
+ this.abort$ = this.actions$.pipe(ofType(abortEstablishingConnection));
2677
+ this.ws = null;
2657
2678
  this.wsMsgHandlers = {};
2658
2679
  this.init();
2659
2680
  }
@@ -2662,9 +2683,24 @@ class SignallingService extends SubService {
2662
2683
  return;
2663
2684
  }
2664
2685
  this.setHandlersFromStream();
2665
- const stop$ = merge(this.abort$, this.destroy$);
2686
+ // Single owner of the "close on abort" side-effect. Previously this
2687
+ // lived as a `tap` inside `abort$` itself, which made every
2688
+ // `takeUntil(this.abort$)` subscriber fire its own close().
2689
+ this.abort$.pipe(takeUntil(this.destroy$)).subscribe(() => {
2690
+ this.close({
2691
+ code: 1000,
2692
+ reason: DisconnectReason.AbortEstablishingConnection,
2693
+ });
2694
+ });
2695
+ // `dataChannelReady` is a per-session event. Bind ONLY to destroy$, not
2696
+ // to abort$ — `abort$` fires on every destroyUnrealScene (route swap),
2697
+ // and `init()` runs once in the constructor. If these subs were torn
2698
+ // down by abort$, a second in-injector session (e.g. navigating between
2699
+ // two routes that both host <app-unreal-scene>) would never send
2700
+ // `p2pEstablished` / `streamPreparedForUserInteraction`. `send()` is
2701
+ // already a no-op on a closed WS, so there's no risk of stray messages.
2666
2702
  this.actions$
2667
- .pipe(ofType(dataChannelReady), takeUntil(stop$))
2703
+ .pipe(ofType(dataChannelReady), takeUntil(this.destroy$))
2668
2704
  .subscribe(() => {
2669
2705
  this.send({
2670
2706
  type: 'p2pEstablished',
@@ -2675,7 +2711,7 @@ class SignallingService extends SubService {
2675
2711
  this.actions$
2676
2712
  .pipe(ofType(dataChannelReady), switchMap(() => this.store
2677
2713
  .select(unrealFeature.selectViewportReady)
2678
- .pipe(filter(Truthy), take(1))), takeUntil(stop$))
2714
+ .pipe(filter(Truthy), take(1))), takeUntil(this.destroy$))
2679
2715
  .subscribe(() => {
2680
2716
  this.send({
2681
2717
  type: 'streamPreparedForUserInteraction',
@@ -2692,36 +2728,93 @@ class SignallingService extends SubService {
2692
2728
  return EMPTY;
2693
2729
  }
2694
2730
  Logger.log('CONNECT TO SIGNALLING');
2731
+ // Side-effects that belong to one logical `connectToSignaling`
2732
+ // invocation (NOT one retry attempt). Wrapping them in a `defer`
2733
+ // makes them fire on subscribe (lazy), but only ONCE — the retry
2734
+ // below sits INSIDE this defer's projection so re-subscribing the
2735
+ // inner pipeline does not re-trigger these.
2695
2736
  return defer(() => {
2696
- // Preparation for WebSocket Orchestration. Inside defer so these
2697
- // side effects only fire once the caller actually subscribes.
2698
2737
  this.correlationId = generateUuid();
2699
2738
  this.commandTelemetryService.reset();
2700
2739
  this.startEstablishingConnection();
2701
- 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(this.abort$));
2702
- }).pipe(
2703
- // Retry the whole attempt on error, advancing to the next URL each time.
2704
- // Note: this currently re-runs `getRegion()` on every retry — see
2705
- // `SIGNALLING.md` for the leak description and proposed fix. The
2706
- // dispatch below is observability-only so the viewer can show the
2707
- // retry storm in the Anomalies table without the fix being applied.
2740
+ // Staged retry:
2741
+ // * `getRegion()` is pulled in front of the retry — region
2742
+ // discovery happens once per outer cycle.
2743
+ // * `connectAttempt(urls)` owns inner retry + URL rotation +
2744
+ // WS connect. WS/orch failures stay inside it (capped at
2745
+ // `INNER_RETRY_LIMIT`).
2746
+ // * If the inner retry exhausts, the error propagates to the
2747
+ // OUTER retry below which re-runs `getRegion()` (rare, real
2748
+ // "region down" case). Outer delay > inner so escalation is
2749
+ // bounded.
2750
+ 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$));
2751
+ return connectCycle$.pipe(retry({
2752
+ delay: (err, attempt) => {
2753
+ this.store.dispatch(signalingRetryAttempted({
2754
+ attempt,
2755
+ reason: err?.message ?? 'unknown',
2756
+ kind: 'region-cycle',
2757
+ }));
2758
+ Logger.info(`Outer retry (region re-pick) in ${OUTER_RETRY_DELAY}ms. Attempt# ${attempt}.`, err?.message);
2759
+ return timer(OUTER_RETRY_DELAY);
2760
+ },
2761
+ }));
2762
+ }).pipe(takeUntil(this.abort$));
2763
+ }
2764
+ /**
2765
+ * One inner cycle: rotate `urlGen` over the pool, resolve via
2766
+ * {@link getAwsInstance}, open WS via {@link connectToCirrus},
2767
+ * retry up to {@link INNER_RETRY_LIMIT} on any hard failure. The
2768
+ * urlGen lives in this closure so retries advance through the
2769
+ * pool. Exhaustion bubbles to {@link connectToSignaling}'s outer
2770
+ * retry which re-pings the region.
2771
+ */
2772
+ connectAttempt(urls) {
2773
+ const urlGen = getActiveUrl(urls);
2774
+ return defer(() => this.getAwsInstance(`${urlGen.next().value}`)).pipe(tap((awsInstance) => this.store.dispatch(setAwsInstance(awsInstance))), switchMap(({ wsUrl }) =>
2775
+ // ── WS-only retry tier ─────────────────────────────────────────────
2776
+ // Tries the SAME `wsUrl` up to `WS_RETRY_PER_INSTANCE` extra times
2777
+ // before letting the error bubble up to the outer (URL-rotating)
2778
+ // retry. This is important because the outer retry calls
2779
+ // `getAwsInstance(url)` again, which polls orchestration again,
2780
+ // which causes orchestrator to RESERVE A NEW instance — the
2781
+ // previous reservation is orphaned (server-side leak).
2782
+ //
2783
+ // The same-wsUrl retry catches transient WS failures (network
2784
+ // blip, brief region unavailability, slow cold handshake) without
2785
+ // burning a second reservation. If WS still fails after these
2786
+ // extra attempts, the instance is genuinely unusable and we
2787
+ // accept the cost of rotating.
2788
+ this.connectToCirrus(wsUrl ?? '').pipe(filter(Truthy), take(1), retry({
2789
+ count: WS_RETRY_PER_INSTANCE,
2790
+ delay: (err, attempt) => {
2791
+ this.store.dispatch(signalingRetryAttempted({
2792
+ attempt,
2793
+ reason: err?.message ?? 'unknown',
2794
+ kind: 'ws-retry',
2795
+ }));
2796
+ this.showStatusMessage(UnrealStatusMessage.CONNECTING_TO_SESSION);
2797
+ Logger.info(`WS-only retry on SAME url in ${POLLING_TIME}ms. Attempt# ${attempt}.`, err?.message);
2798
+ return timer(POLLING_TIME);
2799
+ },
2800
+ }))),
2801
+ // ── URL-rotating retry tier ─────────────────────────────────────────
2802
+ // Triggers only when the WS-only retries above are exhausted. Each
2803
+ // attempt advances `urlGen` → new `getAwsInstance(url)` → new
2804
+ // orchestration poll → new AwsInstance reservation.
2708
2805
  retry({
2806
+ count: INNER_RETRY_LIMIT,
2709
2807
  delay: (err, attempt) => {
2710
2808
  this.store.dispatch(signalingRetryAttempted({
2711
2809
  attempt,
2712
2810
  reason: err?.message ?? 'unknown',
2713
2811
  kind: 'connect',
2714
2812
  }));
2715
- Logger.info(`Retrying Signalling Connection in ${POLLING_TIME}ms. Attempt# ${attempt}.`, err?.message);
2813
+ this.showStatusMessage(UnrealStatusMessage.CONNECTING_TO_SESSION);
2814
+ Logger.info(`Inner retry (WS+orch) in ${POLLING_TIME}ms. Attempt# ${attempt}.`, err?.message);
2716
2815
  return timer(POLLING_TIME);
2717
2816
  },
2718
- // count: 10, // Optional: cap the number of attempts.
2719
- }),
2720
- // abort$ sits OUTSIDE retry, so it kills the entire retry loop,
2721
- // not just the current attempt. Each inner createWebSocket also
2722
- // receives abort$ independently, so mid-connection attempts are
2723
- // canceled too.
2724
- takeUntil(this.abort$));
2817
+ }));
2725
2818
  }
2726
2819
  startEstablishingConnection() {
2727
2820
  this.showStatusMessage(UnrealStatusMessage.STARTING_YOUR_SESSION);
@@ -2736,174 +2829,114 @@ class SignallingService extends SubService {
2736
2829
  });
2737
2830
  }
2738
2831
  /**
2739
- * Resolves an active AWS *signaling* instance and returns a WebSocket endpoint.
2740
- *
2741
- * The observable produced by this method is **cold** and executes inside a `defer`,
2742
- * so each subscription (including those triggered by `retry`) pulls the **next**
2743
- * URL candidate from a generator created by `getActiveUrl(urlsPool)`.
2744
- *
2745
- * ### Execution flow
2746
- *
2747
- * 1. **Telemetry** — Starts a `getSignaling` span for end-to-end timing.
2748
- * 2. **Candidate URL selection** — A stateful generator (`urlGen`) is created
2749
- * from `urlsPool`. Each resubscription (e.g., via `retry`) advances to the next URL.
2750
- * 3. **Short-circuit for WS URLs** — If the picked candidate already matches
2751
- * `ws://` or `wss://`, emit `{ wsUrl, pollingUrl: null, instanceName: null }`
2752
- * immediately and complete (no HTTP request).
2753
- * 4. **Await client/view identifiers** — `this.store.select(selectClientAndViewIds)`
2754
- * is tapped for logging (missing IDs) and then filtered to require both `clientId`
2755
- * and `viewId` to be truthy.
2756
- * 5. **Single in-flight HTTP orchestration call** — Uses `exhaustMap` to issue
2757
- * `GET {signalingUrl}{clientId}/{viewId}`. New `{clientId, viewId}` emissions
2758
- * while the request is in flight are **ignored** until completion, preventing overlap.
2759
- * 6. **Per-attempt timeout** — `timeout(WS_TIMEOUT)` caps how long we wait for a response.
2760
- * On timeout, the attempt errors and the outer `retry` schedules the next URL.
2761
- * 7. **Response validation and progress reporting** — A `filter`:
2762
- * - Calls `TelemetryStop('getSignaling', { ...data, multi: true })` on receipt.
2763
- * - If `data.signallingServer === ''` or `data.error`:
2764
- * shows a status message, optionally dispatches `setStatusPercentSignallingServer`
2765
- * with `data.info.percent`, and **throws** to fail the attempt (triggers `retry`).
2766
- * 8. **Mapping to `AwsInstance`** — Converts the validated orchestration payload into:
2767
- * - `wsUrl`: via `httpUrlToWs(\`\${location.protocol}//\${data.signallingServer}\`)`
2768
- * - `pollingUrl`: the base `signalingUrl` that succeeded
2769
- * - `instanceName`: the (validated) `data.signallingServer`
2770
- * 9. **Retry policy** — On *any* upstream error (timeout, HTTP error, invalid payload),
2771
- * `retry({ delay })`:
2772
- * - Shows a “connecting” status,
2773
- * - Logs the attempt number,
2774
- * - Waits `WS_TIMEOUT` ms (via `timer`) and **resubscribes**, advancing `urlGen`.
2775
- *
2776
- * ### Concurrency semantics
2777
- *
2778
- * - `exhaustMap` guarantees a **single** HTTP request per attempt; later
2779
- * `{clientId, viewId}` emissions are ignored until the current request completes.
2780
- *
2781
- * ### Telemetry semantics
2782
- *
2783
- * - `trackStartCommand('EXT-getSignaling')` begins before work.
2784
- * - `trackStopCommand('getSignaling', {...})` runs on receipt of an orchestration response.
2785
- * If an attempt fails (e.g., timeout), stopping the span is the responsibility of
2786
- * your telemetry layer or a `finalize` elsewhere if desired.
2787
- *
2788
- * ### Error & retry semantics
2789
- *
2790
- * - Missing/never-emitted IDs → timeout → retry with next URL.
2791
- * - HTTP/network error → retry with next URL.
2792
- * - Invalid orchestration payload (`error` set or empty `signallingServer`) → throw in
2793
- * validation filter → retry with next URL.
2794
- * - Short-circuit WS path **does not** retry (emits once, completes).
2795
- *
2796
- * @param urlsPool - Ordered list of base URLs to probe. Retries advance through this list
2797
- * until a working signaling server is found. A candidate may also be a direct `ws://`/`wss://`
2798
- * URL to short-circuit HTTP orchestration.
2799
- *
2800
- * @returns Observable that emits exactly one {@link AwsInstance} on success and then completes.
2801
- * On failure, the stream errors; the built-in `retry` operator re-subscribes after `WS_TIMEOUT`
2802
- * and advances to the next URL candidate.
2803
- *
2804
- * @throws Emits an error within the observable chain (not a thrown synchronous exception) when:
2805
- * - The HTTP request fails or times out.
2806
- * - The orchestration response indicates an error or an empty `signallingServer`.
2807
- * - Any other operator in the chain surfaces an error.
2808
- *
2809
- * @remarks
2810
- * - The URL generator (`urlGen`) is created **outside** `defer`, so resubscriptions
2811
- * advance the pool. Creating it inside `defer` would restart from the first URL each retry.
2812
- * - `location.protocol` is used to derive `ws` vs. `wss`. If this may run in non-browser
2813
- * contexts (e.g., SSR), guard or abstract this logic.
2814
- * - Consider capping retries with `count` in `retry({ count, delay })` when appropriate.
2815
- *
2816
- * @example
2817
- * ```ts
2818
- * getAwsInstance(['https://signaling.example.com/', 'wss://direct.example.com'])
2819
- * .subscribe({
2820
- * next: ({ wsUrl }) => connect(wsUrl),
2821
- * error: (e) => console.error('Unable to establish signaling:', e),
2822
- * });
2823
- * ```
2824
- *
2825
- * @see httpUrlToWs
2826
- * @see selectClientAndViewIds
2827
- * @see setStatusPercentSignallingServer
2832
+ * Resolve one URL to an `AwsInstance`. Dispatches to
2833
+ * {@link directWsInstance} for `ws://` / `wss://` URLs, otherwise
2834
+ * to {@link pollOrchestration}.
2828
2835
  */
2829
- getAwsInstance(urlsPool) {
2830
- this.commandTelemetryService.trackStartCommand('EXT-getSignaling');
2831
- // Stateful iterator over candidate URLs; retries advance this sequence.
2832
- const urlGen = getActiveUrl(urlsPool);
2836
+ getAwsInstance(url) {
2837
+ return url.match(/^ws(s?):\/\//i)
2838
+ ? this.directWsInstance(url)
2839
+ : this.pollOrchestration(url);
2840
+ }
2841
+ /**
2842
+ * Direct `ws://` / `wss://` short-circuit — no HTTP orchestration.
2843
+ * Waits for `clientId` and `viewId` to be truthy in the store, then
2844
+ * emits a `Direct Connection` `AwsInstance` and completes.
2845
+ */
2846
+ directWsInstance(url) {
2847
+ return this.store.select(selectClientAndViewIds).pipe(filter(({ clientId, viewId }) => !!clientId && !!viewId), take(1), map$1(() => ({
2848
+ wsUrl: url,
2849
+ pollingUrl: null,
2850
+ instanceName: 'Direct Connection',
2851
+ })));
2852
+ }
2853
+ /**
2854
+ * HTTP-orchestration path. Brackets {@link pollLoop} with the
2855
+ * `pollingStarted`/`pollingEnded` actions and the `EXT-getSignaling`
2856
+ * telemetry span (start in `defer`, stop in `finalize` with the
2857
+ * last response captured in the closure). Maps the final ready
2858
+ * response to an `AwsInstance`.
2859
+ */
2860
+ pollOrchestration(url) {
2833
2861
  return defer(() => {
2834
- const signalingUrl = `${urlGen.next().value}`;
2835
- // Short-circuit if the candidate is already a WS endpoint.
2836
- if (signalingUrl.match(/^ws(s?):\/\//i)) {
2837
- return this.store.select(selectClientAndViewIds).pipe(filter(({ clientId, viewId }) => !!clientId && !!viewId), map$1(() => {
2838
- return {
2839
- wsUrl: signalingUrl,
2840
- pollingUrl: null,
2841
- instanceName: 'Direct Connection',
2842
- };
2843
- }));
2844
- }
2845
- // Old (HTTP-polling) orchestration flow — mark the round trip so
2846
- // the signaling-phase timeline can show it as a distinct segment.
2862
+ let lastResp;
2847
2863
  this.store.dispatch(pollingStarted());
2848
- return this.store.select(selectClientAndViewIds).pipe(
2849
- // Log missing IDs without altering the stream.
2850
- tap(({ clientId, viewId }) => {
2864
+ this.commandTelemetryService.trackStartCommand('EXT-getSignaling');
2865
+ return this.store.select(selectClientAndViewIds).pipe(tap(({ clientId, viewId }) => {
2851
2866
  if (!clientId || !viewId) {
2852
2867
  Logger.error('Client ID or View ID is not set');
2853
2868
  }
2854
- }),
2855
- // Only proceed when both identifiers are available.
2856
- filter(({ clientId, viewId }) => !!clientId && !!viewId),
2857
- // Take first value
2858
- take(1),
2859
- // Ensure a single in-flight HTTP request per attempt.
2860
- exhaustMap(({ clientId, viewId }) => this.httpClient.get(`${signalingUrl}${clientId}/${viewId}`)),
2861
- // Bound the time spent waiting for the orchestration response.
2862
- timeout(POLLING_TIME),
2863
- // Validate response; report progress; fail fast on invalid payloads.
2864
- filter((data) => {
2865
- this.commandTelemetryService.trackStopCommand('EXT-getSignaling', true, { ...data, multi: true });
2866
- if (data.signallingServer === '' || data.error) {
2867
- this.showStatusMessage(data?.mm_message || 'Server not found');
2868
- if (data?.info) {
2869
- this.store.dispatch(setStatusPercentSignallingServer({
2870
- percent: data.info.percent,
2871
- }));
2872
- }
2873
- // Throwing here fails the attempt and activates the retry strategy.
2874
- throw new Error(`Orchestration message => ${data?.mm_message || 'Polling retry'}`);
2875
- }
2876
- return true;
2877
- }),
2878
- // Map a valid orchestration payload into the final AwsInstance.
2879
- map$1((data) => {
2869
+ }), filter(({ clientId, viewId }) => !!clientId && !!viewId), take(1), switchMap(({ clientId, viewId }) => this.pollLoop(`${url}${clientId}/${viewId}`).pipe(tap((data) => {
2870
+ lastResp = data;
2871
+ }))), map$1((data) => ({
2872
+ wsUrl: httpUrlToWs(`${location.protocol}//${data.signallingServer}`),
2873
+ pollingUrl: url,
2874
+ instanceName: data.signallingServer ?? '',
2875
+ })), finalize(() => {
2876
+ this.commandTelemetryService.trackStopCommand('EXT-getSignaling', true, { ...(lastResp ?? {}), multi: true });
2880
2877
  this.store.dispatch(pollingEnded());
2881
- const wsUrl = httpUrlToWs(`${location.protocol}//${data.signallingServer}`);
2882
- return {
2883
- wsUrl,
2884
- pollingUrl: signalingUrl,
2885
- instanceName: data.signallingServer ?? '',
2886
- };
2887
2878
  }));
2888
- }).pipe(
2889
- // Retry the whole attempt on error, advancing to the next URL each time.
2890
- retry({
2891
- delay: (err, attempt) => {
2892
- this.store.dispatch(signalingRetryAttempted({
2893
- attempt,
2894
- reason: err?.message ?? 'unknown',
2895
- kind: 'orchestration',
2879
+ });
2880
+ }
2881
+ /**
2882
+ * Poll the SAME `url` until ready, or until HTTP 4xx says the URL
2883
+ * is dead.
2884
+ *
2885
+ * | Response | Class | Action |
2886
+ * | ---------------------------------------- | ---------- | ---------------------------- |
2887
+ * | 200 `{ signallingServer: 'cirrus', ... }`| ✅ Ready | `first()` emits, loop ends |
2888
+ * | 200 empty `signallingServer` (any error/info/mm_message) | ⏳ Progress | Dispatch status+percent, re-poll same URL |
2889
+ * | HTTP 4xx (except 429) | ❌ Hard | Re-throw → caller rotates URL |
2890
+ * | HTTP 5xx / 429 / timeout / network | ⚠️ Soft | `EMPTY` → `repeat` same URL |
2891
+ *
2892
+ * `data.error` is NOT treated as fatal — the backend reuses it for
2893
+ * progress strings like `"Ready in ~2m 23s"`. The only fatal exits
2894
+ * are HTTP 4xx and `abort$` upstream.
2895
+ */
2896
+ pollLoop(url) {
2897
+ return defer(() => this.httpClient.get(url)).pipe(timeout(POLLING_TIME), catchError((err) => {
2898
+ // HTTP 4xx (except 429) = the URL itself is dead → rotate.
2899
+ const status = err?.status;
2900
+ if (typeof status === 'number' &&
2901
+ status >= 400 &&
2902
+ status < 500 &&
2903
+ status !== 429) {
2904
+ Logger.warn(`Polling HARD error on ${url} (HTTP ${status}); rotating URL`);
2905
+ return throwError(() => err);
2906
+ }
2907
+ // Everything else = soft → re-poll same URL.
2908
+ Logger.warn(`Polling soft error on ${url} (${err?.message ?? err}); re-polling same URL`);
2909
+ return EMPTY;
2910
+ }), tap((data) => {
2911
+ // Ready response is handled by `first()` below — nothing to
2912
+ // dispatch here. Empty `signallingServer` = progress.
2913
+ if (data.signallingServer)
2914
+ return;
2915
+ this.showStatusMessage(data?.mm_message || 'Server not found');
2916
+ if (data?.info) {
2917
+ this.store.dispatch(setStatusPercentSignallingServer({
2918
+ percent: data.info.percent,
2896
2919
  }));
2897
- this.showStatusMessage(UnrealStatusMessage.CONNECTING_TO_SESSION);
2898
- Logger.log(`Retrying with new URL in ${POLLING_TIME}ms. Attempt# ${attempt}`, err?.message);
2899
- return timer(POLLING_TIME);
2900
- },
2901
- // count: 10, // Optional: cap the number of attempts.
2902
- }));
2920
+ }
2921
+ }),
2922
+ // Re-subscribe (= poll again) after each completed response OR
2923
+ // after `catchError` swallowed a transient failure.
2924
+ repeat({ delay: () => timer(POLLING_TIME) }),
2925
+ // Stop the poll loop as soon as we get a ready response.
2926
+ first((data) => !!data.signallingServer));
2903
2927
  }
2904
2928
  connectToCirrus(wsUrl) {
2905
- this.close({ code: 1000, reason: DisconnectReason.NewConnectionAttempt });
2906
- return createWebSocket(wsUrl).pipe(takeUntil(this.abort$), tapLog('WS CREATED'), tap((ws) => {
2929
+ // Wrapped in `defer` so each subscription (including ws-retry
2930
+ // re-subscriptions from `connectAttempt`) starts a FRESH WS attempt:
2931
+ // the previous socket is closed and a new one is created. Without
2932
+ // this, the WS-only retry would just re-subscribe the SAME WS
2933
+ // observable, which in production-defer'd `createWebSocket` would
2934
+ // still create a new socket, but in tests would re-emit the same
2935
+ // (already-errored) mock observable.
2936
+ return defer(() => {
2937
+ this.close({ code: 1000, reason: DisconnectReason.NewConnectionAttempt });
2938
+ return createWebSocket(wsUrl);
2939
+ }).pipe(takeUntil(this.abort$), tapLog('WS CREATED'), tap((ws) => {
2907
2940
  this.commandTelemetryService.trackStartCommand('EXT-iceCandidate');
2908
2941
  this.addWsHandlers(ws);
2909
2942
  }));
@@ -3004,7 +3037,7 @@ class SignallingService extends SubService {
3004
3037
  * Check if the WebSocket connection is open and ready.
3005
3038
  */
3006
3039
  isConnected() {
3007
- return this.ws && this.ws.readyState === WS_OPEN_STATE;
3040
+ return !!(this.ws && this.ws.readyState === WS_OPEN_STATE);
3008
3041
  }
3009
3042
  getRegion() {
3010
3043
  Logger.log('Start Getting Region');
@@ -3042,6 +3075,7 @@ class WebRtcPlayerService extends SubService {
3042
3075
  this.actions$
3043
3076
  .pipe(ofType(dropPC), takeUntil(this.destroy$))
3044
3077
  .subscribe(() => this.pcClient?.close());
3078
+ this.destroyRef.onDestroy(() => this.closePC());
3045
3079
  }
3046
3080
  init() {
3047
3081
  this.signallingSrv.onConfig$
@@ -3176,7 +3210,7 @@ class WebRtcPlayerService extends SubService {
3176
3210
  this.commandTelemetryService.trackStartCommand('EXT-Browser ICE connection');
3177
3211
  this.commandTelemetryService.trackStartCommand('EXT-Signaling state change');
3178
3212
  this.handlePeerConnectionEvents(this.pcClient);
3179
- this.disconnect$.pipe(take(1)).subscribe(() => {
3213
+ this.disconnect$.pipe(take(1), takeUntil(this.destroy$)).subscribe(() => {
3180
3214
  this.closePC();
3181
3215
  });
3182
3216
  }
@@ -3383,39 +3417,39 @@ class WebRtcPlayerService extends SubService {
3383
3417
  }
3384
3418
  handlePeerConnectionEvents(pcClient) {
3385
3419
  fromEvent(pcClient, 'signalingstatechange')
3386
- .pipe(map$1(() => this.pcClient?.signalingState), takeUntil(this.disconnect$))
3420
+ .pipe(map$1(() => this.pcClient?.signalingState), takeUntil(merge(this.disconnect$, this.destroy$)))
3387
3421
  .subscribe((state) => {
3388
3422
  Logger.info('[Signaling state change] |', state, '|');
3389
3423
  this.commandTelemetryService.trackStopCommand('EXT-Signaling state change', true, { state, multi: true });
3390
3424
  });
3391
3425
  fromEvent(pcClient, 'iceconnectionstatechange')
3392
- .pipe(map$1(() => this.pcClient?.iceConnectionState), takeUntil(this.disconnect$))
3426
+ .pipe(map$1(() => this.pcClient?.iceConnectionState), takeUntil(merge(this.disconnect$, this.destroy$)))
3393
3427
  .subscribe((state) => {
3394
3428
  Logger.info('[Browser ICE connection] |', state, '|');
3395
3429
  this.commandTelemetryService.trackStopCommand('EXT-Browser ICE connection', true, { state, multi: true });
3396
3430
  });
3397
3431
  fromEvent(pcClient, 'icegatheringstatechange')
3398
- .pipe(map$1(() => this.pcClient?.iceGatheringState), takeUntil(this.disconnect$))
3432
+ .pipe(map$1(() => this.pcClient?.iceGatheringState), takeUntil(merge(this.disconnect$, this.destroy$)))
3399
3433
  .subscribe((state) => {
3400
3434
  Logger.info('[Browser ICE gathering] |', state, '|');
3401
3435
  this.commandTelemetryService.trackStopCommand('EXT-Browser ICE gathering', true, { state, multi: true });
3402
3436
  });
3403
3437
  fromEvent(pcClient, 'connectionstatechange')
3404
- .pipe(map$1(() => this.pcClient?.connectionState), takeUntil(this.disconnect$))
3438
+ .pipe(map$1(() => this.pcClient?.connectionState), takeUntil(merge(this.disconnect$, this.destroy$)))
3405
3439
  .subscribe((state) => {
3406
3440
  Logger.info('[RTCPeerConnection state] |', state, '|');
3407
3441
  });
3408
3442
  fromEvent(pcClient, 'icecandidate')
3409
- .pipe(map$1((e) => e.candidate), filter(Truthy), tap$1((candidate) => Logger.info('[Browser ICE candidate]', '| Type=', candidate.type, '| Protocol=', candidate.protocol, '| Address=', candidate.address, '| Port=', candidate.port, '|')), takeUntil(this.disconnect$))
3443
+ .pipe(map$1((e) => e.candidate), filter(Truthy), tap$1((candidate) => Logger.info('[Browser ICE candidate]', '| Type=', candidate.type, '| Protocol=', candidate.protocol, '| Address=', candidate.address, '| Port=', candidate.port, '|')), takeUntil(merge(this.disconnect$, this.destroy$)))
3410
3444
  .subscribe((candidate) => this.signallingSrv.send({ type: 'iceCandidate', candidate }));
3411
3445
  // When the server creates the DataChannel (server-offer flow),
3412
3446
  // we receive it here via the 'datachannel' event
3413
3447
  fromEvent(pcClient, 'datachannel')
3414
- .pipe(tap$1((e) => Logger.info(`[DATACHANNEL] Received remote DC: label="${e.channel.label}" id=${e.channel.id} protocol="${e.channel.protocol}" ordered=${e.channel.ordered}`)), map$1((e) => e.channel), takeUntil(this.disconnect$))
3448
+ .pipe(tap$1((e) => Logger.info(`[DATACHANNEL] Received remote DC: label="${e.channel.label}" id=${e.channel.id} protocol="${e.channel.protocol}" ordered=${e.channel.ordered}`)), map$1((e) => e.channel), takeUntil(merge(this.disconnect$, this.destroy$)))
3415
3449
  .subscribe((datachannel) => {
3416
3450
  this.dcClient = this.setupDataChannelCallbacks(datachannel);
3417
3451
  });
3418
- const onTrack$ = fromEvent(pcClient, 'track').pipe(takeUntil(this.disconnect$), filter((e) => !!e.track), tap$1((e) => Logger.info('Got track - ' +
3452
+ const onTrack$ = fromEvent(pcClient, 'track').pipe(takeUntil(merge(this.disconnect$, this.destroy$)), filter((e) => !!e.track), tap$1((e) => Logger.info('Got track - ' +
3419
3453
  e.track.kind +
3420
3454
  ' id=' +
3421
3455
  e.track.id +
@@ -3496,7 +3530,7 @@ class WebRtcPlayerService extends SubService {
3496
3530
  fromEvent(datachannel, 'close')
3497
3531
  .pipe(
3498
3532
  // Only dispatch disconnect if not already reconnecting to avoid infinite loops
3499
- filter(() => !this.isReconnecting()), take(1))
3533
+ filter(() => !this.isReconnecting()), take(1), takeUntil(this.destroy$))
3500
3534
  .subscribe((e) => {
3501
3535
  Logger.warn(`[DATACHANNEL] Data channel disconnected: ${datachannel.label}(${datachannel.id})`, e);
3502
3536
  this.store.dispatch(disconnectStream({
@@ -3505,7 +3539,7 @@ class WebRtcPlayerService extends SubService {
3505
3539
  }));
3506
3540
  });
3507
3541
  fromEvent(datachannel, 'error')
3508
- .pipe(takeUntil(this.disconnect$))
3542
+ .pipe(takeUntil(merge(this.disconnect$, this.destroy$)))
3509
3543
  .subscribe((e) => {
3510
3544
  const message = `[DATACHANNEL] Data channel "${datachannel.label}" error. id:${datachannel.id}`;
3511
3545
  Logger.error(message, e);
@@ -3513,7 +3547,7 @@ class WebRtcPlayerService extends SubService {
3513
3547
  fromEvent(datachannel, 'open')
3514
3548
  .pipe(tap$1(() => {
3515
3549
  Logger.info(`[DATACHANNEL] Data channel "${datachannel.label}" connected. id:${datachannel.id}`);
3516
- }), takeUntil(this.disconnect$))
3550
+ }), takeUntil(merge(this.disconnect$, this.destroy$)))
3517
3551
  .subscribe(() => {
3518
3552
  this.commandTelemetryService.trackStopCommand('EXT-DataChannel Connection');
3519
3553
  this.onDataChannelConnected();
@@ -3526,7 +3560,7 @@ class WebRtcPlayerService extends SubService {
3526
3560
  `[DATACHANNEL] Message received on "${datachannel.label}" id=${datachannel.id}, byteCode=${view[0]}, size=${view.byteLength}`,
3527
3561
  );
3528
3562
  }),*/
3529
- map$1((e) => e.data), takeUntil(this.disconnect$))
3563
+ map$1((e) => e.data), takeUntil(merge(this.disconnect$, this.destroy$)))
3530
3564
  .subscribe((data) => this.onDataChannelMessage$.next(data));
3531
3565
  return datachannel;
3532
3566
  }
@@ -4025,25 +4059,18 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImpo
4025
4059
  }]
4026
4060
  }] });
4027
4061
 
4028
- class UnrealCommunicatorService {
4062
+ class UnrealCommunicatorService extends SubService {
4029
4063
  constructor() {
4030
- this.store = inject(Store);
4031
- this.actions$ = inject(Actions);
4064
+ super();
4032
4065
  this.commandTelemetryService = inject(CommandTelemetryService);
4033
4066
  this.webRtcPlayerService = inject(WebRtcPlayerService);
4034
4067
  this.unrealConfig = inject(UNREAL_CONFIG, { optional: true });
4035
- this.destroyRef = inject(DestroyRef);
4036
- this.destroy$ = new Subject();
4037
4068
  this.cirrusConnected = this.store.selectSignal(unrealFeature.selectCirrusConnected);
4038
4069
  this.afkIgnoredCommands = new Set(['executeConsoleCommand']);
4039
4070
  this.skipAfkReset = false;
4040
4071
  // Throttle: input events fire 60-200x/sec, but AFK timeouts are measured in
4041
4072
  // seconds. Leading-edge throttle dispatches once, then suppresses for 1s.
4042
4073
  this.resetAfk$ = new Subject();
4043
- this.destroyRef.onDestroy(() => {
4044
- this.destroy$.next();
4045
- this.destroy$.complete();
4046
- });
4047
4074
  this.resetAfk$
4048
4075
  .pipe(throttleTime(1000), takeUntil$1(this.destroy$))
4049
4076
  .subscribe(() => this.store.dispatch(resetAfk()));
@@ -4065,12 +4092,6 @@ class UnrealCommunicatorService {
4065
4092
  this.requestQualityControl();
4066
4093
  });
4067
4094
  }
4068
- destroy() {
4069
- this.destroy$.next();
4070
- // Recreate the Subject so it remains usable for the next session.
4071
- // A completed Subject silently ignores all future next/complete calls.
4072
- this.destroy$ = new Subject();
4073
- }
4074
4095
  sendCommandToUnreal(data) {
4075
4096
  if (this.isPlaywrightMode()) {
4076
4097
  return;
@@ -4655,15 +4676,21 @@ class RegionsPingService {
4655
4676
  constructor() {
4656
4677
  this.httpClient = inject(HttpClient);
4657
4678
  this.store = inject(Store);
4658
- this.unrealInitialConfig = inject(UNREAL_CONFIG, { optional: true });
4679
+ this.unrealConfig = inject(UNREAL_CONFIG, { optional: true });
4680
+ this.destroyRef = inject(DestroyRef);
4681
+ // Aborted when the injector is destroyed. Forwarded into every
4682
+ // `startPinging` call's winnerController so in-flight fetches cancel
4683
+ // immediately on route exit instead of running to natural completion.
4684
+ this.destroyController = new AbortController();
4659
4685
  this.config = {
4660
4686
  ping_count: 2,
4661
4687
  ping_timeout: 2000,
4662
4688
  max_parallel_requests: 10,
4663
4689
  bust_cache: true,
4664
4690
  };
4691
+ this.destroyRef.onDestroy(() => this.destroyController.abort());
4665
4692
  }
4666
- getFastest(regionListUrl = this.unrealInitialConfig?.regionsPingUrl || '') {
4693
+ getFastest(regionListUrl = this.unrealConfig?.regionsPingUrl || '') {
4667
4694
  // Self-announce every lifecycle waypoint of the region-discovery
4668
4695
  // flow (start / providers-fetched / resolved). The final
4669
4696
  // `regionResolved` forwards the winner's full ping-sample array
@@ -4680,15 +4707,32 @@ class RegionsPingService {
4680
4707
  const defaultTimeout = providers.timeout ?? this.config.ping_timeout;
4681
4708
  return from(this.startPinging(providers.regions, defaultTimeout));
4682
4709
  }
4683
- getProviders(regionListUrl = this.unrealInitialConfig?.regionsPingUrl || '') {
4710
+ getProviders(regionListUrl = this.unrealConfig?.regionsPingUrl || '') {
4684
4711
  return this.httpClient.get(regionListUrl);
4685
4712
  }
4686
4713
  async startPinging(endpoints, defaultTimeout) {
4687
4714
  const semaphore = new Semaphore(this.config.max_parallel_requests);
4715
+ // Shared "stop" signal. Aborted when EITHER:
4716
+ // - one region finishes successfully (winner found → cancel losers); OR
4717
+ // - the service's injector is destroyed (route exit → cancel everything).
4718
+ // Without this, losing regions keep pinging to their own `ping_timeout`,
4719
+ // generating up to `(endpoints.length - 1) × ping_count` wasted requests
4720
+ // every time `getFastest` runs, and in-flight fetches outlive the route.
4721
+ const winnerController = new AbortController();
4722
+ const destroySignal = this.destroyController.signal;
4723
+ let onDestroyAbort;
4724
+ if (destroySignal.aborted) {
4725
+ // Service already destroyed — bail every task synchronously.
4726
+ winnerController.abort(destroySignal.reason);
4727
+ }
4728
+ else {
4729
+ onDestroyAbort = () => winnerController.abort(destroySignal.reason);
4730
+ destroySignal.addEventListener('abort', onDestroyAbort, { once: true });
4731
+ }
4688
4732
  const tasks = endpoints.map(async ({ region_code, url, timeout }) => {
4689
4733
  await semaphore.acquire();
4690
4734
  try {
4691
- const result = await this.checkLatency(url, timeout ?? defaultTimeout);
4735
+ const result = await this.checkLatency(url, timeout ?? defaultTimeout, winnerController.signal);
4692
4736
  if (result.length < 2)
4693
4737
  throw new Error('insufficient pings');
4694
4738
  return { region_code, result };
@@ -4703,11 +4747,26 @@ class RegionsPingService {
4703
4747
  catch {
4704
4748
  return null;
4705
4749
  }
4750
+ finally {
4751
+ // Cancel losing in-flight pings. Safe to call on success (we already
4752
+ // captured the winner) AND on all-failed (no-op, all tasks settled).
4753
+ winnerController.abort();
4754
+ // Detach the destroy listener so it doesn't linger on the long-lived
4755
+ // destroyController.signal across multiple `getFastest` invocations.
4756
+ if (onDestroyAbort) {
4757
+ destroySignal.removeEventListener('abort', onDestroyAbort);
4758
+ }
4759
+ }
4706
4760
  }
4707
- async checkLatency(endpoint, timeout) {
4761
+ async checkLatency(endpoint, timeout, abortSignal) {
4708
4762
  const pingResults = [];
4709
4763
  const sep = endpoint.includes('?') ? '&' : '?';
4710
4764
  for (let i = 0; i < this.config.ping_count; i++) {
4765
+ // Bail out as soon as another region's task has won — no point
4766
+ // burning further `ping_timeout` budget here.
4767
+ if (abortSignal?.aborted) {
4768
+ break;
4769
+ }
4711
4770
  const startTime = performance.now();
4712
4771
  try {
4713
4772
  await this.fetchWithTimeout(this.config.bust_cache
@@ -4721,7 +4780,7 @@ class RegionsPingService {
4721
4780
  Expires: '0',
4722
4781
  },
4723
4782
  credentials: 'omit',
4724
- }, timeout);
4783
+ }, timeout, abortSignal);
4725
4784
  }
4726
4785
  catch {
4727
4786
  // intentional: finally pushes the elapsed time as the latency sample
@@ -4733,18 +4792,31 @@ class RegionsPingService {
4733
4792
  }
4734
4793
  return pingResults;
4735
4794
  }
4736
- async fetchWithTimeout(url, options = {}, timeout) {
4795
+ async fetchWithTimeout(url, options = {}, timeout, abortSignal) {
4737
4796
  const controller = new AbortController();
4738
4797
  options.signal = controller.signal;
4798
+ // Forward an external abort into our local controller so a winner-found signal cancels the in-flight `fetch` immediately. If the
4799
+ // external signal is already aborted, abort synchronously before
4800
+ // even starting the fetch.
4801
+ let onExternalAbort;
4802
+ if (abortSignal) {
4803
+ if (abortSignal.aborted) {
4804
+ controller.abort(abortSignal.reason);
4805
+ }
4806
+ else {
4807
+ onExternalAbort = () => controller.abort(abortSignal.reason);
4808
+ abortSignal.addEventListener('abort', onExternalAbort, { once: true });
4809
+ }
4810
+ }
4739
4811
  const timeoutId = setTimeout(() => controller.abort(new DOMException('Request timed out', 'TimeoutError')), timeout);
4740
4812
  try {
4741
- const response = await fetch(url, options);
4742
- clearTimeout(timeoutId);
4743
- return response;
4813
+ return await fetch(url, options);
4744
4814
  }
4745
- catch (error) {
4815
+ finally {
4746
4816
  clearTimeout(timeoutId);
4747
- throw error;
4817
+ if (abortSignal && onExternalAbort) {
4818
+ abortSignal.removeEventListener('abort', onExternalAbort);
4819
+ }
4748
4820
  }
4749
4821
  }
4750
4822
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: RegionsPingService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
@@ -4752,7 +4824,7 @@ class RegionsPingService {
4752
4824
  }
4753
4825
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: RegionsPingService, decorators: [{
4754
4826
  type: Injectable
4755
- }] });
4827
+ }], ctorParameters: () => [] });
4756
4828
 
4757
4829
  class StreamStatusTelemetryService extends SubService {
4758
4830
  constructor() {
@@ -5711,7 +5783,7 @@ class AnalyticsService extends SubService {
5711
5783
  });
5712
5784
  }
5713
5785
  monitorMouse() {
5714
- const stop$ = this.#stopMouse$.asObservable();
5786
+ const stop$ = merge(this.#stopMouse$, this.destroy$);
5715
5787
  const element = document.getElementById('streamingVideo');
5716
5788
  if (!element) {
5717
5789
  return;
@@ -7047,5 +7119,5 @@ function provideUnrealModule() {
7047
7119
  * Generated bundle index. Do not edit.
7048
7120
  */
7049
7121
 
7050
- 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 };
7122
+ 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 };
7051
7123
  //# sourceMappingURL=3dsource-angular-unreal-module.mjs.map